CPython's types.GenericAlias Implementation in C

list[int] is a tiny C struct: an origin, a tuple of args, and a lazy __parameters__ cache. Here's how CPython builds and substitutes it.

TL;DR: list[int] is not a class and not a type variable — it’s a tiny C object holding an origin and a tuple of args, plus a lazily computed __parameters__ cache and a starred flag for *Ts unpacking.

Python’s types.GenericAlias is the object you get from list[int] or dict[str, int]. It is not a class and not a type variable; it is a small wrapper that remembers which generic origin (list) was parameterized and with what arguments ((int,)), and knows how to substitute type variables when it is further subscripted. In CPython 3.15 it is implemented by gaobject in Objects/genericaliasobject.c. This article traces that implementation.

The object struct

c
// Objects/genericaliasobject.c
typedef struct {
    PyObject_HEAD
    PyObject *origin;        // the unparameterized object, e.g. list
    PyObject *args;          // tuple of type arguments, e.g. (int,)
    PyObject *parameters;    // lazily computed __parameters__, or NULL
    PyObject *weakreflist;
    // Whether we're a starred type, e.g. *tuple[int].
    bool starred;
    vectorcallfunc vectorcall;
} gaobject;

There is also a companion iterator type, gaiterobject, used to implement starred unpacking (*tuple[int]); it holds a single PyObject *obj.

The main object is GC-tracked (it can hold arbitrary objects in args and origin) and supports weak references through weakreflist. Deallocation is straightforward: untrack, clear weakrefs, decref the three owned objects, then free:

c
// Objects/genericaliasobject.c
static void
ga_dealloc(PyObject *self)
{
    gaobject *alias = (gaobject *)self;

    _PyObject_GC_UNTRACK(self);
    FT_CLEAR_WEAKREFS(self, alias->weakreflist);
    Py_XDECREF(alias->origin);
    Py_XDECREF(alias->args);
    Py_XDECREF(alias->parameters);
    Py_TYPE(self)->tp_free(self);
}

ga_traverse visits origin, args, and parameters so the collector can find cycles.

Bottom line: six fields, three of which hold real objects. This is about as small as an object can be and still carry full generic-alias semantics.

origin and args

Two fields carry almost all of the meaning:

  • origin is the thing being parameterized: a builtin like list, a collections.abc generic, a user class, or even another GenericAlias.
  • args is always a tuple internally, even if the caller passed a single value. setup_ga normalizes this:
c
// Objects/genericaliasobject.c
static inline int
setup_ga(gaobject *alias, PyObject *origin, PyObject *args) {
    if (!PyTuple_Check(args)) {
        args = PyTuple_Pack(1, args);
        if (args == NULL) {
            return 0;
        }
    }
    else {
        Py_INCREF(args);
    }

    alias->origin = Py_NewRef(origin);
    alias->args = args;
    alias->parameters = NULL;
    alias->weakreflist = NULL;

    if (PyVectorcall_Function(origin) != NULL) {
        alias->vectorcall = ga_vectorcall;
    }
    else {
        alias->vectorcall = NULL;
    }

    return 1;
}

Both construction entry points – Py_GenericAlias(origin, args) and GenericAlias.__new__ (ga_new) – go through setup_ga, so the invariant “args is a tuple” holds everywhere. ga_new requires exactly two positional arguments and no keywords, mirroring the documented GenericAlias(origin, args, /).

The two fields are exposed read-only through ga_members:

c
// Objects/genericaliasobject.c
static PyMemberDef ga_members[] = {
    {"__origin__", _Py_T_OBJECT, offsetof(gaobject, origin), Py_READONLY},
    {"__args__",   _Py_T_OBJECT, offsetof(gaobject, args),   Py_READONLY},
    {"__unpacked__", Py_T_BOOL, offsetof(gaobject, starred), Py_READONLY},
    {0}
};

__origin__ and __args__ are therefore just direct field reads – there is no copying or recomputation on access.

A small but important optimization: if origin supports vectorcall, the alias stores ga_vectorcall in its own vectorcall slot (offset tp_vectorcall_offset). When you call the alias, e.g. list[int]() or list[int](iterable), CPython calls the origin directly and then stamps the result with __orig_class__:

c
// Objects/genericaliasobject.c
static PyObject *
ga_call(PyObject *self, PyObject *args, PyObject *kwds)
{
    gaobject *alias = (gaobject *)self;
    PyObject *obj = PyObject_Call(alias->origin, args, kwds);
    return set_orig_class(obj, self);
}

