CPython's range Implementation in C: Lazy Sequences That Never Materialize
range stores only start, stop, step and a cached length. Here's how CPython implements it in C: O(1) len, computed indexing, O(1) __contains__ for ints, and a two-tier iteration strategy.
TL;DR:
rangeisn’t a list of integers. It’s fourPyObject *fields —start,stop,step, and a cachedlength— and every value is computed on demand.len()is O(1), indexing is arithmetic, andx in range(...)is O(1) whenxis anint.
Python’s range is not a list of integers. It’s a tiny immutable object that remembers only its start, stop, and step, then computes everything else on demand. range(0, 10**18) takes exactly the same memory as range(3).
In CPython that’s rangeobject in Objects/rangeobject.c, with a cached length and two iterator types. Let’s walk the representation and the hot paths through the 3.15 source.
The object struct
// Objects/rangeobject.c
typedef struct {
PyObject_HEAD
PyObject *start;
PyObject *stop;
PyObject *step;
PyObject *length;
} rangeobject;Four PyObject * fields, all owned references. start, stop, and step are the user-supplied bounds, normalized to PyLong objects via PyNumber_Index in range_from_array. length is the precomputed number of items, also a PyLong.
There’s no array of integers anywhere. The sequence is fully lazy.
rangeobject is a fixed-size PyObject (sizeof(rangeobject)), so its allocation cost is constant no matter how many values the range would yield. Deallocation just decrements the four fields and returns the object to a small freelist (Py_ranges_MAXFREELIST == 6):
// Objects/rangeobject.c
static void range_dealloc(PyObject *op) {
rangeobject *r = (rangeobject*)op;
Py_DECREF(r->start);
Py_DECREF(r->stop);
Py_DECREF(r->step);
Py_DECREF(r->length);
_Py_FREELIST_FREE(ranges, r, PyObject_Free);
}Construction and the cached length
range_new / range_vectorcall funnel into range_from_array, which converts arguments and calls make_range_object:
// Objects/rangeobject.c
static rangeobject *make_range_object(PyTypeObject *type, PyObject *start,
PyObject *stop, PyObject *step) {
PyObject *length = compute_range_length(start, stop, step);
if (length == NULL) return NULL;
rangeobject *obj = _Py_FREELIST_POP(rangeobject, ranges);
if (obj == NULL) {
obj = PyObject_New(rangeobject, type);
...
}
obj->start = start;
obj->stop = stop;
obj->step = step;
obj->length = length;
return obj;
}compute_range_length first tries a fast path where all three values fit in a C long (compute_range_length_long), using the same formula as get_len_of_range:
// Objects/rangeobject.c
static unsigned long get_len_of_range(long lo, long hi, long step) {
assert(step != 0);
if (step > 0 && lo < hi)
return 1UL + (hi - 1UL - lo) / step;
else if (step < 0 && lo > hi)
return 1UL + (lo - 1UL - hi) / (0UL - step);
else
return 0UL;
}If any value overflows long (or the result doesn’t fit), it falls back to PyObject arithmetic: take the positive-step case, compute (hi - lo - 1) // step + 1, clamping to zero when lo >= hi.
The result is stored once in r->length, so len(r) is just:
// Objects/rangeobject.c
static Py_ssize_t range_length(PyObject *op) {
rangeobject *r = (rangeobject*)op;
return PyLong_AsSsize_t(r->length);
}That’s why len(range(...)) is O(1) — and why a range whose length exceeds PY_SSIZE_T_MAX (possible on Win64 with huge ints) still works through the length field even though sq_length can’t represent it.
Indexing: computing, not looking up
range_item / range_subscript never touch a buffer. For an integer index, compute_range_item normalizes negative indices against length, bounds-checks, then computes the value:
// Objects/rangeobject.c
static PyObject *compute_item(rangeobject *r, PyObject *i) {
/* PyLong equivalent to: return r->start + (i * r->step) */
if (r->step == _PyLong_GetOne()) {
return PyNumber_Add(r->start, i);
} else {
PyObject *incr = PyNumber_Multiply(i, r->step);
if (!incr) return NULL;
PyObject *result = PyNumber_Add(r->start, incr);
Py_DECREF(incr);
return result;
}
}Notice the fast path when step == 1: it skips the multiply entirely. Negative indices are handled by adding length to the index first, exactly like standard sequence semantics. Out-of-range indices raise IndexError.
Slicing returns a new range, not a list. compute_slice uses _PySlice_GetLongIndices against r->length, then builds substart = compute_item(r, start), substop = compute_item(r, stop) and substep = r->step * step, and constructs a new range via make_range_object. So range(10)[2:8:2] is range(2, 8, 4) and costs O(1).
__contains__ is O(1)
For an exact int (or bool), range_contains dispatches to range_contains_long, which avoids iteration entirely:
// Objects/rangeobject.c
static int range_contains_long(rangeobject *r, PyObject *ob) {
/* Check if the value can possibly be in the range. */
cmp1 = PyObject_RichCompareBool(r->step, zero, Py_GT);
if (cmp1 == 1) { /* positive steps: start <= ob < stop */
cmp2 = PyObject_RichCompareBool(r->start, ob, Py_LE);
cmp3 = PyObject_RichCompareBool(ob, r->stop, Py_LT);
} else { /* negative steps: stop < ob <= start */
cmp2 = PyObject_RichCompareBool(ob, r->start, Py_LE);
cmp3 = PyObject_RichCompareBool(r->stop, ob, Py_LT);
}
if (cmp2 == 0 || cmp3 == 0) { /* ob outside of range */
result = 0; goto end;
}
/* Check that the stride does not invalidate membership. */
tmp1 = PyNumber_Subtract(ob, r->start);
tmp2 = PyNumber_Remainder(tmp1, r->step);
result = PyObject_RichCompareBool(tmp2, zero, Py_EQ);
end:
...
}The algorithm is two steps: bound check (start <= ob < stop for a positive step, reversed for a negative one), then a divisibility check (ob - start) % step == 0. Both are O(1) big-int operations.
Gotcha: for any other object type, range_contains falls back to _PySequence_IterSearch — a linear scan. So x in range(...) is only guaranteed O(1) when x is an int.
range_index reuses the same membership test and then computes the index as (ob - start) // step (or just ob - start when step is 1).
Iteration: two tiers
range_iter picks between a fast C-long iterator and a generic PyObject-based one, depending on whether everything fits in a long:
// Objects/rangeobject.c
static PyObject *range_iter(PyObject *seq) {
rangeobject *r = (rangeobject *)seq;
long lstart = PyLong_AsLong(r->start);
if (lstart == -1 && PyErr_Occurred()) { PyErr_Clear(); goto long_range; }
... /* lstop, lstep similarly */
ulen = get_len_of_range(lstart, lstop, lstep);
if (ulen > (unsigned long)LONG_MAX) goto long_range;
/* overflow check for lstart + ulen * lstep */
...
return fast_range_iter(lstart, lstop, lstep, (long)ulen);
long_range:
it = PyObject_New(longrangeiterobject, &PyLongRangeIter_Type);
it->start = Py_NewRef(r->start);
it->step = Py_NewRef(r->step);
it->len = Py_NewRef(r->length);
return (PyObject *)it;
}The fast tier uses _PyRangeIterObject, whose entire state is three C longs:
// Include/internal/pycore_range.h
typedef struct {
PyObject_HEAD
long start;
long step;
long len;
} _PyRangeIterObject;Its tp_iternext is trivial, and allocation-free beyond the first PyLong_FromLong:
// Objects/rangeobject.c
static PyObject *rangeiter_next(PyObject *op) {
_PyRangeIterObject *r = (_PyRangeIterObject*)op;
if (r->len > 0) {
long result = r->start;
r->start = result + r->step;
r->len--;
return PyLong_FromLong(result);
}
return NULL;
}The generic tier (longrangeiterobject) stores PyObject *start/step/len and does PyNumber_Add / PyNumber_Subtract per step, so it handles arbitrary-precision bounds at the cost of per-iteration allocation. Both iterators are recycled through freelists of size 6.
__reversed__ (range_reverse) re-expresses the reversed sequence as a new range range(start+(n-1)*step, start-step, -step) and again prefers the fast C-long iterator when all values fit.
Memory efficiency summary
- A
rangeissizeof(rangeobject)(≈ 40 bytes on 64-bit) plus the four referencedPyLongobjects — independent of its length. - The length is computed once at construction (O(1) for small ints, big-int arithmetic otherwise) and cached.
- Indexing, slicing, containment, and
len()are all O(1) for ints. Only non-int containment and iteration over huge values degrade. - Iteration has a zero-allocation-per-step fast path for the common case where bounds fit in a C
long.
Key takeaways
rangestores onlystart,stop,step, and a cachedlength. The sequence is never materialized.- Membership of an
intis O(1) via a bounds check plus a modulo divisibility test; non-ints fall back to a linear search. - Indexing computes
start + i * step; slicing produces anotherrange. - There are two iterator tiers: a C-
longfast path (_PyRangeIterObject) and a genericPyObjectpath for arbitrary precision. - Both
rangeand its iterators are recycled through 6-deep freelists.
Further reading
Objects/rangeobject.c– all of the above.Include/internal/pycore_range.h–_PyRangeIterObject.Include/internal/pycore_freelist_state.h–Py_ranges_MAXFREELIST.