You can raise, detect, translate, propagate, suppress and chain Python exceptions from C; you know the sentinel convention for every return type; and you can write a cleanup path that does not silently destroy the exception it is unwinding from.
Two halves of one signal
When a Python-level exception is "in flight", two things are true simultaneously:
- A thread-local error indicator holds the exception object. It is per-thread state, hanging off the current
PyThreadState(Lesson 10), not a global. - The C function that failed returned a sentinel value telling its caller to stop.
Neither half alone is enough. Setting the indicator and returning a valid-looking value means the exception surfaces later, attributed to innocent code. Returning the sentinel without setting the indicator produces the interpreter's least helpful message: SystemError: <built-in function foo> returned NULL without setting an exception.
Returning the error sentinel ⇔ the error indicator is set. A debug build of CPython asserts this at C-function boundaries. Build one and run your test suite against it; this check alone justifies the effort.
Which sentinel
| Return type | Error value | Caution |
|---|---|---|
PyObject * | NULL | Unambiguous. |
int (status) | -1, success is 0 | Unambiguous. |
int (predicate, e.g. PyObject_IsTrue) | -1; results are 0/1 | Never write if (PyObject_IsTrue(x)) — -1 is truthy. Compare explicitly. |
Py_ssize_t | -1 | Unambiguous for lengths. |
long / double conversions | -1 / -1.0 | Ambiguous: the real value may legitimately be −1. Must be disambiguated with PyErr_Occurred(). |
The last row is the trap:
long v = PyLong_AsLong(obj);
if (v == -1 && PyErr_Occurred()) {
return NULL; /* genuine failure: not an int, or overflow */
}
/* otherwise v is valid, and may legitimately be -1 */
Checking PyErr_Occurred() unconditionally instead of only on the sentinel is wasteful and, worse, will report an unrelated pre-existing exception. Test the sentinel first.
Raising
PyErr_SetString(PyExc_ValueError, "n must be positive") | The workhorse. |
PyErr_Format(PyExc_TypeError, "expected str, got %s", Py_TYPE(o)->tp_name) | printf-style plus Python-specific units: %S (str of an object), %R (repr), %U (a str object), %T (an object's type name, 3.13+). |
PyErr_SetObject(PyExc_KeyError, key) | When the exception argument is an object, not a message. |
PyErr_NoMemory() | Sets MemoryError and returns NULL, so return PyErr_NoMemory(); is idiomatic. |
PyErr_SetFromErrno(PyExc_OSError) | Reads errno and builds the right OSError subclass. Variants take a filename. |
PyErr_SetFromWindowsErr(0) | Same for GetLastError(). Windows only. |
PyErr_BadArgument(), PyErr_BadInternalCall() | Shorthands for programming errors. |
Every one of these returns nothing useful except PyErr_NoMemory; the convention is to set and then return your own sentinel:
if (n <= 0) {
PyErr_Format(PyExc_ValueError, "n must be positive, got %zd", n);
return NULL;
}
Propagating
Propagation requires no work. If a call fails it has already set the indicator; you return your own sentinel and the exception keeps travelling up until it reaches the interpreter's eval loop, which converts it back into a Python exception at the call site.
PyObject *item = PyObject_GetItem(container, key);
if (item == NULL) {
return NULL; /* the KeyError, or whatever it was, is already set */
}
Resist the urge to "improve" the error by clearing it and raising your own. You destroy the traceback and usually the useful part of the message. If you must add context, chain instead (below).
Catching and inspecting
PyObject *v = PyObject_GetItem(d, key);
if (v == NULL) {
if (!PyErr_ExceptionMatches(PyExc_KeyError)) {
return NULL; /* not our problem: propagate */
}
PyErr_Clear(); /* swallow the KeyError */
v = Py_NewRef(default_value); /* and substitute a default */
}
PyObject *PyErr_Occurred(void)- Returns a borrowed reference to the exception type, or NULL. It is a test, not a retrieval. It requires an attached thread state (Lesson 10).
int PyErr_ExceptionMatches(PyObject *exc)- Like an
exceptclause: honours subclassing and tuples of types. This is what you want, not a pointer comparison againstPyErr_Occurred(). void PyErr_Clear(void)- Discards the indicator. Only call this when you have genuinely handled the condition.
Taking the exception out and putting it back
Since Python 3.12 the modern pair is:
PyObject *exc = PyErr_GetRaisedException(); /* strong ref, clears the indicator */
... do work that must not see an exception in flight ...
PyErr_SetRaisedException(exc); /* steals the reference, restores it */
This replaces the older three-value PyErr_Fetch / PyErr_Restore / PyErr_NormalizeException trio, which is deprecated and much easier to get wrong. If you need to support Python 3.11 and earlier, either use the old API under a version check or pull in the pythoncapi-compat header, which backports the modern spelling.
Your cleanup path may call things that can fail or that clear the indicator — tp_dealloc of some object, a logging callback, a close routine. If an exception is already in flight, that destroys it, and the caller sees success or, worse, a SystemError about a missing exception.
/* Correct: protect the in-flight exception across risky cleanup. */
PyObject *exc = PyErr_GetRaisedException();
close_the_thing(self); /* may run Python code */
PyErr_SetRaisedException(exc);
The same pattern is mandatory inside tp_finalize and inside any callback invoked from a destructor.
Chaining
Python's implicit chaining (__context__, the "During handling of the above exception…" text) happens automatically: raising a new exception while one is set links them. Explicit chaining — the equivalent of raise X from Y — is done with PyException_SetCause, or more conveniently:
/* Prepends context to the current exception, Python 3.12+ */
_PyErr_FormatFromCause(PyExc_RuntimeError, "while loading %s", path);
/* Public equivalent: capture, raise the new one, then set __cause__. */
For the common case of wrapping a third-party C library, the useful pattern is to raise a module-specific exception type carrying the library's own error code, and let implicit chaining handle the rest.
Defining your own exception types
/* In module exec (Lesson 08). "mymod.Error" -- the dotted name matters. */
PyObject *ErrorType = PyErr_NewExceptionWithDoc(
"mymod.Error", /* name */
"Base error for mymod.", /* docstring */
NULL, /* base: NULL means Exception */
NULL); /* dict of extra attributes */
if (ErrorType == NULL) return -1;
if (PyModule_AddObjectRef(module, "Error", ErrorType) < 0) {
Py_DECREF(ErrorType); return -1;
}
/* Keep a strong reference in module state so C code can raise it later. */
state->Error = ErrorType; /* module state: Lesson 08 */
Do not stash the exception type in a C static variable. That is the isolation bug Lesson 08 is about, and it is also a lifetime bug: the type belongs to a module instance.
Three things that are not exceptions
Warnings
if (PyErr_WarnEx(PyExc_DeprecationWarning, "use spam() instead", 1) < 0) {
return NULL; /* warnings can be turned into errors -- always check */
}
The final argument is a stack level. PyErr_WarnFormat takes printf arguments. Note the return check: under -W error a warning becomes an exception, so ignoring the result is a bug.
Unraisable exceptions
Sometimes an exception occurs where there is no caller to propagate to — inside a deallocator, inside a callback invoked from a C library that has no error channel. The correct move is not to swallow it silently:
PyErr_FormatUnraisable("Exception ignored in callback for %R", self);
/* older spelling: PyErr_WriteUnraisable(self); */
This routes the exception to sys.unraisablehook, so the user sees it, and clears the indicator.
Signals and Ctrl-C
A C loop that never returns to the interpreter never processes signals, so Ctrl-C does nothing until it finishes. In a long-running loop that holds the interpreter, poll periodically:
for (Py_ssize_t i = 0; i < n; i++) {
if ((i & 0xFFFF) == 0 && PyErr_CheckSignals() < 0) {
return NULL; /* KeyboardInterrupt is now set */
}
...
}
(If instead you have released the interpreter for the duration of the loop — Lesson 10 — you must not call this, and you need a different interruption strategy.)
Translating a C library's errors
Wrapping a library means converting its convention into Python's. A reusable shape:
/* Returns 0 on success, -1 with a Python exception set on failure. */
static int
check_lib(module_state *state, int rc)
{
if (rc == LIB_OK) return 0;
switch (rc) {
case LIB_ENOMEM:
PyErr_NoMemory();
break;
case LIB_EINVAL:
PyErr_SetString(PyExc_ValueError, lib_strerror(rc));
break;
case LIB_EIO:
errno = lib_errno();
PyErr_SetFromErrno(PyExc_OSError);
break;
default:
PyErr_Format(state->Error, "library error %d: %s", rc, lib_strerror(rc));
}
return -1;
}
/* Call sites read cleanly: */
if (check_lib(state, lib_open(&h, path)) < 0) goto error;
One translation point, so the mapping is consistent and testable.
Reproduce the two failure modes from Python, using ctypes is not needed — the standard library already contains a demonstration:
import sys
# 1. See the interpreter's complaint about a missing exception. This is what
# your users see when C code returns NULL without setting the indicator.
# (Simulated here; in C it comes from the _PyErr_ChainStackItem check.)
print("SystemError: ... returned NULL without setting an exception")
# 2. Unraisable exceptions: the hook that PyErr_FormatUnraisable feeds.
def hook(unraisable):
print("unraisable:", unraisable.exc_type.__name__, "in", unraisable.object)
sys.unraisablehook = hook
class Bad:
def __del__(self): raise RuntimeError("boom")
Bad() # the exception cannot propagate; it goes to the hook
# 3. Signal polling: why a long C loop ignores Ctrl-C.
# Compare interrupting sum(range(10**9)) with interrupting a C extension
# loop that never calls PyErr_CheckSignals.
Then write, in C pseudocode, a function that calls three fallible APIs, must close a FILE * on every path, and must not lose the original exception if fclose itself fails.
Further reading
- C API — Exception Handling. Every function named above, with exact semantics.
- C API Introduction — Exceptions. The convention, stated authoritatively, with the worked
goto errorexample. - Python Extension Patterns — Exceptions. Practical patterns for custom exception types and for translating library errors.
- PEP 678 — Exception notes and PEP 654 — Exception Groups, if your extension needs to aggregate failures.