Python range Memory Efficiency: How range() Stays Small
python range memory efficiency: Understand how Python's range object stores only start, stop, and step, and why iterating a range avoids materializing millions of inte...
range() in Python 3 is a lazy sequence type, not a function that builds a list. When you write range(10**9), Python does not allocate memory for a billion integers. The object stores only the start, stop, and step values and computes each element on demand during iteration. That is the core of python range memory efficiency: the memory footprint of a range object stays constant no matter how many values it represents.
How range() Represents a Sequence Without Storing It
A range object keeps three integers internally: start, stop, and step. Iteration works by computing each value arithmetically from the current position, so no element is ever materialized unless you explicitly convert the range to a list or another container.
import sys small = range(100) large = range(10**9) print(sys.getsizeof(small)) print(sys.getsizeof(large))
Both calls return the same value because the two objects store the same fields: the bounds and the step. The number of values in the sequence does not change the object size. This is the fundamental difference between a range and a list: a list holds pointers to every element, while a range holds only the parameters needed to reproduce the sequence.
This design carries over from Python 2's xrange, which was the lazy counterpart to the list-building range. In Python 3, range is the lazy version and xrange no longer exists. Code written for Python 2 that relied on xrange for large loops translates directly to range in Python 3.
What range() Does Not Do
A range object is immutable. You cannot append to it, remove from it, or replace an element. If you need a mutable sequence, you must materialize it with list(range(...)).
Slicing a range returns a new range object rather than a list:
r = range(10) print(r[1:5]) # range(1, 5)
The slice preserves the lazy representation, so slicing a range of a billion values still costs almost nothing. Indexing works the same way: r[3] computes the value at position 3 directly instead of walking the sequence.
Comparing Memory Use: range() vs list(range())
The practical difference appears when the sequence is large. A list of n integers stores an array of n pointers plus the integer objects themselves. On a 64-bit build, the pointer array alone costs 8 bytes per element, and each integer beyond the small-integer cache is a separate object. A range object costs a fixed amount regardless of n.
| Aspect | range(n) | list(range(n)) |
|---|---|---|
| Memory | Fixed, stores start, stop, step | Grows linearly with n |
| Mutability | Immutable | Mutable |
| Membership test | Constant time for integers | Linear scan |
| Slicing | Returns a range | Returns a list |
The table reflects the structural difference, not a measured benchmark. The key point is that the range version does not allocate per-element storage, while the list version must.
Membership Tests and Indexing Without Materialization
Because a range stores arithmetic parameters, membership tests for integer values can be computed in constant time:
print(10**9 in range(10**10)) # True, no iteration
The check verifies that the value falls within the bounds and aligns with the step. A list would require a linear scan over every element. The same arithmetic applies to indexing: range(10**10)[5] computes the fifth value directly.
This behavior is specific to integer membership tests. Testing for a non-integer value, such as a float, falls back to a linear scan because the range cannot determine alignment arithmetically.
When a List Is Still Necessary
Materializing a range into a list is the right choice when you need to modify the sequence. Appending, removing, or replacing elements requires a mutable container. Likewise, if you pass the sequence to a function that expects a list, you must convert it first.
Another case is when you need the values to outlive the loop that produced them. Iterating a range produces each value and discards it; if you collect those values for later use, you are building a list anyway, so converting explicitly makes the intent clear.
For repeated iteration over the same large sequence, a range is still the better choice. Each pass creates a fresh iterator that computes values on demand, so no memory accumulates between passes.
Iteration and Memory in Long-Running Loops
Iterating a range does not accumulate memory. Each value is computed, consumed, and released before the next one is produced. Functions that consume iterables, such as sum() or enumerate(), work directly with a range without holding the full sequence:
total = sum(range(10**7))
The summation completes without ever storing all ten million integers. Converting the range to a list first would hold every value for the duration of the operation, which is unnecessary for this pattern and can cause memory pressure in constrained environments.
Practical Guidance for Choosing range() or a List
Use a range when the sequence is large, when you only need to iterate once or a few times, when you want constant-time integer membership tests, or when you do not need to mutate the sequence. Use a list when you must modify elements, when the consuming code requires a list, or when the values must persist independently of the range object.
The decision reduces to whether the values need to exist as a stored collection. If they do not, a range keeps memory use flat and avoids the allocation cost of building a list. If they do, the list is necessary and the memory cost is unavoidable. There is no meaningful middle ground: a range is the lazy representation, and a list is the materialized one.