static PyObject *
ga_vectorcall(PyObject *self, PyObject *const *args,
              size_t nargsf, PyObject *kwnames)
{
    gaobject *alias = (gaobject *) self;
    PyObject *obj = PyObject_Vectorcall(alias->origin, args, nargsf, kwnames);
    return set_orig_class(obj, self);
}

set_orig_class sets __orig_class__ on the constructed instance when the origin allows it, swallowing AttributeError/TypeError (so origins that reject arbitrary attributes do not fail the call).

__parameters__ (type variables)

__parameters__ is not stored eagerly. It is computed on first access and cached in parameters under a per-object critical section (the 3.15 build makes this thread-safe on free-threaded builds):

c
// Objects/genericaliasobject.c
static PyObject *
ga_parameters_lock_held(PyObject *self)
{
    _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(self);
    gaobject *alias = (gaobject *)self;
    if (alias->parameters == NULL) {
        alias->parameters = _Py_make_parameters(alias->args);
        if (alias->parameters == NULL) {
            return NULL;
        }
    }
    return Py_NewRef(alias->parameters);
}

static PyObject *
ga_parameters(PyObject *self, void *unused)
{
    PyObject *result;
    Py_BEGIN_CRITICAL_SECTION(self);
    result = ga_parameters_lock_held(self);
    Py_END_CRITICAL_SECTION();
    return result;
}

_Py_make_parameters walks the args tuple and collects every type variable it finds. The rules, in order:

  1. A plain type (PyType_Check) contributes nothing – list[int] has no parameters because int is concrete.
  2. An object with __typing_subst__ (a TypeVar/TypeVarTuple/ParamSpec) is added, deduplicated via tuple_add.
  3. Otherwise it looks for __parameters__ on the object. If that is missing and the object is a tuple or list, it recurses into _Py_make_parameters, flattening nested parameters.
  4. A __parameters__ tuple is flattened, with the outer tuple resized as needed (_PyTuple_Resize) to make room.
c
// Objects/genericaliasobject.c
PyObject *
_Py_make_parameters(PyObject *args)
{
    assert(PyTuple_Check(args) || PyList_Check(args));
    if (Py_EnterRecursiveCall(" in __parameter__ calculation")) {
        return NULL;
    }
    ...
    Py_ssize_t len = nargs;
    PyObject *parameters = PyTuple_New(len);
    ...
    for (Py_ssize_t iarg = 0; iarg < nargs; iarg++) {
        PyObject *t = PyTuple_GET_ITEM(args, iarg);
        if (PyType_Check(t)) {
            continue;                       // concrete type
        }
        int rc = PyObject_HasAttrWithError(t, &_Py_ID(__typing_subst__));
        ...
        if (rc) {
            iparam += tuple_add(parameters, iparam, t);   // TypeVar-like
        }
        else {
            ... // __parameters__ lookup, recursion for tuple/list
        }
    }
    if (iparam < len) {
        if (_PyTuple_Resize(&parameters, iparam) < 0) { ... }
    }
    ...
}

tuple_add only appends when the item is not already present, so dict[T, T] has parameters (T,), not (T, T). The recursion plus resize is what makes list[list[T]] resolve to parameters (T,).

Because parameters is cached, the (possibly expensive) walk happens at most once per alias. Recursive construction is guarded by Py_EnterRecursiveCall, and a recent fix ensures cleanup runs correctly on deeply nested input.

This is the part people get wrong. list[int] has no parameters. Parameters only appear when a type variable is actually in play, and duplicates collapse.

ParamSpec and list-shaped arguments

ParamSpec captures a list of parameter types, and that shows up in __args__ as a list rather than a tuple. For example Callable[[int, str], bool] stores something like (([int, str], bool)) internally, with the ParamSpec argument represented as a list [int, str]. The code handles this in two places.

In _Py_make_parameters, lists are converted to tuples up front (PySequence_Tuple) so the same recursive walk applies. And in ga_repr, a list element of args is rendered by ga_repr_items_list, which prints it with square brackets:

c
// Objects/genericaliasobject.c
static int
ga_repr_items_list(PyUnicodeWriter *writer, PyObject *p)
{
    assert(PyList_CheckExact(p));
    ...
    if (PyUnicodeWriter_WriteChar(writer, '[') < 0) { return -1; }
    for (Py_ssize_t i = 0; i < len; i++) {
        ...
        if (_Py_typing_type_repr(writer, item) < 0) { ... }
    }
    if (PyUnicodeWriter_WriteChar(writer, ']') < 0) { return -1; }
    return 0;
}

and in ga_repr:

c
// Objects/genericaliasobject.c
for (Py_ssize_t i = 0; i < len; i++) {
    PyObject *p = PyTuple_GET_ITEM(alias->args, i);
    if (PyList_CheckExact(p)) {
        // Looks like we are working with ParamSpec's list of type args
        if (ga_repr_items_list(writer, p) < 0) { goto error; }
    }
    else if (_Py_typing_type_repr(writer, p) < 0) {
        goto error;
    }
}

So the list-vs-tuple distinction in args is precisely what distinguishes a ParamSpec argument list from an ordinary tuple of type arguments, and it is preserved (not normalized) by setup_ga because only a non-tuple scalar is packed; a list passed as an element stays a list.

Gotcha: the top-level args is always a tuple, but an element of it can be a list. That asymmetry is deliberate, and it is what keeps Callable[[int, str], bool] printable as Callable[[int, str], bool].

Substitution: alias[item]

Subscripting a GenericAlias is where type variables get replaced. ga_getitem locks the object, materializes __parameters__, then calls _Py_subs_parameters:

c
// Objects/genericaliasobject.c
static PyObject *
ga_getitem(PyObject *self, PyObject *item)
{
    gaobject *alias = (gaobject *)self;
    PyObject *parameters;
    Py_BEGIN_CRITICAL_SECTION(self);
    parameters = ga_parameters_lock_held(self);
    Py_END_CRITICAL_SECTION();
    if (parameters == NULL) {
        return NULL;
    }

    PyObject *newargs = _Py_subs_parameters(self, alias->args, parameters, item);
    Py_DECREF(parameters);
    if (newargs == NULL) {
        return NULL;
    }

    PyObject *res = Py_GenericAlias(alias->origin, newargs);
    if (res == NULL) { ... }
    ((gaobject *)res)->starred = alias->starred;

    Py_DECREF(newargs);
    return res;
}

The result is a new GenericAlias with the same origin and the same starred flag, but with substituted args. The origin itself is reused, so list[int][str] becomes list[str].

_Py_subs_parameters does the heavy lifting:

  • _unpack_args(item) normalizes the subscript into a tuple, flattening starred unpacked TypeVarTuples (*Ts) into the argument list.
  • It validates the argument count against nparams.
  • For each original arg:
    • plain types are copied verbatim;
    • nested tuples/lists are substituted recursively;
    • objects with __typing_subst__ have that method called with the matching type argument;
    • otherwise subs_tvars handles nested generics by looking up their own __parameters__ and re-subscripting them.

subs_tvars is the recursive case: if obj has a non-empty __parameters__, it builds a fresh subargs tuple by mapping each of its parameters to the corresponding substitution (with special handling for TypeVarTuple, where a tuple argument is spliced in via tuple_extend), then calls PyObject_GetItem(obj, subargs) to produce obj[subargs]. Otherwise it returns obj unchanged:

c
// Objects/genericaliasobject.c
static PyObject *
subs_tvars(PyObject *obj, PyObject *params,
           PyObject **argitems, Py_ssize_t nargs)
{
    PyObject *subparams;
    if (PyObject_GetOptionalAttr(obj, &_Py_ID(__parameters__), &subparams) < 0) {
        return NULL;
    }
    if (subparams && PyTuple_Check(subparams) && PyTuple_GET_SIZE(subparams)) {
        ...
        PyObject *subargs = PyTuple_New(nsubargs);
        ...
        for (Py_ssize_t i = 0; i < nsubargs; ++i) {
            PyObject *arg = PyTuple_GET_ITEM(subparams, i);
            Py_ssize_t iparam = tuple_index(params, nparams, arg);
            if (iparam >= 0) {
                PyObject *param = PyTuple_GET_ITEM(params, iparam);
                arg = argitems[iparam];
                if (Py_TYPE(param)->tp_iter && PyTuple_Check(arg)) {
                    // TypeVarTuple: splice the tuple in
                    j = tuple_extend(&subargs, j, ...);
                    continue;
                }
            }
            PyTuple_SET_ITEM(subargs, j, Py_NewRef(arg));
            j++;
        }
        obj = PyObject_GetItem(obj, subargs);
        Py_DECREF(subargs);
    }
    else {
        Py_INCREF(obj);
    }
    Py_XDECREF(subparams);
    return obj;
}

This is why list[T][int] works even though T only appears inside list’s args: the outer alias’s parameters drive substitution into the nested generic.

Starred unpacking (*tuple[int])

A GenericAlias can be starred for use in *Ts unpacking contexts. The starred bool is set on the object and exposed as __unpacked__. Iterating a starred *tuple[...] alias yields one alias per element type, each itself marked starred:

c
// Objects/genericaliasobject.c
static PyObject *
ga_iternext(PyObject *op)
{
    gaiterobject *gi = (gaiterobject*)op;
    ...
    gaobject *alias = (gaobject *)obj;
    PyObject *starred_alias = Py_GenericAlias(alias->origin, alias->args);
    ...
    ((gaobject *)starred_alias)->starred = true;
    return starred_alias;
}

ga_iter creates a gaiterobject holding a reference to the original alias, and ga_unpacked_tuple_args fast-paths a starred tuple alias by returning its args directly. Starred state also participates in equality (ga_richcompare compares starred first) and in __reduce__ (a starred alias pickles via next(iter(...))).

Attribute access

ga_getattro implements a hybrid: most attribute names are proxied to origin, but a fixed list of “own” attributes (attr_exceptions) and a blocked list (attr_blocked) are handled by the generic object machinery:

c
// Objects/genericaliasobject.c
static const char* const attr_exceptions[] = {
    "__class__", "__origin__", "__args__", "__unpacked__",
    "__parameters__", "__typing_unpacked_tuple_args__",
    "__mro_entries__", "__reduce_ex__", "__reduce__", NULL,
};
static const char* const attr_blocked[] = {
    "__bases__", "__copy__", "__deepcopy__", NULL,
};

Blocked attributes are never proxied (they fall through to generic_getattr, which usually raises AttributeError), preventing a generic alias from inheriting dangerous or meaningless operations from its origin. Everything else – append, __name__, etc. – is forwarded to origin, so list[int].append resolves to list.append.

Hashing and equality

Equality compares starred, then origin, then args. Hashing combines the hashes of origin and args with XOR:

c
// Objects/genericaliasobject.c
static Py_hash_t
ga_hash(PyObject *self)
{
    gaobject *alias = (gaobject *)self;
    Py_hash_t h0 = PyObject_Hash(alias->origin);
    if (h0 == -1) return -1;
    Py_hash_t h1 = PyObject_Hash(alias->args);
    if (h1 == -1) return -1;
    return h0 ^ h1;
}

Note the origin’s hash is currently not folded in a more sophisticated way (there is a // TODO: Hash in the hash for the origin comment), but the result is still a valid hash consistent with equality.

Calling, __mro_entries__, and isinstance

  • Calling delegates to origin and sets __orig_class__ (above).
  • __mro_entries__ returns (origin,), so a generic alias can be used as a base class in class Foo(list[int]): ... (PEP 560).
  • __instancecheck__ and __subclasscheck__ raise TypeError: you cannot use a parameterized generic with isinstance/issubclass.

Gotcha: isinstance([], list[int]) is a TypeError, not False. If you need a runtime check, use the origin.

Key takeaways

  • types.GenericAlias is a GC-tracked gaobject with five fields: origin, args (always a tuple), lazily cached parameters, weakreflist, and a starred flag, plus an optional vectorcall pointer.
  • origin is the unparameterized generic; args holds the type arguments. A non-tuple scalar argument is packed into a one-tuple by setup_ga, but a list element is preserved (it denotes a ParamSpec argument list).
  • __parameters__ is computed lazily and cached under a critical section; _Py_make_parameters recursively collects and deduplicates type variables, flattening nested generics and (via list-to-tuple conversion) ParamSpecs.
  • Subscripting builds a new alias with substituted args via _Py_subs_parameters, which handles count validation, nested substitution, __typing_subst__, and TypeVarTuple splicing.
  • Starred aliases support *Ts unpacking and iterate into per-element starred aliases.
  • Attribute access proxies to origin except for a fixed allow/block list, so methods resolve while __bases__/copy operations are blocked.
  • Equality and hashing are derived from starred, origin, and args.

Further reading

  • Objects/genericaliasobject.c – all of the above.
  • Include/genericaliasobject.h – public Py_GenericAlias and Py_GenericAliasType.
  • Objects/unionobject.c – _Py_union_type_or, used for X | Y of aliases.
  • PEP 585 – type hinting generics in standard collections.

PythonCpythonInternalsGenericaliasTyping