CPython's Exception Implementation in C

BaseException as a GC object, the PyException_HEAD layout, args, __traceback__, __context__, __cause__, and exception groups traced through CPython 3.15's Objects/exceptions.c.

Python exceptions are ordinary objects. In CPython every exception instance is a PyBaseExceptionObject (or a subclass struct that starts with the same PyException_HEAD), allocated through the normal type machinery, tracked by the garbage collector, and carrying args, __traceback__, __context__, __cause__, and __notes__. The whole hierarchy lives in Objects/exceptions.c, with the shared layout declared in Include/cpython/pyerrors.h.

The shared layout: PyException_HEAD

Every exception class uses the same prefix, defined as a macro so that PySyntaxErrorObject, PyOSErrorObject, PyStopIterationObject, etc. can append their own fields:

c
// Include/cpython/pyerrors.h
#define PyException_HEAD PyObject_HEAD PyObject *dict;\
             PyObject *args; PyObject *notes; PyObject *traceback;\
             PyObject *context; PyObject *cause;\
             char suppress_context;

typedef struct {
    PyException_HEAD
} PyBaseExceptionObject;

So PyBaseExceptionObject is just the prefix: dict, args, notes, traceback, context, cause, and suppress_context. The dict field is the instance __dict__; traceback is the head of the traceback chain; context is the implicitly chained exception (PEP 3134); cause is the explicitly chained one; suppress_context records whether raise ... from was used.

BaseException: the root type

BaseException is a real GC type. BaseException_new allocates with tp_alloc, zeroes the pointers, and either takes ownership of the passed args tuple or creates an empty one:

c
// Objects/exceptions.c
static PyObject *
BaseException_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
    PyBaseExceptionObject *self;
    self = (PyBaseExceptionObject *)type->tp_alloc(type, 0);
    if (!self)
        return NULL;
    self->dict = NULL;
    self->notes = NULL;
    self->traceback = self->cause = self->context = NULL;
    self->suppress_context = 0;

    if (args) {
        self->args = Py_NewRef(args);
        return (PyObject *)self;
    }
    self->args = PyTuple_New(0);
    ...
}

BaseException_init then replaces args from the call arguments, so BaseException.__init__ is what actually stores them. There is also a BaseException_vectorcall fast path that allocates and builds args in one step via PyTuple_FromArray.

Because exceptions can be chained through __context__ and __traceback__, deallocating a long chain can overflow the C stack. BaseException_dealloc runs through the trashcan and calls BaseException_clear, which Py_CLEARs every owned reference:

c
// Objects/exceptions.c
static int
BaseException_clear(PyObject *op)
{
    PyBaseExceptionObject *self = PyBaseExceptionObject_CAST(op);
    Py_CLEAR(self->dict);
    Py_CLEAR(self->args);
    Py_CLEAR(self->notes);
    Py_CLEAR(self->traceback);
    Py_CLEAR(self->cause);
    Py_CLEAR(self->context);
    return 0;
}

BaseException_traverse visits the same fields so the cyclic GC can find reference cycles. The type is registered with Py_TPFLAGS_HAVE_GC and Py_TPFLAGS_BASE_EXC_SUBCLASS (the latter lets the interpreter treat any instance of a BaseException subclass specially, e.g. for sys.excepthook).

Exception and the hierarchy

Exception itself adds nothing: it is produced by the SimpleExtendsException macro, which declares a PyTypeObject whose tp_basicsize is sizeof(PyBaseExceptionObject) and whose base is _PyExc_BaseException:

c
// Objects/exceptions.c
#define SimpleExtendsException(EXCBASE, EXCNAME, EXCDOC) \
static PyTypeObject _PyExc_ ## EXCNAME = { \
    ...
    sizeof(PyBaseExceptionObject), \
    ...
    Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, \
    ... \
    0, 0, 0, 0, 0, 0, 0, &_ ## EXCBASE, \
    ...
    BaseException_init, 0, BaseException_new, \
};

SimpleExtendsException(PyExc_BaseException, Exception,
                       "Common base class for all non-exit exceptions.");

