CPython's structseq Implementation in C (PyStructSequence): Tuples With Names
structseq is CPython's C-level named tuple: a PyTupleObject subclass with named fields, some of them hidden. Here's the visible/real size split, tp_members, and how sys.version_info is built.
TL;DR: Python’s C-level “named tuples” aren’t
collections.namedtuple— they’re struct sequences, tuple subclasses with named fields.PyStructSequenceis literally an alias forPyTupleObject, and hidden fields are smuggled into the sameob_itemarray pastob_size.
sys.version_info, os.stat_result, time.struct_time, and os.uname_result are all struct sequences. In CPython the machinery lives in Objects/structseq.c and is built around PyStructSequence, which is literally an alias for PyTupleObject:
// Include/cpython/structseq.h
typedef PyTupleObject PyStructSequence;
#define PyStructSequence_SET_ITEM PyStructSequence_SetItem
#define PyStructSequence_GET_ITEM PyStructSequence_GetItemLet’s trace that implementation through the 3.15 source.
The idea: a tuple with named, sometimes hidden, fields
A struct sequence type is described by a PyStructSequence_Desc:
// Include/structseq.h
typedef struct PyStructSequence_Field {
const char *name;
const char *doc;
} PyStructSequence_Field;
typedef struct PyStructSequence_Desc {
const char *name;
const char *doc;
PyStructSequence_Field *fields;
int n_in_sequence; /* how many fields are visible as tuple items */
} PyStructSequence_Desc;The fields array is NULL-terminated. n_in_sequence says how many of those fields participate in the sequence part (tuple length, indexing, unpacking); the rest are “hidden” fields reachable only as attributes.
os.stat_result is the canonical example: its first 10 fields form the tuple, while st_atime, st_mtime, st_ctime, st_atime_ns, … are extra attributes stored in the same object but not counted in len().
// Modules/posixmodule.c
static PyStructSequence_Desc stat_result_desc = {
"os.stat_result",
stat_result__doc__,
stat_result_fields,
10 /* n_in_sequence */
};Fields can also be unnamed. PyStructSequence_UnnamedField (the string "unnamed field") marks a slot that has an index but no attribute name; the first three time fields of stat_result (st_atime’s integer predecessor) use this. Unnamed fields count toward the sequence length but get no tp_members entry.
Visible size vs. real size
Because hidden fields must not show up to Python code (len(), indexing, iteration, unpacking), the object’s ob_size is hacked to the visible count, while the allocation still holds all fields. Two macros capture this:
// Objects/structseq.c
#define VISIBLE_SIZE(op) Py_SIZE(op)
#define REAL_SIZE(op) get_real_size((PyObject *)op)
static Py_ssize_t
get_real_size(PyObject *op)
{
// Compute the real size from the visible size (i.e., Py_SIZE()) and the
// number of non-sequence fields accounted for in tp_basicsize.
Py_ssize_t hidden = Py_TYPE(op)->tp_basicsize
- offsetof(PyStructSequence, ob_item);
return Py_SIZE(op) + hidden / sizeof(PyObject *);
}The type’s tp_basicsize is deliberately inflated to cover the hidden slots, so they live in the same contiguous ob_item array but beyond ob_size:
// Objects/structseq.c
Py_ssize_t n_hidden = n_members - desc->n_in_sequence;
type->tp_basicsize = sizeof(PyStructSequence) + (n_hidden - 1) * sizeof(PyObject *);
type->tp_itemsize = sizeof(PyObject *);This mirrors the tuple layout: tp_basicsize is the header plus the hidden slots (minus one, because PyStructSequence already contains ob_item[1]), and tp_itemsize is one slot. PyObject_GC_NewVar(type, size) with size = REAL_SIZE therefore allocates visible + hidden slots — and then the size is rewritten to the visible count:
// Objects/structseq.c
PyObject *
PyStructSequence_New(PyTypeObject *type)
{
PyStructSequence *obj;
Py_ssize_t size = REAL_SIZE_TP(type), i;
...
Py_ssize_t vsize = VISIBLE_SIZE_TP(type);
...
obj = PyObject_GC_NewVar(PyStructSequence, type, size);
if (obj == NULL)
return NULL;
_PyTuple_RESET_HASH_CACHE(obj);
/* Hack the size of the variable object, so invisible fields don't appear
to Python code. */
Py_SET_SIZE(obj, vsize);
for (i = 0; i < size; i++)
obj->ob_item[i] = NULL;
return (PyObject*)obj;
}So len(os.stat_result(...)) == 10 even though the object holds ~17 slots, and tuple(os.stat_result(...)) only yields the 10 visible ones. Indexing beyond the visible range isn’t exposed; hidden fields are attributes.
Here’s the trick: the hidden data isn’t in a side table. It’s the same array, just past the end of what ob_size admits.
Memory layout
For a type with V visible and H hidden fields:
+----------------------------------+ <- PyObject_GC_NewVar result
| ob_refcnt |
+----------------------------------+
| *ob_type (&TheStructSeqType) |
+----------------------------------+
| ob_size (= V, "visible") |
+----------------------------------+
| ob_hash (cached, from tuple) |
+----------------------------------+
| ob_item[0] (visible field 0) |
| ... |
| ob_item[V-1] (visible field V-1) |
| ob_item[V] (hidden field 0) |
| ... |
| ob_item[V+H-1] (hidden field H-1)|
+----------------------------------+H hidden slots are absorbed into tp_basicsize, so ob_size stays V. The hash cache (ob_hash) is inherited from PyTupleObject and reset on creation via _PyTuple_RESET_HASH_CACHE, which is why struct sequences hash like tuples over their visible fields.
Field access: attributes via tp_members
Named (non-unnamed) fields become PyMemberDef entries whose offset points directly into ob_item:
// Objects/structseq.c
members[k].name = desc->fields[i].name;
members[k].type = _Py_T_OBJECT;
members[k].offset = offsetof(PyStructSequence, ob_item)
+ i * sizeof(PyObject*);
members[k].flags = Py_READONLY;_Py_T_OBJECT (read-only object member) gives obj.field returning a borrowed reference without calling a getter, and Py_READONLY makes assignment raise AttributeError.
Unnamed fields are skipped, so they have no attribute. Because the offset is computed with the real index i (including unnamed fields before it), the member table stays aligned with ob_item.
PyStructSequence_SetItem / GetItem are thin wrappers that cast to PyTupleObject and index ob_item directly, with no refcount changes on set:
// Objects/structseq.c
void
PyStructSequence_SetItem(PyObject *op, Py_ssize_t index, PyObject *value)
{
PyTupleObject *tuple = _PyTuple_CAST(op);
...
tuple->ob_item[index] = value; // no incref; caller owns reference
}
PyObject *
PyStructSequence_GetItem(PyObject *op, Py_ssize_t index)
{
...
return PyTuple_GET_ITEM(op, index);
}This matches the tuple convention: PyTuple_SetItem steals a reference and is only safe on a uniquely referenced, not-yet-published object. Typical C usage is exactly that – PyStructSequence_New then SET_ITEM for each slot, then _PyObject_GC_TRACK.
Type creation
There are two paths. Builtin static types (version_info, stat_result, struct_time) use _PyStructSequence_InitBuiltinWithFlags, which fills in a pre-declared PyTypeObject:
// Objects/structseq.c
int
_PyStructSequence_InitBuiltinWithFlags(PyInterpreterState *interp,
PyTypeObject *type,
PyStructSequence_Desc *desc,
unsigned long tp_flags)
{
...
members = initialize_members(desc, n_members, n_unnamed_members);
initialize_static_fields(type, desc, members, n_members, tp_flags);
_Py_SetImmortal((PyObject *)type);
...
_PyStaticType_InitBuiltin(interp, type);
initialize_structseq_dict(desc, _PyType_GetDict(type), n_members, n_unnamed_members);
...
}initialize_static_fields wires everything up:
// Objects/structseq.c
static void
initialize_static_fields(PyTypeObject *type, PyStructSequence_Desc *desc,
PyMemberDef *tp_members, Py_ssize_t n_members,
unsigned long tp_flags)
{
type->tp_name = desc->name;
...
type->tp_dealloc = structseq_dealloc;
type->tp_repr = structseq_repr;
type->tp_doc = desc->doc;
type->tp_base = &PyTuple_Type; // <-- tuple subclass
type->tp_methods = structseq_methods; // __reduce__, __replace__
type->tp_new = structseq_new;
type->tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC | tp_flags;
type->tp_traverse = structseq_traverse;
type->tp_members = tp_members;
}Setting tp_base = &PyTuple_Type is what makes a struct sequence a tuple: it inherits tuple’s sequence protocol, hashing, comparison, __getitem__, iteration – and isinstance(x, tuple) is true. The type is also made immortal for builtins.
For dynamically created types (e.g. via PyStructSequence_NewType, used by extensions), _PyStructSequence_NewType builds a heap type from a PyType_Spec with PyType_FromSpecWithBases(..., (PyObject *)&PyTuple_Type).
The type dictionary: sizes and __match_args__
initialize_structseq_dict stores three integer entries used at runtime by the VISIBLE_SIZE_TP / REAL_SIZE_TP / UNNAMED_FIELDS_TP macros, plus __match_args__:
// Objects/structseq.c
SET_DICT_FROM_SIZE(visible_length_key, desc->n_in_sequence); // "n_sequence_fields"
SET_DICT_FROM_SIZE(real_length_key, n_members); // "n_fields"
SET_DICT_FROM_SIZE(unnamed_fields_key, n_unnamed_members); // "n_unnamed_fields"
__match_args__ is built from the visible field names (skipping unnamed ones), resized with _PyTuple_Resize, and stored under "__match_args__". That’s what enables match obj: case sys.version_info(major=3, ...) style patterns and positional match args.
Construction from Python: structseq_new
__new__ accepts a sequence (and optional dict of field-name overrides):
// Objects/structseq.c
static PyObject *
structseq_new_impl(PyTypeObject *type, PyObject *arg, PyObject *dict)It validates that len(arg) is between min_len (visible) and max_len (real), copies the first len items, then fills the remaining slots from dict (by field name) or None.
This allows partial construction such as os.stat_result((mode, ino, dev, nlink, uid, gid, size, atime, mtime, ctime), {'st_atime_ns': ...}). After filling, it calls _PyObject_GC_TRACK.
GC: traverse and dealloc
Because hidden fields are in ob_item too, both must iterate over REAL_SIZE:
// Objects/structseq.c
static int
structseq_traverse(PyObject *op, visitproc visit, void *arg)
{
...
size = REAL_SIZE(obj);
for (i = 0; i < size; ++i) {
Py_VISIT(obj->ob_item[i]);
}
return 0;
}
static void
structseq_dealloc(PyObject *op)
{
...
size = REAL_SIZE(obj); // cannot use type dict: GC may have cleared it
for (i = 0; i < size; ++i) {
Py_XDECREF(obj->ob_item[i]);
}
PyObject_GC_Del(obj);
if (_PyType_HasFeature(tp, Py_TPFLAGS_HEAPTYPE)) {
Py_DECREF(tp);
}
}Note the comment in structseq_dealloc: REAL_SIZE_TP() can’t be used during dealloc because the GC may have already cleared the type’s __dict__; instead REAL_SIZE(op) recomputes the hidden count from tp_basicsize, which survives.
repr, reduce, and __replace__
structseq_repr writes typename(field=value, ...) using only the visible fields:
// Objects/structseq.c
for (Py_ssize_t i=0; i < VISIBLE_SIZE(obj); i++) {
...
const char *name_utf8 = typ->tp_members[i].name;
...
PyUnicodeWriter_WriteUTF8(writer, name_utf8, -1);
...
PyObject *value = PyStructSequence_GetItem((PyObject*)obj, i);
PyUnicodeWriter_WriteRepr(writer, value);
}structseq_reduce returns (type, (visible_tuple, hidden_dict)), so pickling/reconstruction round-trips through __new__:
// Objects/structseq.c
tup = PyTuple_FromArray(self->ob_item, n_visible_fields);
dict = PyDict_New();
for (i = n_visible_fields; i < n_fields; i++) {
const char *n = Py_TYPE(self)->tp_members[i - n_unnamed_fields].name;
PyDict_SetItemString(dict, n, self->ob_item[i]);
}
result = Py_BuildValue("(O(OO))", Py_TYPE(self), tup, dict);__replace__ (PEP 73? functional update) creates a copy and applies keyword overrides; it’s disabled for types with unnamed fields:
// Objects/structseq.c
if (n_unnamed_fields > 0) {
PyErr_Format(PyExc_TypeError,
"__replace__() is not supported for %.500s "
"because it has unnamed field(s)", ...);
return NULL;
}Why it is “namedtuple-like”
From Python’s perspective a struct sequence is a tuple subclass instance:
isinstance(sys.version_info, tuple)isTrue.len(), indexing, slicing, iteration, unpacking, comparison, and hashing all come fromPyTuple_Type.- Elements are also attributes:
sys.version_info.major,stat.st_mode. repristypename(field=value, ...).- It supports
__reduce__and__replace__.
The difference from collections.namedtuple is that struct sequences are defined in C via a descriptor, store hidden (non-sequence) fields, and are immutable tuple subclasses whose slots are raw PyObject * in one allocation. Named tuples created in Python are also tuple subclasses, but generated dynamically with _make, _asdict, etc., and have no hidden fields.
Example: sys.version_info
// Python/sysmodule.c
static PyStructSequence_Field version_info_fields[] = {
{"major", "Major release number"},
{"minor", "Minor release number"},
{"micro", "Patch release number"},
{"releaselevel", "'alpha', 'beta', 'candidate', or 'final'"},
{"serial", "Serial release number"},
{0}
};
static PyStructSequence_Desc version_info_desc = {
"sys.version_info",
version_info__doc__,
version_info_fields,
5 /* n_in_sequence: all 5 are visible */
};
static PyObject *
make_version_info(PyThreadState *tstate)
{
PyObject *version_info;
version_info = PyStructSequence_New(&VersionInfoType);
...
PyStructSequence_SET_ITEM(version_info, pos++, PyLong_FromLong(PY_MAJOR_VERSION));
...
}Here n_in_sequence == 5 == n_members, so there are no hidden fields; the type is a plain named 5-tuple. The type is initialized at interpreter startup with ENSURE_INFO_TYPE(VersionInfoType, version_info_desc).
Key takeaways
PyStructSequenceis justPyTupleObject; struct sequences are tuple subclasses (tp_base = &PyTuple_Type), gaining all tuple behavior.- Fields are stored in the same embedded
ob_itemarray as a tuple. n_in_sequencevisible fields are exposed viaob_size; hidden fields are absorbed intotp_basicsize, so they live inob_itembeyondob_sizeand are invisible tolen/indexing.- Named fields become read-only
PyMemberDefentries pointing intoob_item; unnamed fields (PyStructSequence_UnnamedField) have no attribute. - Types are described by
PyStructSequence_Descand initialized either as static builtins (_PyStructSequence_InitBuiltinWithFlags) or heap types (_PyStructSequence_NewType). - The type dict stores
n_sequence_fields,n_fields,n_unnamed_fields, and__match_args__. PyStructSequence_SetItemstores without incref (like tuple); creation isNew+SET_ITEM+_PyObject_GC_TRACK.- GC traverse/dealloc use
REAL_SIZE(not the visible size) to visit all slots. repr,__reduce__, and__replace__operate over visible (and hidden, for reduce) fields.
Further reading
Objects/structseq.c– all of the above.Include/structseq.h,Include/cpython/structseq.h–PyStructSequence_Desc,PyStructSequencealias, API.Python/sysmodule.c–sys.version_info.Modules/posixmodule.c–os.stat_result,os.statvfs_result.Modules/timemodule.c–time.struct_time.Objects/tupleobject.c– the underlying tuple implementation.