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. PyStructSequence is literally an alias for PyTupleObject, and hidden fields are smuggled into the same ob_item array past ob_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:

c
// Include/cpython/structseq.h
typedef PyTupleObject PyStructSequence;
#define PyStructSequence_SET_ITEM PyStructSequence_SetItem
#define PyStructSequence_GET_ITEM PyStructSequence_GetItem

Let’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:

c
// 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().

c
// 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:

c
// 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:

c
// 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:

c
// 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:

text
+----------------------------------+  <- 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:

c
// 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:

c
// 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:

c
// 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:

c
// 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__:

c
// 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):

c
// 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:

c
// 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:

c
// 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__:

c
// 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:

c
// 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) is True.
  • len(), indexing, slicing, iteration, unpacking, comparison, and hashing all come from PyTuple_Type.
  • Elements are also attributes: sys.version_info.major, stat.st_mode.
  • repr is typename(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

c
// 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

  • PyStructSequence is just PyTupleObject; struct sequences are tuple subclasses (tp_base = &PyTuple_Type), gaining all tuple behavior.
  • Fields are stored in the same embedded ob_item array as a tuple.
  • n_in_sequence visible fields are exposed via ob_size; hidden fields are absorbed into tp_basicsize, so they live in ob_item beyond ob_size and are invisible to len/indexing.
  • Named fields become read-only PyMemberDef entries pointing into ob_item; unnamed fields (PyStructSequence_UnnamedField) have no attribute.
  • Types are described by PyStructSequence_Desc and 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_SetItem stores without incref (like tuple); creation is New + 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, PyStructSequence alias, 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.

PythonCpythonInternalsStructseq