Homeβ€Ί Pythonβ€Ί Python range() Explained: Stop Writing Broken Loops Forever

Python range() Explained: Stop Writing Broken Loops Forever

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: Python Basics β†’ Topic 15 of 15
Python range() function explained from first principles β€” syntax, step values, reverse loops, and the off-by-one bug that breaks production code daily.
πŸ§‘β€πŸ’» Beginner-friendly β€” no prior Python experience needed
In this tutorial, you'll learn:
  • range() is not a list β€” it's a lazy rule stored as three integers. range(1_000_000) costs 48 bytes no matter what. Converting it to list() throws away the only reason to use it.
  • Stop is always exclusive. Every time. No exceptions. range(0, 5) gives you 0,1,2,3,4. Burn this into muscle memory and you kill 90% of off-by-one bugs before they happen.
  • Reach for range() when you need a counted loop, index generation, or pagination offsets. The moment you're writing collection[i] just to read values, switch to direct iteration or enumerate() β€” range(len(x)) for reading is a code smell that linters will flag.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑ Quick Answer
Imagine you're a factory floor supervisor telling a worker: 'Start at station 3, work through to station 9, skip every other station.' You're not handing them a list of stations β€” you're giving them a rule they follow as they go. That's range(). It's an instruction set for counting, not a physical list of numbers sitting in memory. Your program follows the rule step by step without ever storing all the numbers at once β€” which is why range(1, 1000000) doesn't eat your RAM the way a list of a million numbers would.

The off-by-one error killed a batch job at a fintech I consulted for β€” 99,999 records processed instead of 100,000, one customer's end-of-month statement never generated, and nobody noticed for six weeks because the count was 'close enough' to pass the eyeball test. The culprit was a misunderstood range() call. One wrong number. Six weeks of silent wrong data.

range() is the engine behind almost every loop you write in Python. Get it wrong and your loops silently skip data, process one record too many, or run forever. Get it right and you have precise, memory-efficient control over iteration that scales from five items to five billion without changing a single line of logic. This isn't academic β€” every data pipeline, every retry loop, every pagination handler in Python touches range().

By the end of this, you'll know exactly how range() works under the hood, why it doesn't store numbers in memory, how to count backwards without a hacky workaround, and the three specific mistakes that cause silent data corruption in production loops. You'll write range() calls with confidence and spot broken ones in code review on sight.

