Python range() — range(1, n) Skips Index 0 Silently
range(1, n) on zero-indexed list silently skips index 0 — no exception.
- range() is a lazy sequence object — stores only start, stop, step as three integers, not a list of numbers
- range(1_000_000) costs 48 bytes of memory; list(range(1_000_000)) costs ~8MB in shallow pointer storage alone — the true allocation including integer objects is closer to 35MB. Never convert unnecessarily
- Stop is always exclusive — range(0, 5) gives 0,1,2,3,4, never 5. This is the #1 source of off-by-one bugs in production Python
- Membership testing (x in range(n)) is O(1) via arithmetic formula, not O(n) like list scanning — this is the interview differentiator most candidates miss
- Use range(n) for counted loops, range(len(x)) only for in-place index writes, enumerate() for index+value pairs, and zip() for parallel sequences — zip() stops at the shorter sequence, so use itertools.zip_longest() when lengths may differ
- Biggest production trap: range(1, len(collection)) silently skips index 0 — first record never processed, no exception raised, no warning emitted
- range() only accepts integers — float steps raise TypeError; use a list comprehension with round() for approximate decimal sequences, or decimal.Decimal arithmetic for financial precision
Imagine you are a factory floor supervisor telling a worker: 'Start at station 3, work through to station 9, skip every other station.' You are not handing them a written list of stations — you are giving them a rule they follow as they go. That is range(). It is 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, 1_000_000) does not eat your RAM the way a list of a million numbers would. The rule takes three things to define: where to start, when to stop, and how big each step is. Change any of those three and you get a completely different counting pattern, all with the same 48 bytes of overhead.
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 nightly billing accumulation never updated, and nobody noticed for six weeks because the total count was close enough to pass the eyeball test. No exception was raised. No alert fired. The job logged 'success' every single night. The culprit was a misunderstood range() call. One wrong number in the start argument. 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 with no complaint. 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 is not academic — every data pipeline, every retry loop, every pagination handler, every batch processor in Python touches range().
By the end of this guide you will know exactly how range() works under the hood, why it does not store numbers in memory, how to count backwards without any workaround, how membership testing in range() is O(1) when list scanning is O(n), and the specific mistake patterns that cause silent data corruption in production loops. You will write range() calls with confidence and spot broken ones in code review on sight.
All code examples and memory figures in this article were verified on CPython 3.12. The sys.getsizeof values you see reflect CPython's internal representation for that version — results will differ slightly on Python 3.10 or 3.11, and will differ more substantially on PyPy or Jython. The algorithmic properties — O(1) membership testing, lazy evaluation, 48-byte range object size — hold across all CPython versions from 3.2 onwards.
What range() Actually Is — And Why It's Not a List
Before range() existed in its modern form, Python 2 had two functions: range() that returned an actual list and 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 drive the iteration — pure overhead with no payoff. Python 3 collapsed them: range() is now always lazy. It never builds the full list. It stores exactly three integers — start, stop, step — and calculates each value on demand as the loop advances.
This matters the moment you are paginating through a database result set, iterating over file offsets, or running a retry loop in a distributed system where worker memory is constrained. You are not paying a memory cost proportional to the count — you are paying for three integers, always, regardless of whether the range covers five numbers or five billion.
One important precision that most articles skip: sys.getsizeof() on a list returns the shallow size — the size of the container and its array of pointers to integer objects, but not the integer objects themselves. For list(range(1_000_000)), sys.getsizeof() reports roughly 8MB. But CPython allocates heap memory for every integer outside the small-integer cache (-5 to 256). The 999,744 integers from 257 to 999,999 each occupy roughly 28 bytes, adding approximately 27MB to the true footprint. Total real allocation: closer to 35MB, not 8MB. The range() object stays at 48 bytes regardless, because it stores three Python integers — all of which fall in the cached range or cost negligible overhead. The memory efficiency argument for range() is even stronger than the shallow-size comparison suggests.
The implementation detail that surprises most developers: range() is not just an iterator. It is a full sequence type. You can index into it directly (range(10)[7] == 7), slice it (range(0, 100, 2)[3:6] returns a new range object), get its length in constant time (len(range(100)) == 100), and test membership in O(1). That last property has a behaviour almost nobody learns until an interview forces it. Membership testing in a range() object applies an arithmetic formula: is the value an integer, does (value - start) % step == 0, and does the value fall within the [start, stop) bounds? Three constant-time calculations. It never iterates through the range to check. A list scan is O(n) and gets slower as the list grows; a range membership check takes the same time whether the range has 10 elements or 10 billion.
Think of range() as a bookmark rule, not a bookshelf. 'Start at page 10, read every third page, stop before page 40' — you do not photocopy those pages in advance. You follow the rule as you go. range() is the rule. The loop is you following it.
sys.getsizeof() suggests — the true allocation on CPython 3.12 for list(range(1_000_000)) is closer to 35MB once the integer objects themselves are counted, and for list(range(50_000_000)) it approaches 1.7GB. On a batch processor sized for 512MB, that single list() wrapper is enough to OOM-kill the worker before a single iteration runs. range() is already indexable, sliceable, and membership-testable without conversion. Keep it lazy. The only legitimate reason to call list(range(n)) is when you genuinely need list-specific mutability — appending, popping, or inserting — which counting loops essentially never require.sys.getsizeof() returns ~8MB. tracemalloc reports a true peak of ~35MB because CPython caches only integers from -5 to 256 — every integer from 257 to 999,999 is a separately heap-allocated 28-byte object. Scale to 50 million and the true allocation approaches 1.7GB, enough to OOM-kill a worker sized for routine processing load.len()-able, and O(1) membership-testable via arithmetic formula. You get all of these without converting to a list.list() memory cost by 3-4x — it measures the pointer array, not the integer objects. Use tracemalloc to measure true allocation. The actual memory efficiency of range() versus list() is closer to 700,000x for one million integers, not the 166,667x that shallow size suggests.range() directly — lazy, constant memory, supports indexing, slicing, and O(1) membership without conversionlist() — range objects are immutable and cannot be mutated in placerange() qualifies for both and supports len(), indexing, and slicing. Only convert if the function explicitly requires list type or calls list-specific mutation methods.The Three-Argument Syntax: Start, Stop, Step Without Guessing
Every range() confusion in production code traces back to one of two things: forgetting that stop is exclusive, or not knowing that step exists. Here is the complete syntax, once and for all: range(start, stop, step). Start is where counting begins — inclusive. Stop is where counting ends — but the stop value itself is never produced. Step is how much to add on each iteration.
When you write range(5), Python treats it as range(0, 5, 1) — start defaults to 0, step defaults to 1. That is why range(5) gives you 0, 1, 2, 3, 4 — five values, none of them 5. This is not arbitrary. It means range(len(my_list)) always gives you exactly the valid indices for that list — no arithmetic needed, no off-by-one to introduce. By design.
The step argument is where range() earns its keep beyond toy loops. Batch processing every Nth record, building retry delays at a fixed interval, generating database page offsets, checking every even-numbered slot in a buffer — these all need step. For counting backwards, a negative step is all you need. There is no reversed() call required, no subtraction gymnastics. Just range(start, stop, -1) where start is numerically greater than stop. Stop is still exclusive in the negative direction — range(10, 0, -1) gives you 10 down to 1, because 0 is the stop and it is never included.
When you want to reverse a list and only need the values without indices, use reversed(my_list) — it is cleaner, requires no start/stop arithmetic, and works on any sequence regardless of whether it supports len(). Reserve range() with a negative step for situations where you genuinely need the decreasing index value — progress counters, countdown displays, decreasing batch offsets.
The one constraint worth memorising: step cannot be zero. range(0, 10, 0) raises ValueError: range() arg 3 must not be zero. A zero step would produce an infinite sequence of the start value — advancing by nothing means never terminating. Python raises an error here rather than silently returning an empty range, because the two cases have completely different meanings to the program. An empty range from an unsatisfiable start/stop condition is well-defined and may be intentional. A zero step almost always means a wrong variable was passed to the step argument — failing loudly prevents that from silently masking the bug.
for i in range(len(my_list)): val = my_list[i], stop. You are doing two operations per iteration — the index lookup and the subsequent list access — for zero benefit over for val in my_list. Worse, you have introduced off-by-one surface area at the range() boundary. Use enumerate(my_list) when you need both the index and the value. Reserve range(len(x)) for the one legitimate use case: when you need to write back to the list by index — swapping elements, zeroing out values, modifying in place. If you are reading, not writing, this pattern is flagged by Pylint (consider-using-enumerate) and Ruff (PERF101) for real reasons, and any competent code reviewer will ask why the index is needed.min().min() when the total does not divide evenly.range() with negative step for when you need the actual decreasing index.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 process one record too few or too many, the job reports success, and the corruption accumulates silently until someone notices a discrepancy or a customer surfaces the problem. The fintech incident that opened this guide was exactly this: range(1, record_count) where range(0, record_count) was correct. Index 0 never processed. Six weeks of silent nightly corruption.
There are exactly three failure modes worth memorising, because they cover the vast majority of off-by-one bugs in production Python code. First: range(1, n) when you mean range(0, n) — skips the first item, by far the most common pattern. Second: range(0, n-1) when you mean range(0, n) — skips the last item because stop is already exclusive, and subtracting 1 from it silently drops the final valid index n-1, so the loop visits indices 0 through n-2 and never touches the last element. Third: range(0, n+1) when you mean range(0, n) — processes one index past the end of the collection, causing an IndexError on the final iteration or, in dynamically-sized cases, quietly processing a sentinel or default value as real data.
All three produce wrong output with no exception, which is what makes them production-dangerous rather than merely annoying. They pass unit tests written against small test fixtures where the missing record is not checked. They pass integration tests that verify aggregate values rather than record counts. They run in production for days or weeks before the data discrepancy grows large enough to be noticed.
The rules that prevent all three: when iterating a list or array by index, always use range(len(collection)) — no manual arithmetic on start or stop. When you need a counted loop, use range(N). When you need both index and value, use enumerate(collection) — this eliminates range() from the equation entirely and makes off-by-one structurally impossible because enumerate() always generates the correct index for each item automatically.
range() produce silent data corruption — the loop runs without error, the job reports success, and the missing records accumulate until someone notices a discrepancy or a customer surfaces the problem.assert processed_count == len(source_data). It catches all three failure modes — range(1,n), range(0,n-1), range(0,n+1) — with one line of code, and it costs nothing at runtime relative to the loop itself. If the assertion fires, the job fails loudly before writing partial results anywhere downstream. That is exactly what you want.enumerate() for index+value access, and never manually add or subtract from the stop value.range() entirely and makes off-by-one structurally impossiblerange() vs enumerate() vs zip(): Picking the Right Tool Every Time
range() is not always the right tool for looping — and using it when you should not is a reliable tell that someone learned Python through C or Java and is carrying index-based loop habits into a language that does not need them. Here is the decision framework that should be 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 collection value. The clearest signal that range(n) is right: there is no collection being indexed — just a number of iterations.
Use range(len(collection)) only when you need to modify the list in-place by index — inserting at a specific position, swapping elements, zeroing out values, or when you need to access two lists simultaneously at the same index and zip() is not appropriate. This is the one use case where you genuinely need the raw index and range(len()) is the right tool.
Use a direct for item in collection loop when you only need the values — no index, no parallel list. This is the cleanest form and requires the least mental overhead when reading the code six months later.
Use enumerate(collection) when you need both the position and the value simultaneously — building numbered output, tracking which item failed in error logs, reporting progress through a large dataset. enumerate() gives you both at the same time without writing collection[i].
Use zip(list_a, list_b) when you need to walk two sequences in lockstep — pairing input records with expected outputs, merging two data streams by position, comparing before and after values side by side. One critical property of zip() that catches engineers out: it stops silently at the shorter sequence. If list_a has 10 elements and list_b has 9, zip() produces 9 pairs and the 10th element of list_a is silently ignored — no warning, no exception. When your two sequences might differ in length and you need to process all elements from both, use itertools.zip_longest(), which fills missing values with a fillvalue you specify.
These are not stylistic preferences — they are correctness decisions. range(len(x)) where a direct loop or enumerate() would do is flagged by Pylint (consider-using-enumerate) and Ruff (PERF101) for real reasons: it is harder to read, it introduces off-by-one surface area, and it signals to future maintainers that the index must be needed for something — which then requires them to trace through the loop body to discover it is just used to access the collection value.
range() do not know this, and it comes up in algorithm design interviews at companies that care about complexity analysis. Unlike 'x in my_list' — which scans every element from the beginning, making it O(n) — 'x in range(n)' applies three arithmetic checks in constant time: (1) is x an integer type, (2) does (x - start) % step equal zero, meaning x falls exactly on a step boundary, and (3) does x fall within the [start, stop) bounds? Three operations, constant time, regardless of range size. range(1_000_000_000) membership testing takes the same nanoseconds as range(5). Knowing this is the difference between using range() as a loop counter and actually understanding it as a sequence type.zip() is fine. If they might differ — due to upstream data issues, partial loads, or async race conditions — always use itertools.zip_longest() with an explicit fillvalue, and add a length assertion before the loop.enumerate() (index and values).enumerate() for index+value pairs, zip() for parallel lists where equal length is guaranteed, and direct iteration for reading values.itertools.zip_longest() when lists might differ in length and silent truncation would be a bug.range() is O(1) via arithmetic formula — not O(n) via scanning. This is the interview differentiator that separates developers who use range() from developers who understand it as the full sequence type it actually is.Off-by-One in Nightly Billing Accumulator Silently Skips First Customer Record for Six Weeks
- range(1, n) on a zero-indexed collection silently skips index 0 every time — no exception, no warning, no indication that anything went wrong. The loop runs successfully and processes n-1 records.
- Never trust that a successful loop processed everything — always add a post-loop assertion that verifies processed count equals expected count before declaring success.
- Zero-based indexing means range(len(collection)) or range(0, len(collection)) is the only correct full-coverage pattern — no manual arithmetic on the start or stop values.
- Add automated count assertions after any batch loop that processes records by index; aggregate total checks are insufficient because they only catch value errors, not missing records.
range() call in the loop setup and change range(1, len(collection)) to range(0, len(collection)) or simply range(len(collection)). Add a post-loop assertion: assert processed_count == len(collection).list() wrapper — range() is already iterable, indexable, and sliceable without conversion.range() callround(): [round(i * 0.1, 1) for i in range(10)]. For financial precision where floating-point representation errors are unacceptable, use decimal.Decimal arithmetic instead. For numeric computing, numpy.arange(0.0, 1.0, 0.1) is the idiomatic solution if NumPy is already in the stack.Key takeaways
list() throws away the only reason to use it. sys.getsizeof() understates the cost — true allocation on CPython 3.12 for list(range(1_000_000)) is closer to 35MB once integer objects are counted, not the ~8MB shallow figure. The list() wrapper is only justified when you need list-specific mutability, which counting loops essentially never require.enumerate() for index+value pairs, direct iteration for reading values, and range(len(x)) only for in-place writes by index. Use zip() when lengths are guaranteed equalitertools.zip_longest() when they might differ. zip() stops silently at the shorter sequence with no warning, and that silence has corrupted production data.range() from developers who understand it as a full sequence type. The gap versus list scanning widens linearly with collection sizerange() takes the same ~168ns it always does while a list would take over a second.Common mistakes to avoid
6 patternsWriting range(1, len(collection)) intending to cover all indices
Using range(0, n-1) thinking subtraction is needed because n-1 is the last valid index
Wrapping range() in list() on large datasets unnecessarily
sys.getsizeof() understates the cost — list(range(10_000_000)) appears to cost ~80MB in shallow measurement but true allocation on CPython 3.12 is closer to 280MB once the integer objects above 256 are included. On workers with memory limits of 512MB or less, this single wrapper is enough to trigger a kill.range() directly — it is already a sequence type with full support for indexing, slicing, len(), and O(1) membership testing. Only convert to list() when you genuinely need list-specific mutability: appending, popping, inserting, or sorting. Counting loops require none of these operations.Expecting range() to accept float steps like range(0, 1, 0.1)
range().round() — [round(i 0.1, 1) for i in range(10)]. The round() call prevents floating-point representation errors like 0.30000000000000004. For financial calculations where exact decimal precision is required: use decimal.Decimal arithmetic — [Decimal('0.0') + Decimal('0.1') i for i in range(10)]. For numeric computing: numpy.arange(0.0, 1.0, 0.1) is the idiomatic and correct solution if NumPy is already in the dependency stack.Using range(start, stop, -1) where start is less than stop, expecting values to appear
Using zip() on two lists that may differ in length, expecting all elements to be processed
zip() loop if equal length is a contract: assert len(list_a) == len(list_b), f'Length mismatch: {len(list_a)} vs {len(list_b)}'.Interview Questions on This Topic
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.
Frequently Asked Questions
That's Python Basics. Mark it forged?
9 min read · try the examples if you haven't