Most builtin exceptions (TypeError, ValueError, …) are created the same way. Exceptions that need extra state (StopIteration.value, SystemExit.code, OSError.errno/filename, SyntaxError location fields, …) use ComplexExtendsException with a larger struct such as PyStopIterationObject or PyOSErrorObject, and their own _init, _clear, _traverse, and _dealloc that call down to BaseException_clear/BaseException_traverse.

args

args is a tuple (or None if deleted is attempted, though deletion is rejected). The getter returns a new reference; the setter runs the value through PySequence_Tuple, so assigning a list converts it:

c
// Objects/exceptions.c
static PyObject *
BaseException_args_get_impl(PyBaseExceptionObject *self)
{
    if (self->args == NULL) {
        Py_RETURN_NONE;
    }
    return Py_NewRef(self->args);
}

static int
BaseException_args_set_impl(PyBaseExceptionObject *self, PyObject *value)
{
    PyObject *seq;
    if (value == NULL) {
        PyErr_SetString(PyExc_TypeError, "args may not be deleted");
        return -1;
    }
    seq = PySequence_Tuple(value);
    if (!seq)
        return -1;
    Py_XSETREF(self->args, seq);
    return 0;
}

BaseException_str uses this: with zero args it returns "", with one arg it returns str(args[0]), otherwise str(args). BaseException.__reduce__ packages type(self), self.args, and (if present) self.__dict__ so exceptions pickle and unpickle faithfully.

__notes__ and add_note

notes is a list (or NULL). add_note lazily creates __notes__ as an empty list and appends to it, so exceptions created without notes carry no list at all:

c
// Objects/exceptions.c
static PyObject *
BaseException_add_note_impl(PyBaseExceptionObject *self, PyObject *note)
{
    PyObject *notes;
    if (PyObject_GetOptionalAttr((PyObject *)self, &_Py_ID(__notes__), &notes) < 0)
        return NULL;
    if (notes == NULL) {
        notes = PyList_New(0);
        ...
        if (PyObject_SetAttr((PyObject *)self, &_Py_ID(__notes__), notes) < 0) { ... }
    }
    else if (!PyList_Check(notes)) { ... }
    if (PyList_Append(notes, note) < 0) { ... }
    ...
}

__traceback__

The internal traceback pointer is exposed as __traceback__. The getter returns None when the pointer is NULL; the setter accepts only a traceback object or None:

c
// Objects/exceptions.c
static int
BaseException___traceback___set_impl(PyBaseExceptionObject *self,
                                     PyObject *value)
{
    if (value == NULL) {
        PyErr_SetString(PyExc_TypeError, "__traceback__ may not be deleted");
        return -1;
    }
    if (PyTraceBack_Check(value)) {
        Py_XSETREF(self->traceback, Py_NewRef(value));
    }
    else if (value == Py_None) {
        Py_CLEAR(self->traceback);
    }
    else {
        PyErr_SetString(PyExc_TypeError,
                        "__traceback__ must be a traceback or None");
        return -1;
    }
    return 0;
}

with_traceback(tb) is a thin wrapper that calls this setter and returns self. The C API mirrors it: PyException_GetTraceback/PyException_SetTraceback.

Traceback objects

A traceback is a linked list of frames, not stored inside the exception’s struct beyond the head pointer. The struct is in Include/cpython/traceback.h:

c
// Include/cpython/traceback.h
struct _traceback {
    PyObject_HEAD
    PyTracebackObject *tb_next;
    PyFrameObject *tb_frame;
    int tb_lasti;
    int tb_lineno;
};

tb_next chains to the caller’s traceback, tb_frame is the frame, and tb_lasti/tb_lineno record where execution was. When an exception is raised, the interpreter attaches a fresh PyTracebackObject to exc->traceback; as the exception unwinds, frames are prepended by setting tb_next. Because __traceback__ is a normal attribute, user code can replace or truncate the chain (e.g. exc.__traceback__ = exc.__traceback__.tb_next).

__context__ and __cause__