What range() Actually Is (And Why It's Not a List)

Before range() existed in its modern form, Python 2 had a function called range() that returned an actual list and a separate function called xrange() that returned a lazy iterator. Developers who needed to loop a million times with the old range() would inadvertently build a list of a million integers in memory just to count β€” pure waste. Python 3 collapsed them: range() is now always lazy. It never builds the full list. It just remembers three numbers β€” start, stop, step β€” and calculates each value on demand.

This matters the moment you're paginating through a database result set, iterating over file offsets, or running a retry loop in a distributed system. You're not paying memory cost for the count β€” you're paying only for the current position.

Think of range() as a bookmark rule, not a bookshelf. 'Start at page 10, read every third page, stop before page 40' β€” you don't photocopy those pages in advance. You just follow the rule as you go. That's the exact mental model. range() is the rule. The loop is you following it.

RangeMemoryDemo.py Β· PYTHON
1234567891011121314151617181920212223242526
# io.thecodeforge β€” Python tutorial

import sys

# A list of 1 million integers β€” Python builds every single number in memory right now
million_list = list(range(1_000_000))

# A range object covering the same 1 million numbers β€” stores only start, stop, step
million_range = range(1_000_000)

# See the memory difference for yourself
list_size_bytes = sys.getsizeof(million_list)
range_size_bytes = sys.getsizeof(million_range)

print(f"list of 1,000,000 ints : {list_size_bytes:,} bytes")
print(f"range(1,000,000)       : {range_size_bytes} bytes")

# range() is a sequence type β€” you can index it, slice it, check membership
page_offsets = range(0, 10_000, 250)  # database pagination: start=0, stop=10000, step=250

print(f"\nFirst page offset  : {page_offsets[0]}")
print(f"Second page offset : {page_offsets[1]}")
print(f"Last page offset   : {page_offsets[-1]}")
print(f"Total pages        : {len(page_offsets)}")
print(f"Is offset 500 valid page start? {500 in page_offsets}")
print(f"Is offset 501 valid page start? {501 in page_offsets}")
β–Ά Output
list of 1,000,000 ints : 8,000,056 bytes
range(1,000,000) : 48 bytes

First page offset : 0
Second page offset : 250
Last page offset : 9750
Total pages : 40
Is offset 500 valid page start? True
Is offset 501 valid page start? False
⚠️
Production Trap: wrapping range() in list() defeats the pointI've seen list(range(n)) used in production code 'to make it easier to debug.' That immediately materialises every integer into memory. On a worker processing 50 million rows, that's ~400MB of integers doing nothing. Keep it as range() β€” it's already indexable, sliceable, and membership-testable without converting it.

The Three-Argument Syntax: Start, Stop, Step Without Guessing

Every range() confusion in the wild traces back to one of two things: forgetting that stop is exclusive, or not knowing that step exists. Here's the full syntax once and for all: range(start, stop, step). Start is where counting begins. Stop is where counting ends β€” but the stop value itself is never included. Step is how much to add each time.

When you write range(5), Python treats it as range(0, 5, 1) β€” start defaults to 0, step defaults to 1. That's why range(5) gives you 0, 1, 2, 3, 4 β€” five values, none of them 5. This isn't arbitrary. It means range(len(my_list)) always gives you exactly the valid indices for that list. No off-by-one. By design.

The step argument is where range() earns its keep beyond toy loops. Batch processing every Nth record, building retry delays that increase by a fixed interval, checking every even-numbered port in a range β€” these all need step. And for counting backwards, you use a negative step. There's no reverse() call needed, no subtraction gymnastics. Just a negative number.

RetryBackoffScheduler.py Β· PYTHON
123456789101112131415161718192021222324252627282930313233343536
# io.thecodeforge β€” Python tutorial

# --- Scenario: A payment processor retry scheduler ---
# We need to retry a failed charge at increasing intervals.
# Retry at seconds: 5, 10, 15, 20, 25 (linear backoff, max 5 retries)

MAX_RETRIES = 5
BASE_DELAY_SECONDS = 5

print("=== Linear Backoff Retry Schedule ===")
for attempt_number in range(1, MAX_RETRIES + 1):  # range(1, 6) β€” gives 1,2,3,4,5
    delay = attempt_number * BASE_DELAY_SECONDS
    print(f"Attempt {attempt_number}: retry after {delay}s")

# --- Scenario: Processing a leaderboard in reverse rank order ---
# Display rank 10 down to rank 1 (countdown-style)

print("\n=== Leaderboard Countdown ===")
for rank in range(10, 0, -1):  # start=10, stop=0 (exclusive), step=-1
    print(f"Rank #{rank}")

# --- Scenario: Batch database writes, 100 records at a time ---
TOTAL_RECORDS = 450
BATCH_SIZE = 100

print("\n=== Batch Write Boundaries ===")
for batch_start in range(0, TOTAL_RECORDS, BATCH_SIZE):  # 0, 100, 200, 300, 400
    # min() prevents overshooting on the final partial batch
    batch_end = min(batch_start + BATCH_SIZE, TOTAL_RECORDS)
    print(f"Writing records {batch_start} to {batch_end - 1}")

# --- Demonstrating stop is ALWAYS exclusive ---
print("\n=== Stop Is Exclusive β€” Always ===")
print(f"range(0, 5)    β†’ {list(range(0, 5))}")    # 5 is never included
print(f"range(1, 6)    β†’ {list(range(1, 6))}")    # useful when you want 1-5
print(f"range(5, 0, -1)β†’ {list(range(5, 0, -1))}") # counts down but stops before 0
β–Ά Output
=== Linear Backoff Retry Schedule ===
Attempt 1: retry after 5s
Attempt 2: retry after 10s
Attempt 3: retry after 15s
Attempt 4: retry after 20s
Attempt 5: retry after 25s

=== Leaderboard Countdown ===
Rank #10
Rank #9
Rank #8
Rank #7
Rank #6
Rank #5
Rank #4
Rank #3
Rank #2
Rank #1

=== Batch Write Boundaries ===
Writing records 0 to 99
Writing records 100 to 199
Writing records 200 to 299
Writing records 300 to 399
Writing records 400 to 449

=== Stop Is Exclusive β€” Always ===
range(0, 5) β†’ [0, 1, 2, 3, 4]
range(1, 6) β†’ [1, 2, 3, 4, 5]
range(5, 0, -1)β†’ [5, 4, 3, 2, 1]
⚠️
Senior Shortcut: range(len(x)) is often a code smellIf you're writing for i in range(len(my_list)): and then using my_list[i] inside, stop. Use enumerate(my_list) instead β€” you get the index and the value together, with no risk of an index-out-of-range bug. Reserve range(len(x)) for cases where you genuinely need only the index and not the value, or when you need to index multiple lists in lockstep.

Off-By-One Errors: The Exact Bug Pattern That Corrupts Production Data

Off-by-one errors with range() are insidious because the code runs β€” no exception, no crash, no obvious failure. You just silently process one record too few or too many. The fintech story I opened with? Exactly this. A developer wrote range(1, record_count) when they meant range(0, record_count). Record at index 0 never processed. Six weeks of silent corruption.

There are exactly three failure modes to memorise. First: range(1, n) when you mean range(0, n) β€” skips the first item. Second: range(0, n-1) when you mean range(0, n) β€” skips the last item. Third: range(0, n+1) when you mean range(0, n) β€” processes one item past the end, usually causing an IndexError or processing a sentinel value as real data.

The rule that prevents all three: when iterating a list or array by index, always use range(len(collection)). No manual arithmetic on the stop value. When iterating a count of things (do this 10 times), use range(10). When you need 1-based counting (item 1, item 2...), use enumerate(collection, start=1).

OffByOneAudit.py Β· PYTHON
1234567891011121314151617181920212223242526272829303132
# io.thecodeforge β€” Python tutorial

# --- Real scenario: processing customer invoice line items ---
invoice_items = [
    {"sku": "WIDGET-A", "qty": 3, "unit_price": 9.99},
    {"sku": "GADGET-B", "qty": 1, "unit_price": 49.99},
    {"sku": "DOOHICKEY-C", "qty": 5, "unit_price": 4.50},
]

item_count = len(invoice_items)  # 3

print("=== BUG: range(1, item_count) β€” skips the first item ===")
for i in range(1, item_count):  # gives indices 1, 2 β€” index 0 (WIDGET-A) is GONE
    item = invoice_items[i]
    print(f"  Processing: {item['sku']}")
# WIDGET-A never billed. Silent revenue loss.

print("\n=== BUG: range(0, item_count - 1) β€” skips the last item ===")
for i in range(0, item_count - 1):  # gives indices 0, 1 β€” index 2 (DOOHICKEY-C) is GONE
    item = invoice_items[i]
    print(f"  Processing: {item['sku']}")
# DOOHICKEY-C never billed. More silent revenue loss.

print("\n=== CORRECT: range(len(invoice_items)) β€” processes every item ===")
for i in range(len(invoice_items)):  # gives indices 0, 1, 2 β€” all items hit
    item = invoice_items[i]
    print(f"  Processing: {item['sku']}")

print("\n=== BETTER: enumerate() when you need index AND value ===")
for line_number, item in enumerate(invoice_items, start=1):  # 1-based line numbers
    subtotal = item['qty'] * item['unit_price']
    print(f"  Line {line_number}: {item['sku']} β€” ${subtotal:.2f}")
β–Ά Output
=== BUG: range(1, item_count) β€” skips the first item ===
Processing: GADGET-B
Processing: DOOHICKEY-C

=== BUG: range(0, item_count - 1) β€” skips the last item ===
Processing: WIDGET-A
Processing: GADGET-B

=== CORRECT: range(len(invoice_items)) β€” processes every item ===
Processing: WIDGET-A
Processing: GADGET-B
Processing: DOOHICKEY-C

=== BETTER: enumerate() when you need index AND value ===
Line 1: WIDGET-A β€” $29.97
Line 2: GADGET-B β€” $49.99
Line 3: DOOHICKEY-C β€” $22.50
⚠️
The Classic Bug: range(1, n) on a zero-indexed collectionThis is the most common silent data bug I see in Python code review. No exception is raised. The loop runs. It just skips index 0. Always. If you're iterating invoice lines, user records, or log entries and the first one is mysteriously absent, this is your culprit. Check for range(1, ...) on any zero-indexed data structure and change it to range(0, ...) or range(len(...)).

range() vs enumerate() vs zip(): Picking the Right Tool Every Time

range() is not always the right tool for looping β€” and using it when you shouldn't is a tell that someone learned Python through C. Here's the decision tree you should have hardwired.

Use range(n) when you need a bare count: run this loop exactly n times, generate n evenly-spaced values, or you genuinely only need the index with no corresponding data. Use range(len(collection)) only when you need to modify the list in-place by index β€” inserting at a position, swapping elements, or accessing two lists simultaneously by the same index. The moment you're writing collection[i] just to read a value, switch to a direct for item in collection loop. Use enumerate(collection) when you need both the position and the value β€” building numbered output, tracking progress through a list, logging which item failed. Use zip(list_a, list_b) when you need to walk two sequences in lockstep β€” pairing up input records with expected outputs, merging two data streams.

These aren't stylistic preferences. range(len(x)) where a direct loop would do is harder to read, introduces off-by-one risk, and gets flagged in every serious Python linter.

LoopToolSelector.py Β· PYTHON
1234567891011121314151617181920212223242526272829303132
# io.thecodeforge β€” Python tutorial

order_ids = ["ORD-001", "ORD-002", "ORD-003", "ORD-004"]
order_statuses = ["shipped", "pending", "cancelled", "shipped"]

# --- range(n): pure count loop β€” send N reminder emails ---
print("=== range(n): run exactly N times ===")
for reminder_number in range(3):  # 0, 1, 2 β€” we don't care about index meaning
    print(f"Sending scheduled reminder #{reminder_number + 1}")

# --- Direct iteration: read each item β€” no index needed ---
print("\n=== Direct for-in: cleanest when index is irrelevant ===")
for order_id in order_ids:
    print(f"Dispatching notification for {order_id}")

# --- enumerate(): need position AND value β€” building an audit log ---
print("\n=== enumerate(): index + value together ===")
for position, order_id in enumerate(order_ids, start=1):
    print(f"Audit entry {position}: processed {order_id}")

# --- zip(): two sequences in lockstep β€” pairing orders with statuses ---
print("\n=== zip(): walking two lists together ===")
for order_id, status in zip(order_ids, order_statuses):
    print(f"{order_id} β†’ {status}")

# --- range(len(x)): legitimate use β€” in-place list modification ---
print("\n=== range(len(x)): in-place update (valid use case) ===")
priority_scores = [72, 45, 91, 38]
for i in range(len(priority_scores)):  # need index to WRITE back to the list
    if priority_scores[i] < 50:
        priority_scores[i] = 0  # zero out low-priority scores
print(f"Adjusted scores: {priority_scores}")
β–Ά Output
=== range(n): run exactly N times ===
Sending scheduled reminder #1
Sending scheduled reminder #2
Sending scheduled reminder #3

=== Direct for-in: cleanest when index is irrelevant ===
Dispatching notification for ORD-001
Dispatching notification for ORD-002
Dispatching notification for ORD-003
Dispatching notification for ORD-004

=== enumerate(): index + value together ===
Audit entry 1: processed ORD-001
Audit entry 2: processed ORD-002
Audit entry 3: processed ORD-003
Audit entry 4: processed ORD-004

=== zip(): walking two lists together ===
ORD-001 β†’ shipped
ORD-002 β†’ pending
ORD-003 β†’ cancelled
ORD-004 β†’ shipped

=== range(len(x)): in-place update (valid use case) ===
Adjusted scores: [72, 0, 91, 0]
πŸ”₯
Interview Gold: why range() membership testing is O(1)Unlike a list where 999999 in my_list scans every element, 999999 in range(1_000_000) returns instantly. range() doesn't iterate to check membership β€” it applies the mathematical formula: is (value - start) divisible by step, and does it land within bounds? That's one calculation regardless of range size. This comes up in technical screens β€” most candidates who 'know range()' don't know this.
Feature / Aspectrange()list()
Memory for 1 million integers48 bytes (constant)~8,000,056 bytes (~8MB)
Membership test: x in collectionO(1) β€” math formulaO(n) β€” full scan
Supports negative step (reverse)Yes β€” range(10, 0, -1)Yes β€” but needs reversed() or slicing
Indexing: collection[i]Yes β€” O(1)Yes β€” O(1)
Slicing: collection[a:b]Yes β€” returns a new range objectYes β€” returns a new list
Can hold non-integer valuesNo β€” integers onlyYes β€” any type
Mutable (can add/remove items)No β€” immutableYes β€” append, pop, etc.
Created lazily (on demand)Yes β€” no upfront computationNo β€” all values computed at creation
Works with len()YesYes
Best forCounted loops, index generation, pagination offsetsWhen you need to store, modify, or pass around a sequence of values

🎯 Key Takeaways

  • range() is not a list β€” it's a lazy rule stored as three integers. range(1_000_000) costs 48 bytes no matter what. Converting it to list() throws away the only reason to use it.
  • Stop is always exclusive. Every time. No exceptions. range(0, 5) gives you 0,1,2,3,4. Burn this into muscle memory and you kill 90% of off-by-one bugs before they happen.
  • Reach for range() when you need a counted loop, index generation, or pagination offsets. The moment you're writing collection[i] just to read values, switch to direct iteration or enumerate() β€” range(len(x)) for reading is a code smell that linters will flag.
  • range() membership testing is O(1) via arithmetic, not O(n) via scanning. This separates people who use range() from people who understand it β€” and it comes up in interviews at companies that care about algorithmic reasoning.

⚠ Common Mistakes to Avoid

  • βœ•Mistake 1: Writing range(1, len(collection)) intending to cover all indices β€” skips index 0 silently, first record never processed, no exception raised β€” fix: always use range(len(collection)) or range(0, len(collection)) for full index coverage
  • βœ•Mistake 2: Using range(0, n-1) thinking n-1 is the last valid index β€” skips the final item, last record dropped silently β€” fix: stop argument is already exclusive, so range(len(collection)) gives you all valid indices 0 through len-1 with no manual subtraction
  • βœ•Mistake 3: Wrapping range() in list() unnecessarily β€” list(range(10_000_000)) allocates ~80MB of integers in memory before the loop even starts, can cause MemoryError on constrained workers β€” fix: iterate range() directly, it's already a sequence; only convert to list when you genuinely need list-specific mutability
  • βœ•Mistake 4: Expecting range() to include floating-point steps like range(0, 1, 0.1) β€” raises TypeError: 'float' object cannot be interpreted as an integer β€” fix: use a list comprehension [round(i * 0.1, 1) for i in range(10)] or numpy.arange(0, 1, 0.1) for float sequences

Interview Questions on This Topic

  • Qrange() membership testing β€” how does Python evaluate '500000 in range(1000000)' and what is its time complexity compared to '500000 in list(range(1000000))'? Walk me through the implementation detail that makes them differ.
  • QYou're building a batch processor that chunks 10 million database rows into pages of 500. Would you use range() to generate offsets or query a count upfront and build a list? What breaks at scale if you choose the list approach?
  • QWhat happens when you pass a step of 0 to range() β€” and why does Python raise that specific error rather than silently returning an empty range like it does when start equals stop?

Frequently Asked Questions

Why does range(5) start at 0 and not 1 in Python?

Because Python uses zero-based indexing for all sequences, and range(n) is designed to produce exactly the valid indices for a list of length n β€” no arithmetic needed. range(5) gives 0,1,2,3,4, which maps directly to the five positions in a five-element list. If it started at 1, you'd write range(1, len(my_list)+1) every time you wanted to index a list, which would be error-prone and tedious. The zero default is a deliberate design choice to eliminate that class of bug entirely.

What's the difference between range() and enumerate() in Python?

range() generates numbers; enumerate() generates (index, value) pairs from an existing iterable. Use range(n) or range(len(x)) when you only need the count or position. Use enumerate(x) when you need both the index and the actual item β€” it's cleaner, safer, and eliminates the need to write collection[i] inside the loop.

How do I loop backwards with range() in Python?

Use a negative step: range(start, stop, -1) where start is higher than stop. To count from 10 down to 1, write range(10, 0, -1) β€” this gives 10,9,8,7,6,5,4,3,2,1. Remember stop is still exclusive, so to include 1 you use 0 as the stop value. Don't use reversed(range(n)) β€” that works but it's roundabout; just use the negative step directly.

Is it safe to use range() with very large numbers in Python β€” say, range(10 ** 18)?

Yes, completely safe β€” and this is exactly where range() shines over lists. range(1018) still occupies only 48 bytes because it stores just the start, stop, and step values. Python integers have arbitrary precision, so there's no overflow. You can index it, slice it, and test membership in O(1) time. The only moment this becomes dangerous is if you convert it to list(range(1018)) β€” that will exhaust your memory and crash the process. Keep it as a range object and you can handle astronomically large sequences with zero memory overhead.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousPython print() Function: Syntax, Formatting and Examples
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged