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 anoriginand a tuple ofargs, plus a lazily computed__parameters__cache and astarredflag for*Tsunpacking.
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
// 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:
// 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:
originis the thing being parameterized: a builtin likelist, acollections.abcgeneric, a user class, or even anotherGenericAlias.argsis always a tuple internally, even if the caller passed a single value.setup_ganormalizes this:
// 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:
// 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__:
// 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):
// 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:
- A plain type (
PyType_Check) contributes nothing –list[int]has no parameters becauseintis concrete. - An object with
__typing_subst__(aTypeVar/TypeVarTuple/ParamSpec) is added, deduplicated viatuple_add. - 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. - A
__parameters__tuple is flattened, with the outer tuple resized as needed (_PyTuple_Resize) to make room.
// 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(¶meters, 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:
// 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:
// 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:
// 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_tvarshandles 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:
// 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:
// 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:
// 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:
// 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
originand sets__orig_class__(above). __mro_entries__returns(origin,), so a generic alias can be used as a base class inclass Foo(list[int]): ...(PEP 560).__instancecheck__and__subclasscheck__raiseTypeError: you cannot use a parameterized generic withisinstance/issubclass.
Gotcha: isinstance([], list[int]) is a TypeError, not False. If you
need a runtime check, use the origin.
Key takeaways
types.GenericAliasis a GC-trackedgaobjectwith five fields:origin,args(always a tuple), lazily cachedparameters,weakreflist, and astarredflag, plus an optionalvectorcallpointer.originis the unparameterized generic;argsholds the type arguments. A non-tuple scalar argument is packed into a one-tuple bysetup_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_parametersrecursively 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
*Tsunpacking and iterate into per-element starred aliases. - Attribute access proxies to
originexcept for a fixed allow/block list, so methods resolve while__bases__/copy operations are blocked. - Equality and hashing are derived from
starred,origin, andargs.
Further reading
Objects/genericaliasobject.c– all of the above.Include/genericaliasobject.h– publicPy_GenericAliasandPy_GenericAliasType.Objects/unionobject.c–_Py_union_type_or, used forX | Yof aliases.- PEP 585 – type hinting generics in standard collections.