CPython's tuple Implementation in C: Immutable, Inline, and Recycled
A tuple's items live in the same allocation as its header. Here's how CPython implements it in C: the embedded ob_item array, the empty-tuple singleton, per-size freelists, xxHash caching, and the immutability contract.
TL;DR: A CPython
tuplestores its items inside the object’s own allocation — no backing array, no indirection. Its length is also its capacity, its hash is cached inob_hash, and sizes 1..20 are recycled through per-size freelists.
Python’s tuple is an immutable sequence of object pointers. In CPython it’s PyTupleObject in Objects/tupleobject.c. Unlike list, a tuple never over-allocates: its length and its capacity are the same. Let’s trace the representation and the hot paths through the 3.15 source.
The object struct
Every Python object starts with PyObject_VAR_HEAD (ob_refcnt, ob_type, and ob_size). For tuples the payload adds a cached hash and the item vector:
// Include/cpython/tupleobject.h
typedef struct {
PyObject_VAR_HEAD
/* Cached hash. Initially set to -1. */
Py_hash_t ob_hash;
/* ob_item contains space for 'ob_size' elements.
Items must normally not be NULL, except during construction when
the tuple is not yet visible outside the function that builds it. */
PyObject *ob_item[1];
} PyTupleObject;The trailing ob_item[1] is the classic C “struct hack”: the real object is allocated with ob_size slots, and ob_item is treated as a flexible array. The type object declares this with tp_basicsize and tp_itemsize:
// Objects/tupleobject.c
PyTypeObject PyTuple_Type = {
...
sizeof(PyTupleObject) - sizeof(PyObject *), /* tp_basicsize */
sizeof(PyObject *), /* tp_itemsize */
...
};tp_basicsize is the size of the header minus one slot, and tp_itemsize is the size of one slot, so PyObject_GC_NewVar(..., size) allocates exactly sizeof(PyTupleObject) - sizeof(PyObject *) + size * sizeof(PyObject *) bytes.
The items therefore live in the same allocation as the object header – no separate backing array and no indirection.
Memory layout
+---------------------------+ <- PyObject_GC_NewVar result
| ob_refcnt |
+---------------------------+
| *ob_type (&PyTuple_Type) |
+---------------------------+
| ob_size (len) |
+---------------------------+
| ob_hash (cached hash) |
+---------------------------+
| ob_item[0] (PyObject *) |
| ob_item[1] (PyObject *) |
| ... |
| ob_item[ob_size - 1] |
+---------------------------+Because there’s no allocated field, a tuple’s memory is exactly its length. Shrinking or growing means reallocating the whole object (see _PyTuple_Resize).
The empty-tuple singleton
The empty tuple is a statically allocated singleton, not GC-tracked, and its hash is precomputed at compile time:
// Include/internal/pycore_runtime_structs.h
struct {
_PyGC_Head_UNUSED _tuple_empty_gc_not_used;
PyTupleObject tuple_empty;
...
} singletons;
// Include/internal/pycore_runtime_init.h
.tuple_empty = {
.ob_base = _PyVarObject_HEAD_INIT(&PyTuple_Type, 0),
.ob_hash = _PyTuple_HASH_EMPTY,
},Every call asking for an empty tuple returns the same object:
// Objects/tupleobject.c
static inline PyObject *
tuple_get_empty(void)
{
return (PyObject *)&_Py_SINGLETON(tuple_empty);
}
PyObject *
PyTuple_New(Py_ssize_t size)
{
PyTupleObject *op;
if (size == 0) {
return tuple_get_empty();
}
...
}tuple_dealloc guards the singleton against being freed:
// Objects/tupleobject.c
if (Py_SIZE(op) == 0) {
if (op == &_Py_SINGLETON(tuple_empty)) {
#ifdef Py_DEBUG
_Py_FatalRefcountError("deallocating the empty tuple singleton");
#else
return;
#endif
}
}Subclasses don’t share the singleton; tuple_subtype_new may allocate its own empty instance.
Free list (tuple reuse)
Exact PyTupleObjects of sizes 1..20 are recycled through a per-size free list, each holding up to 2000 entries:
// Include/internal/pycore_freelist_state.h
# define PyTuple_MAXSAVESIZE 20 // Largest tuple to save on freelist
# define Py_tuple_MAXFREELIST 2000 // Maximum number of tuples of each size
tuple_alloc pops from the matching bucket before allocating:
// Objects/tupleobject.c
static PyTupleObject *
tuple_alloc(Py_ssize_t size)
{
if (size < 0) { ... }
assert(size != 0); // The empty tuple is statically allocated.
Py_ssize_t index = size - 1;
if (index < PyTuple_MAXSAVESIZE) {
PyTupleObject *op = _Py_FREELIST_POP(PyTupleObject, tuples[index]);
if (op != NULL) {
_PyTuple_RESET_HASH_CACHE(op);
return op;
}
}
...
PyTupleObject *result = PyObject_GC_NewVar(PyTupleObject, &PyTuple_Type, size);
if (result != NULL) {
_PyTuple_RESET_HASH_CACHE(result);
}
return result;
}On deallocation, maybe_freelist_push returns the object to its bucket instead of freeing it:
// Objects/tupleobject.c
static inline int
maybe_freelist_push(PyTupleObject *op)
{
if (!Py_IS_TYPE(op, &PyTuple_Type)) {
return 0; // subclasses are not recycled
}
Py_ssize_t index = Py_SIZE(op) - 1;
if (index < PyTuple_MAXSAVESIZE) {
return _Py_FREELIST_PUSH(tuples[index], op, Py_tuple_MAXFREELIST);
}
return 0;
}Because a recycled object may have been tracked by the GC before, _PyTuple_Recycle re-tracks it and resets the hash cache when it’s reused:
// Include/internal/pycore_tuple.h
static inline void
_PyTuple_Recycle(PyObject *op)
{
_PyTuple_RESET_HASH_CACHE(op);
if (!_PyObject_GC_IS_TRACKED(op)) {
_PyObject_GC_TRACK(op);
}
}Creation
PyTuple_New allocates and zeroes the slots, then tracks the object:
// Objects/tupleobject.c
PyObject *
PyTuple_New(Py_ssize_t size)
{
PyTupleObject *op;
if (size == 0) {
return tuple_get_empty();
}
op = tuple_alloc(size);
if (op == NULL) {
return NULL;
}
for (Py_ssize_t i = 0; i < size; i++) {
op->ob_item[i] = NULL;
}
_PyObject_GC_TRACK(op);
return (PyObject *) op;
}PyTuple_Pack builds a tuple from varargs and only enables GC tracking if at least one item may itself be tracked:
// Objects/tupleobject.c
PyObject *
PyTuple_Pack(Py_ssize_t n, ...)
{
...
PyTupleObject *result = tuple_alloc(n);
...
for (i = 0; i < n; i++) {
o = va_arg(vargs, PyObject *);
if (!track && maybe_tracked(o)) {
track = true;
}
items[i] = Py_NewRef(o);
}
if (track) {
_PyObject_GC_TRACK(result);
}
return (PyObject *)result;
}maybe_tracked is a conservative check: any GC-capable type makes the tuple tracked. _PyTuple_MaybeUntrack later untracks a tuple once all of its items are non-GC objects, which keeps short-lived tuples of plain values off the collector’s radar:
// Objects/tupleobject.c
void
_PyTuple_MaybeUntrack(PyObject *op)
{
if (!PyTuple_CheckExact(op) || !_PyObject_GC_IS_TRACKED(op))
return;
...
for (i = 0; i < n; i++) {
PyObject *elt = PyTuple_GET_ITEM(t, i);
if (!elt || _PyObject_GC_MAY_BE_TRACKED(elt))
return; // still potentially cyclic
}
_PyObject_GC_UNTRACK(op);
}PySequence_Tuple special-cases exact tuples (returns the same object) and exact lists (PyList_AsTuple), then falls back to iterating, buffering the first 8 items on the stack and spilling into a list.
tuple.__new__ returns the empty singleton for no argument, and otherwise delegates to PySequence_Tuple. For a tuple argument this returns the same object – tuples are immutable, so sharing is safe.
Hashing
The hash is computed lazily and cached in ob_hash. -1 is the “not yet computed” sentinel, so a real hash of -1 is remapped to 1546275796. The algorithm is a single-accumulator simplification of xxHash:
// Objects/tupleobject.c
static Py_hash_t
tuple_hash(PyObject *op)
{
PyTupleObject *v = _PyTuple_CAST(op);
Py_uhash_t acc = FT_ATOMIC_LOAD_SSIZE_RELAXED(v->ob_hash);
if (acc != (Py_uhash_t)-1) {
return acc; // cached
}
Py_ssize_t len = Py_SIZE(v);
PyObject **item = v->ob_item;
acc = _PyTuple_HASH_XXPRIME_5;
for (Py_ssize_t i = 0; i < len; i++) {
Py_uhash_t lane = PyObject_Hash(item[i]);
if (lane == (Py_uhash_t)-1) {
return -1; // propagate error
}
acc += lane * _PyTuple_HASH_XXPRIME_2;
acc = _PyTuple_HASH_XXROTATE(acc);
acc *= _PyTuple_HASH_XXPRIME_1;
}
/* Add input length, mangled to keep the historical value of hash(()). */
acc += len ^ (_PyTuple_HASH_XXPRIME_5 ^ 3527539UL);
if (acc == (Py_uhash_t)-1) {
acc = 1546275796;
}
FT_ATOMIC_STORE_SSIZE_RELAXED(v->ob_hash, acc);
return acc;
}The cache is reset to -1 whenever a tuple is recycled (_PyTuple_RESET_HASH_CACHE), and tuple_richcompare relies on immutability to reuse vlen/wlen across comparison calls.
Immutability
Immutability is enforced at the type level: tp_as_sequence has no sq_ass_item or sq_ass_slice slots, so tuple[i] = x raises TypeError.
The only mutation paths are internal, and they require a uniquely referenced, exact PyTuple_Type instance:
PyTuple_SetItemstores a pointer without increfing the new item (but decrefing the replaced one), and only succeeds when_PyObject_IsUniquelyReferenced(op)holds:
// Objects/tupleobject.c
int
PyTuple_SetItem(PyObject *op, Py_ssize_t i, PyObject *newitem)
{
...
if (!PyTuple_Check(op) || !_PyObject_IsUniquelyReferenced(op)) {
Py_XDECREF(newitem);
PyErr_BadInternalCall();
return -1;
}
...
p = ((PyTupleObject *)op) -> ob_item + i;
Py_XSETREF(*p, newitem);
return 0;
}_PyTuple_Resizecan grow or shrink a uniquely referenced exact tuple by reallocating the object in place. It decrefs deleted items on shrink and zeroes new slots on grow; the empty tuple is never resized in place (it’s the singleton):
// Objects/tupleobject.c
int
_PyTuple_Resize(PyObject **pv, Py_ssize_t newsize)
{
...
if (newsize == 0) {
Py_DECREF(v);
*pv = tuple_get_empty();
return 0;
}
if (oldsize == 0) {
/* The empty tuple is statically allocated so we never
resize it in-place. */
Py_DECREF(v);
*pv = PyTuple_New(newsize);
return *pv == NULL ? -1 : 0;
}
...
sv = PyObject_GC_Resize(PyTupleObject, v, newsize);
...
}Because tuples are immutable, operations like slicing, concatenation, and repetition can share or reuse objects: a full slice of an exact tuple returns the same object, and tuple * 1 / tuple + () return the original.
The real win: immutability is what lets CPython hand out the same tuple object in a dozen places instead of copying it. tuple(a) is a is True for a tuple.
Key takeaways
tupleis aPyObject_VAR_HEADplus a cachedob_hashand an embeddedob_itemarray;ob_sizeis both the length and the capacity.- Items are stored in the same allocation as the header (flexible array via
tp_basicsize/tp_itemsize); there’s no separate buffer. - The empty tuple is a statically allocated, non-GC singleton with a precomputed hash.
- Exact tuples of sizes 1..20 are recycled through per-size freelists of up to 2000 entries; recycled objects are re-tracked and have their hash reset.
- GC tracking is conservative: a tuple is only tracked if at least one item may be a GC object, and it can be untracked later via
_PyTuple_MaybeUntrack. - The hash is a single-accumulator xxHash variant, computed once and cached (with
-1remapped to avoid clashing with the sentinel). - Immutability is enforced by the type (no
sq_ass_item); internal mutation (PyTuple_SetItem,_PyTuple_Resize) is allowed only for uniquely referenced exact tuples.
Further reading
Objects/tupleobject.c– all of the above.Include/cpython/tupleobject.h–PyTupleObjectstruct and access macros.Include/internal/pycore_tuple.h– internal helpers, freelist recycle, hash constants.Objects/abstract.c(PySequence_Tuple) – construction from an iterable.