Python range() Explained: Stop Writing Broken Loops Forever
- 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.
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.
# 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}")
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
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.
# 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
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]
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).
# 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}")
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
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.
# 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}")
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]
| Feature / Aspect | range() | list() |
|---|---|---|
| Memory for 1 million integers | 48 bytes (constant) | ~8,000,056 bytes (~8MB) |
| Membership test: x in collection | O(1) β math formula | O(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 object | Yes β returns a new list |
| Can hold non-integer values | No β integers only | Yes β any type |
| Mutable (can add/remove items) | No β immutable | Yes β append, pop, etc. |
| Created lazily (on demand) | Yes β no upfront computation | No β all values computed at creation |
| Works with len() | Yes | Yes |
| Best for | Counted loops, index generation, pagination offsets | When 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.
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.