CPython's Union Type Implementation in C (X | Y)
UnionType as a GC object: unionobject struct, _PyUnion_Type, unionbuilder dedup/flattening, hashable vs unhashable args, __or__ via type_as_number, __getitem__ substitution, parameters, and weakrefs, traced through the CPython 3.15 source.
Python’s X | Y syntax (and typing.Union[X, Y]) produces an object of type
types.UnionType. Unlike Ellipsis or None, a union is a real allocated
object: it owns references to its arguments, participates in garbage collection,
and can be weakly referenced. The whole implementation lives in
Objects/unionobject.c. This article traces it through the 3.15 source.
The struct: unionobject
// Objects/unionobject.c
typedef struct {
PyObject_HEAD
PyObject *args; // all args (tuple)
PyObject *hashable_args; // frozenset or NULL
PyObject *unhashable_args; // tuple or NULL
PyObject *parameters;
PyObject *weakreflist;
} unionobject;argsis the ordered, deduplicated tuple of members, in the order they were first seen. This is what__args__exposes and whatrepriterates.hashable_argsis afrozensetof the members that hash successfully. It exists so that two unions can be compared/hashed cheaply.unhashable_argsis a tuple of members that do not hash (e.g. lists, or types containing unhashable parameters). It may beNULL.parametersis lazily computed (type variables found inargs), guarded by a critical section.weakreflistsupportsweakref.ref(union).
The type: _PyUnion_Type
// Objects/unionobject.c
PyTypeObject _PyUnion_Type = {
PyVarObject_HEAD_INIT(&PyType_Type, 0)
.tp_name = "typing.Union",
.tp_doc = PyDoc_STR("Represent a union type\n\nE.g. for int | str"),
.tp_basicsize = sizeof(unionobject),
.tp_dealloc = unionobject_dealloc,
.tp_alloc = PyType_GenericAlloc,
.tp_free = PyObject_GC_Del,
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC,
.tp_traverse = union_traverse,
.tp_hash = union_hash,
.tp_getattro = union_getattro,
.tp_members = union_members, // __args__
.tp_methods = union_methods, // __mro_entries__, __class_getitem__
.tp_richcompare = union_richcompare,
.tp_as_mapping = &union_as_mapping, // __getitem__
.tp_as_number = &union_as_number, // __or__
.tp_repr = union_repr,
.tp_getset = union_properties, // __name__, __qualname__, __origin__, __parameters__
.tp_weaklistoffset = offsetof(unionobject, weakreflist),
};Key points:
tp_nameis"typing.Union"even though Python exposes it astypes.UnionType.types.UnionType = type(int | str)(seeLib/types.py), and_typesmodule.cexports_PyUnion_Typeunder the name"UnionType".- It is a GC type (
Py_TPFLAGS_HAVE_GC,tp_traverse,tp_free = PyObject_GC_Del), because it owns references to arbitrary objects. - It is not a heap type and is statically allocated, so it is immortal and
safe to export with
EXPORT_STATIC_TYPE. tp_weaklistoffsetenables weak references.
It is exported here:
// Modules/_typesmodule.c
EXPORT_STATIC_TYPE("UnionType", _PyUnion_Type);Deallocation and GC
// Objects/unionobject.c
static void
unionobject_dealloc(PyObject *self)
{
unionobject *alias = (unionobject *)self;
_PyObject_GC_UNTRACK(self);
FT_CLEAR_WEAKREFS(self, alias->weakreflist);
Py_XDECREF(alias->args);
Py_XDECREF(alias->hashable_args);
Py_XDECREF(alias->unhashable_args);
Py_XDECREF(alias->parameters);
Py_TYPE(self)->tp_free(self);
}
static int
union_traverse(PyObject *self, visitproc visit, void *arg)
{
unionobject *alias = (unionobject *)self;
Py_VISIT(alias->args);
Py_VISIT(alias->hashable_args);
Py_VISIT(alias->unhashable_args);
Py_VISIT(alias->parameters);
return 0;
}FT_CLEAR_WEAKREFS clears weak references before freeing. tp_free is
PyObject_GC_Del, matching GC allocation.
Equality and hashing
A union is hashable only if all of its members are hashable. The split into
hashable_args (a frozenset) and unhashable_args (a tuple) lets equality
and hashing be computed without re-hashing members on every call:
// Objects/unionobject.c
static Py_hash_t
union_hash(PyObject *self)
{
unionobject *alias = (unionobject *)self;
if (alias->unhashable_args) {
// Attempt to get an error from one of the values.
assert(PyTuple_CheckExact(alias->unhashable_args));
Py_ssize_t n = PyTuple_GET_SIZE(alias->unhashable_args);
for (Py_ssize_t i = 0; i < n; i++) {
PyObject *arg = PyTuple_GET_ITEM(alias->unhashable_args, i);
Py_hash_t hash = PyObject_Hash(arg);
if (hash == -1) {
return -1;
}
}
// The unhashable values somehow became hashable again. Still raise
// an error.
PyErr_Format(PyExc_TypeError, "union contains %zd unhashable elements", n);
return -1;
}
return PyObject_Hash(alias->hashable_args);
}unions_equal first compares the frozensets, then (if both sides have
unhashable members) checks mutual containment of the unhashable tuples. This
makes int | str == str | int true while still distinguishing unions that
differ only in unhashable members.
union_richcompare only handles ==/!= between two unions; anything else
returns NotImplemented.
Building a union: unionbuilder
Construction is not done by directly filling the struct. Instead a small builder accumulates members, deduplicates them, flattens nested unions, and collapses single-member results:
// Objects/unionobject.c
typedef struct {
PyObject *args; // list
PyObject *hashable_args; // set
PyObject *unhashable_args; // list or NULL
bool is_checked; // whether to call type_check()
} unionbuilder;unionbuilder_init creates an empty args list and an empty hashable set.
unionbuilder_add_single is the core:
// Objects/unionobject.c
static bool
unionbuilder_add_single(unionbuilder *ub, PyObject *arg)
{
if (Py_IsNone(arg)) {
arg = (PyObject *)&_PyNone_Type; // immortal, so no refcounting needed
}
else if (_PyUnion_Check(arg)) {
PyObject *args = ((unionobject *)arg)->args;
return unionbuilder_add_tuple(ub, args); // flatten nested unions
}
if (ub->is_checked) {
PyObject *type = type_check(arg, "Union[arg, ...]: each arg must be a type.");
if (type == NULL) {
return false;
}
bool result = unionbuilder_add_single_unchecked(ub, type);
Py_DECREF(type);
return result;
}
else {
return unionbuilder_add_single_unchecked(ub, arg);
}
}Two important behaviors:
Noneis normalized toNoneType(_PyNone_Type), soint | Nonebecomesint | NoneType.- Nested unions are flattened: adding an existing
UnionTypesplices itsargstuple into the new builder, so(int | str) | floatis the same asint | str | float.
unionbuilder_add_single_unchecked does the dedup:
// Objects/unionobject.c
static bool
unionbuilder_add_single_unchecked(unionbuilder *ub, PyObject *arg)
{
Py_hash_t hash = PyObject_Hash(arg);
if (hash == -1) {
PyErr_Clear();
if (ub->unhashable_args == NULL) {
ub->unhashable_args = PyList_New(0);
...
}
else {
int contains = PySequence_Contains(ub->unhashable_args, arg);
if (contains < 0) return false;
if (contains == 1) return true; // duplicate, skip
}
if (PyList_Append(ub->unhashable_args, arg) < 0) return false;
}
else {
int contains = PySet_Contains(ub->hashable_args, arg);
if (contains < 0) return false;
if (contains == 1) return true; // duplicate, skip
if (PySet_Add(ub->hashable_args, arg) < 0) return false;
}
return PyList_Append(ub->args, arg) == 0;
}Hashable members go into a set for O(1) dedup; unhashable members fall back to
linear PySequence_Contains. Either way, args (the ordered list) only gets
the member if it is new.
make_union finalizes:
// Objects/unionobject.c
static PyObject *
make_union(unionbuilder *ub)
{
Py_ssize_t n = PyList_GET_SIZE(ub->args);
if (n == 0) {
PyErr_SetString(PyExc_TypeError, "Cannot take a Union of no types.");
...
return NULL;
}
if (n == 1) {
PyObject *result = PyList_GET_ITEM(ub->args, 0);
Py_INCREF(result);
unionbuilder_finalize(ub);
return result; // collapse single-member "union" to the member
}
... // build args tuple, hashable frozenset, unhashable tuple
unionobject *result = PyObject_GC_New(unionobject, &_PyUnion_Type);
...
_PyObject_GC_TRACK(result);
return (PyObject*)result;
}So int | int evaluates to int, and int | with no arguments is an error.
The single-member collapse is what makes int | int is int true.
Entry points
X | Y (binary |)
type and GenericAlias both install _Py_union_type_or as their nb_or:
// Objects/typeobject.c
static PyNumberMethods type_as_number = {
.nb_or = _Py_union_type_or,
};
// Objects/genericaliasobject.c
... .nb_or = _Py_union_type_or ..._Py_union_type_or first checks is_unionable:
// Objects/unionobject.c
static int
is_unionable(PyObject *obj)
{
if (obj == Py_None ||
PyType_Check(obj) ||
PySentinel_Check(obj) ||
_PyGenericAlias_Check(obj) ||
_PyUnion_Check(obj) ||
Py_IS_TYPE(obj, &_PyTypeAlias_Type)) {
return 1;
}
return 0;
}
PyObject *
_Py_union_type_or(PyObject* self, PyObject* other)
{
if (!is_unionable(self) || !is_unionable(other)) {
Py_RETURN_NOTIMPLEMENTED; // lets normal __or__ / TypeError handling proceed
}
unionbuilder ub;
// unchecked because we already checked is_unionable()
if (!unionbuilder_init(&ub, false)) return NULL;
if (!unionbuilder_add_single(&ub, self) ||
!unionbuilder_add_single(&ub, other)) {
unionbuilder_finalize(&ub);
return NULL;
}
PyObject *new_union = make_union(&ub);
return new_union;
}Because it returns NotImplemented for non-unionable operands, int | 5
falls through to the default int.__or__, which raises TypeError. The
builder is unchecked here (is_checked = false), so members are stored as-is
rather than being run through type_check.
typing.Union[X, Y] and UnionType[X, Y]
// Objects/unionobject.c
static PyObject *
union_class_getitem(PyObject *cls, PyObject *args)
{
return _Py_union_from_tuple(args);
}
PyObject *
_Py_union_from_tuple(PyObject *args)
{
unionbuilder ub;
if (!unionbuilder_init(&ub, true)) return NULL; // checked
if (PyTuple_CheckExact(args)) {
if (!unionbuilder_add_tuple(&ub, args)) { ... }
}
else {
if (!unionbuilder_add_single(&ub, args)) { ... }
}
return make_union(&ub);
}__class_getitem__ is registered as a classmethod, so types.UnionType[int, str] works. The builder is checked (is_checked = true), meaning each
member passes through type_check:
// Objects/unionobject.c
static PyObject *
type_check(PyObject *arg, const char *msg)
{
if (Py_IsNone(arg)) {
return (PyObject *)Py_TYPE(arg); // None -> NoneType
}
if (is_unionable(arg)) {
return Py_NewRef(arg);
}
PyObject *message_str = PyUnicode_FromString(msg);
PyObject *args[2] = {arg, message_str};
PyObject *result = call_typing_func_object("_type_check", args, 2);
Py_DECREF(message_str);
return result;
}Fast path for already-unionable objects; otherwise it calls
typing._type_check (which may raise TypeError for invalid members). This is
why Union[int, "str"] (string forward reference) can resolve, while
Union[int, 42] raises.
Substitution: union.__getitem__
Unions support U[int] to substitute type variables:
// Objects/unionobject.c
static PyObject *
union_getitem(PyObject *self, PyObject *item)
{
unionobject *alias = (unionobject *)self;
if (union_init_parameters(alias) < 0) return NULL;
PyObject *newargs = _Py_subs_parameters(self, alias->args,
alias->parameters, item);
if (newargs == NULL) return NULL;
PyObject *res = _Py_union_from_tuple(newargs);
Py_DECREF(newargs);
return res;
}Parameters are populated lazily and cached under a critical section:
// Objects/unionobject.c
static int
union_init_parameters(unionobject *alias)
{
int result = 0;
Py_BEGIN_CRITICAL_SECTION(alias);
if (alias->parameters == NULL) {
alias->parameters = _Py_make_parameters(alias->args);
if (alias->parameters == NULL) result = -1;
}
Py_END_CRITICAL_SECTION();
return result;
}So U[T][int] rebuilds the union from substituted args via _Py_union_from_tuple.
Repr
// Objects/unionobject.c
static PyObject *
union_repr(PyObject *self)
{
unionobject *alias = (unionobject *)self;
Py_ssize_t len = PyTuple_GET_SIZE(alias->args);
Py_ssize_t estimate = (len <= PY_SSIZE_T_MAX / 6) ? len * 6 : len;
PyUnicodeWriter *writer = PyUnicodeWriter_Create(estimate);
...
for (Py_ssize_t i = 0; i < len; i++) {
if (i > 0 && PyUnicodeWriter_WriteASCII(writer, " | ", 3) < 0) goto error;
PyObject *p = PyTuple_GET_ITEM(alias->args, i);
if (_Py_typing_type_repr(writer, p) < 0) goto error;
}
return PyUnicodeWriter_Finish(writer);
...
}Members are rendered with _Py_typing_type_repr, joined by " | ". That helper
is what turns _PyNone_Type back into NoneType and handles generics.
Attributes and properties
// Objects/unionobject.c
static PyMemberDef union_members[] = {
{"__args__", _Py_T_OBJECT, offsetof(unionobject, args), Py_READONLY},
{0}
};
static PyGetSetDef union_properties[] = {
{"__name__", union_name, ...}, // "Union"
{"__qualname__", union_name, ...}, // "Union"
{"__origin__", union_origin, ...}, // always _PyUnion_Type
{"__parameters__", union_parameters, ...},
{0}
};__args__ is a read-only member pointing directly at args. __parameters__
is lazily initialized (see above). __mro_entries__ raises TypeError, so
unions cannot be subclassed.
union_getattro special-cases __module__, forwarding it to the type object,
for compatibility with the typing module.
Comparison with typing.Union
types.UnionType is the C implementation; typing.Union is the legacy
pure-Python form. Modern code paths (int | str, Union[int, str],
types.UnionType[...]) all funnel through _PyUnion_Type. The typing
module still provides _type_check and _type_repr-adjacent helpers, but the
object itself, its hashing, equality, substitution, and GC are all in C.
Memory layout
unionobject (GC object)
+----------------------------+
| ob_refcnt / ob_type ... | PyObject_HEAD
+----------------------------+
| args -> tuple |
| hashable_args -> frozenset (or NULL) |
| unhashable_args -> tuple (or NULL) |
| parameters -> tuple (lazy, or NULL) |
| weakreflist -> weakref list (or NULL) |
+----------------------------+Allocated with PyObject_GC_New and freed with PyObject_GC_Del; tracked by
the GC from creation (_PyObject_GC_TRACK).
Key takeaways
types.UnionTypeis_PyUnion_Type, defined inObjects/unionobject.cand exported byModules/_typesmodule.c.- A union is a GC-tracked struct holding an ordered
argstuple plus a hashable frozenset and an optional unhashable tuple, plus lazyparametersand a weakref list. unionbuilderflattens nested unions, normalizesNonetoNoneType, deduplicates members (set for hashable, linear scan for unhashable), and collapses single-member results.X | Yroutes throughtype.__or__/GenericAlias.__or__=_Py_union_type_or(unchecked builder);Union[X, Y]routes through__class_getitem__=_Py_union_from_tuple(checked builder, callstyping._type_check).U[item]substitutes parameters and rebuilds the union.- Equality compares frozensets plus mutual containment of unhashable members; hashing fails if any member is unhashable.
- Unions cannot be subclassed (
__mro_entries__raises).
Further reading
Objects/unionobject.c–unionobject,_PyUnion_Type,unionbuilder,_Py_union_type_or,_Py_union_from_tuple,make_union,union_hash,unions_equal,union_repr,union_getitem.Include/internal/pycore_unionobject.h–_PyUnion_Type,_PyUnion_Check,_Py_union_type_or,_Py_union_from_tuple,_Py_union_args.Objects/typeobject.c,Objects/genericaliasobject.c–nb_orwiring.Modules/_typesmodule.c– export ofUnionType.Lib/types.py–UnionType = type(int | str).Lib/test/test_types.py–UnionTypecoverage.