CPython's enumerate Implementation in C (and zip/reversed)
The enumobject, zipobject, and reversedobject structs, their iterator state, the recycled result tuple fast path, huge indices via PyLong, and the atomic/GC machinery traced through CPython 3.15's Objects/enumobject.c and Python/bltinmodule.c.
Python’s enumerate, zip, and reversed are not functions that build lists.
They are lazy iterator objects implemented in C. enumerate lives in
Objects/enumobject.c alongside reversed; zip lives in
Python/bltinmodule.c. All three follow the same broad pattern: a small
struct holding iterator state, a tp_iternext that produces one item at a
time, and a recycled result container to avoid per-step allocations. This
article focuses on enumerate and then sketches zip and reversed.
The enumerate object
// Objects/enumobject.c
typedef struct {
PyObject_HEAD
Py_ssize_t en_index; /* current index of enumeration */
PyObject* en_sit; /* secondary iterator of enumeration */
PyObject* en_result; /* result tuple */
PyObject* en_longindex; /* index for sequences >= PY_SSIZE_T_MAX */
PyObject* one; /* borrowed reference */
} enumobject;Five fields carry all the state:
en_indexis the next index to emit, stored as a CPy_ssize_tfor the common case. It is read/written with relaxed atomics (FT_ATOMIC_LOAD_SSIZE_RELAXED/FT_ATOMIC_STORE_SSIZE_RELAXED) so the free-threaded build can share an enumerator across threads safely.en_sitis the iterator over the underlying iterable.enumeratedoes not know anything about the iterable beyond this iterator.en_resultis a length-2 tuple reused across calls (see below).en_longindexis only non-NULL once the counter has overflowedPY_SSIZE_T_MAX; from then on the index is a Pythonint.oneis a borrowed reference to the integer1, used to step the long index without reallocating.
The type is GC-tracked (Py_TPFLAGS_HAVE_GC) and enum_traverse visits
en_sit, en_result, and en_longindex.
Construction: enum_new_impl
// Objects/enumobject.c
static PyObject *
enum_new_impl(PyTypeObject *type, PyObject *iterable, PyObject *start)
{
enumobject *en;
en = (enumobject *)type->tp_alloc(type, 0);
if (en == NULL)
return NULL;
if (start != NULL) {
start = PyNumber_Index(start);
if (start == NULL) { Py_DECREF(en); return NULL; }
assert(PyLong_Check(start));
en->en_index = PyLong_AsSsize_t(start);
if (en->en_index == -1 && PyErr_Occurred()) {
PyErr_Clear();
en->en_index = PY_SSIZE_T_MAX;
en->en_longindex = start; // keep the Python int
} else {
en->en_longindex = NULL;
Py_DECREF(start);
}
} else {
en->en_index = 0;
en->en_longindex = NULL;
}
en->en_sit = PyObject_GetIter(iterable);
if (en->en_sit == NULL) { Py_DECREF(en); return NULL; }
en->en_result = _PyTuple_FromPairSteal(Py_None, Py_None);
if (en->en_result == NULL) { Py_DECREF(en); return NULL; }
en->one = _PyLong_GetOne(); /* borrowed reference */
return (PyObject *)en;
}Key points:
startis converted withPyNumber_Index, soenumerate(x, start=1.0)is rejected andbool/intsubclasses work. The start value is then stored either as a C integer (en_index) or, if it does not fit inPy_ssize_t, as a Pythonintinen_longindexwithen_indexset toPY_SSIZE_T_MAXas a sentinel meaning “we are in long mode”.- The underlying iterable is immediately turned into an iterator with
PyObject_GetIter, soenumeratenever re-iterates the original. en_resultis pre-allocated as a pair ofNones. This tuple will be mutated in place on everynext()call.oneis the process-wide singleton1(borrowed, never freed by this object).
enumerate also has a fast tp_vectorcall (enumerate_vectorcall) that
parses positional/keyword arguments by hand, including the case where the
user writes enumerate(start=..., iterable=...) in either order. It
delegates to enum_new_impl.
next(): the fast path
// Objects/enumobject.c
static PyObject *
enum_next(PyObject *op)
{
enumobject *en = _enumobject_CAST(op);
PyObject *next_index;
PyObject *next_item;
PyObject *result = en->en_result;
PyObject *it = en->en_sit;
PyObject *old_index;
PyObject *old_item;
next_item = (*Py_TYPE(it)->tp_iternext)(it);
if (next_item == NULL)
return NULL; // StopIteration propagates
Py_ssize_t en_index = FT_ATOMIC_LOAD_SSIZE_RELAXED(en->en_index);
if (en_index == PY_SSIZE_T_MAX)
return enum_next_long(en, next_item);
next_index = PyLong_FromSsize_t(en_index);
if (next_index == NULL) { Py_DECREF(next_item); return NULL; }
FT_ATOMIC_STORE_SSIZE_RELAXED(en->en_index, en_index + 1);
if (_PyObject_IsUniquelyReferenced(result)) {
Py_INCREF(result);
old_index = PyTuple_GET_ITEM(result, 0);
old_item = PyTuple_GET_ITEM(result, 1);
PyTuple_SET_ITEM(result, 0, next_index);
PyTuple_SET_ITEM(result, 1, next_item);
Py_DECREF(old_index);
Py_DECREF(old_item);
_PyTuple_Recycle(result);
return result;
}
return _PyTuple_FromPairSteal(next_index, next_item);
}This is the heart of the implementation. Two optimisations matter:
- One allocation per step only for the index. The item comes from the underlying iterator (which may allocate). The result tuple is not allocated fresh each time; it is mutated in place when it is uniquely referenced.
- Tuple recycling.
_PyObject_IsUniquelyReferenced(result)checks that no other object holds a reference toen_result. In the normalfor i, x in enumerate(...)loop the previous yielded tuple has been consumed and dropped, so this is true, and the same tuple storage is reused viaPyTuple_SET_ITEM._PyTuple_Recyclere-tracks it for the GC (a recycled tuple may have been untracked) and resets its cached hash.
If the result tuple is shared (for example someone kept a reference to an
earlier pair), enumerate falls back to _PyTuple_FromPairSteal, which
allocates a fresh length-2 tuple. Either way ownership of next_index and
next_item is transferred into the returned tuple.
The counter is stepped with a relaxed atomic store. In the normal single- threaded case this compiles to a plain store; under free threading it avoids data races on the shared struct.
Huge indices: enum_next_long
Once en_index reaches PY_SSIZE_T_MAX the counter switches to Python
int arithmetic:
// Objects/enumobject.c
static inline PyObject *
increment_longindex_lock_held(enumobject *en)
{
if (en->en_longindex == NULL) {
en->en_longindex = PyLong_FromSsize_t(PY_SSIZE_T_MAX);
if (en->en_longindex == NULL) return NULL;
}
PyObject *next_index = en->en_longindex; // old value, returned
PyObject *stepped_up = PyNumber_Add(next_index, en->one);
if (stepped_up == NULL) return NULL;
en->en_longindex = stepped_up; // new current
return next_index; // caller owns this ref
}enum_next_long calls this under a critical section (Py_BEGIN_CRITICAL_SECTION),
because the long index is a Python object and the mutation must be atomic with
respect to other threads. It then performs the same in-place result-tuple
recycling as the fast path. one is the borrowed 1, so PyNumber_Add does
not allocate a new small int for the step.
enum_reduce reflects this split: it pickles (type, sit, en_longindex) when
in long mode and (type, sit, en_index) otherwise.
GC and deallocation
// Objects/enumobject.c
static void enum_dealloc(PyObject *op) {
enumobject *en = _enumobject_CAST(op);
PyObject_GC_UnTrack(en);
Py_XDECREF(en->en_sit);
Py_XDECREF(en->en_result);
Py_XDECREF(en->en_longindex);
Py_TYPE(en)->tp_free(en);
}tp_clear is NULL because enumobject holds no weak references and the
traverse function is enough for the collector; the GC frees the object through
PyObject_GC_Del. en->one is borrowed and is not decremented.
reversed
reversed is in the same file and is simpler: it does not wrap an iterator,
it walks a sequence by index.
// Objects/enumobject.c
typedef struct {
PyObject_HEAD
Py_ssize_t index;
PyObject* seq;
} reversedobject;Construction (reversed_new_impl) first looks up __reversed__ on the
argument; if present it calls that and returns its result (so user-defined
reversal wins). Otherwise it requires PySequence_Check, reads
PySequence_Size, and stores index = n - 1. The object keeps a strong
reference to the sequence.
reversed_next reads the current index atomically; if it is >= 0 it calls
PySequence_GetItem(seq, index), decrements the stored index on success, and
returns the item. On IndexError/StopIteration it clears the error. When
exhausted it sets index = -1 and, in the GIL build, clears seq to drop
the reference early. __length_hint__ recomputes the remaining count from
the current index and the (possibly changed) sequence size.
zip
zip lives in Python/bltinmodule.c and stores its iterators in a tuple:
// Python/bltinmodule.c
typedef struct {
PyObject_HEAD
Py_ssize_t tuplesize;
PyObject *ittuple; /* tuple of iterators */
PyObject *result;
int strict;
} zipobject;Construction (zip_new) turns each argument into an iterator with
PyObject_GetIter and stores them in ittuple; it also pre-allocates result
as a tuple of None of length tuplesize. strict comes from the keyword
argument.
zip_next is the same recycling idea scaled to N:
// Python/bltinmodule.c (simplified)
if (_PyObject_IsUniquelyReferenced(result)) {
Py_INCREF(result);
for (i = 0; i < tuplesize; i++) {
it = PyTuple_GET_ITEM(lz->ittuple, i);
item = (*Py_TYPE(it)->tp_iternext)(it);
if (item == NULL) { ... handle strict/exhaustion ... }
olditem = PyTuple_GET_ITEM(result, i);
PyTuple_SET_ITEM(result, i, item);
Py_DECREF(olditem);
}
_PyTuple_Recycle(result);
} else {
result = PyTuple_New(tuplesize);
...
}When an iterator is exhausted, zip returns NULL (StopIteration). In
strict=True mode it instead raises ValueError if the iterables have
different lengths, by draining the remaining iterators to see whether any
produces another item. zip_reduce/zip_setstate round-trip the iterator
tuple and the strict flag.
Shared design notes
- All three are GC objects with
tp_traverseover their held references; none implementtp_clear, relying on traversal plusPyObject_GC_Del. - All three reuse a single result tuple (
en_result/result) and mutate it withPyTuple_SET_ITEMwhen uniquely referenced, avoiding an allocation per yielded value._PyTuple_Recyclekeeps the GC tracking correct. - Counter/index state is touched with relaxed atomics so the same object can be driven from multiple threads in the free-threaded build.
enumerateandzipdo not materialise sequences; they only advance the iterators they hold.reversedis the exception, because indexing requires the whole sequence.
Further reading
Objects/enumobject.c–enumobject,reversedobject,enum_next,enum_next_long,reversed_next.Python/bltinmodule.c–zipobject,zip_new,zip_next.Objects/tupleobject.c–_PyTuple_FromPairSteal,_PyTuple_Recycle,PyTuple_SET_ITEM.Include/internal/pycore_tuple.h– tuple recycling and hash cache helpers.