__context__ is the exception that was being handled when this one was raised (implicit chaining). Its setter validates that the value is None or an exception instance, and stores NULL for None:

c
// Objects/exceptions.c
static int
BaseException___context___set_impl(PyBaseExceptionObject *self,
                                   PyObject *value)
{
    if (value == NULL) { ... }
    else if (value == Py_None) {
        value = NULL;
    } else if (!PyExceptionInstance_Check(value)) { ... }
    else {
        Py_INCREF(value);
    }
    Py_XSETREF(self->context, value);
    return 0;
}

__cause__ is set explicitly by raise X from Y. Its setter delegates to PyException_SetCause, which sets suppress_context = 1 (so __context__ is not printed) and steals the reference:

c
// Objects/exceptions.c
void
PyException_SetCause(PyObject *self, PyObject *cause)
{
    Py_BEGIN_CRITICAL_SECTION(self);
    PyBaseExceptionObject *base_self = PyBaseExceptionObject_CAST(self);
    base_self->suppress_context = 1;
    Py_XSETREF(base_self->cause, cause);
    Py_END_CRITICAL_SECTION();
}

The suppress_context flag is also exposed as the read/write member __suppress_context__.

Exception groups

PEP 654 added BaseExceptionGroup and ExceptionGroup. The group struct extends PyException_HEAD with msg, excs, and excs_str:

c
// Include/cpython/pyerrors.h
typedef struct {
    PyException_HEAD
    PyObject *msg;
    PyObject *excs;
    PyObject *excs_str;
} PyBaseExceptionGroupObject;

BaseExceptionGroup_new does the real work: it parses message, exceptions, converts exceptions to a tuple, rejects empty sequences and non-exception items, and then chooses the concrete class. If a BaseExceptionGroup is constructed with only Exception subclasses, it is upgraded to ExceptionGroup; conversely ExceptionGroup cannot wrap bare BaseExceptions:

c
// Objects/exceptions.c (excerpt)
PyTypeObject *cls = type;
if (cls == PyExc_ExceptionGroup) {
    if (nested_base_exceptions) { ... reject ... }
}
else if (cls == (PyTypeObject*)PyExc_BaseExceptionGroup) {
    if (!nested_base_exceptions) {
        cls = PyExc_ExceptionGroup;
    }
}

excs_str caches the original repr of the input sequence so that repr stays stable even if the caller mutates a list later. The group stores msg/excs and reuses BaseException for args/traceback/context/cause.

Group operations are implemented in C: split(matcher) recursively walks the nested tree, subgroup returns the matching part, and derive(excs) builds a new group copying msg. exceptiongroup_subset propagates traceback, context, cause, and a copy of __notes__ from the original group to each derived part. The interpreter uses _PyExc_PrepReraiseStar (exposed as PyUnstable_Exc_PrepReraiseStar) to reconstruct reraised groups in except* blocks, preserving metadata so reraised subgroups compare equal to the original.

Key takeaways

  • Every exception is a GC object whose layout starts with PyException_HEAD (dict, args, notes, traceback, context, cause, suppress_context); subclasses append their own fields.
  • BaseException lives in Objects/exceptions.c with BaseException_new, BaseException_clear/_traverse, and tp_flags including Py_TPFLAGS_BASE_EXC_SUBCLASS.
  • Exception and most builtins are generated by SimpleExtendsException, sharing PyBaseExceptionObject’s size and BaseException_* callbacks.
  • args is always (after init) a tuple; __traceback__ holds the head of a PyTracebackObject linked list; __context__/__cause__ store chained exceptions, with PyException_SetCause setting suppress_context.
  • Exception groups add msg/excs/excs_str and implement split/subgroup/ derive plus interpreter-side reraise reconstruction in C.

Further reading

  • Objects/exceptions.c – BaseException_*, macros, group implementation.
  • Include/cpython/pyerrors.h – PyException_HEAD and all exception structs.
  • Python/traceback.c and Include/cpython/traceback.h – traceback objects.
  • Lib/test/exception_hierarchy.txt – the documented class hierarchy.

PythonCpythonInternalsExceptions