17 — The C API and extensions: ownership, ABI tiers, and the boundary where Python stops¶
Tier 2, doc 17. Prerequisites:
14-pyobject-and-types.md(PyObject,PyTypeObject, thetp_*slots),15-refcounting-and-ownership.md(borrowed vs new vs stolen at the concept level — this doc makes it a compiler problem),16-object-memory-layout.md§2 (the free-threading header) and §4 (the three allocation domains),24-the-gil.md§3 (whatPy_BEGIN_ALLOW_THREADSis for). Feeds into:26-free-threading.md,34-going-native.md,44-packaging-and-environments.md.THESIS: the C API is not "Python with more typing" — it is a contract in which the compiler enforces nothing that matters. Ownership, error state, thread state, and allocation domain are all invariants held together by convention and by the reader's attention. Get one wrong and the failure surfaces somewhere else entirely: a segfault three frames back, a heap corruption 40 ms later, a class that leaks forever, a free-threaded build that silently loses 11% of its data. Everything else in this document — the ABI tiers, multi-phase init, the buffer protocol, critical sections, the binding generators — exists to move one of those invariants from "you remember it" to "the machine checks it".
Build provenance. Everything below was built and run on the machine this repo lives on: Apple M3 Pro, macOS 15 (Darwin 25.5.0), arm64, Apple clang 21.0.0, against CPython 3.14.6 (
~/.local/bin/python3.14) and CPython 3.14.6 free-threading (~/.local/bin/python3.14t), both fromuv's python-build-standalone distributions. I actually built and imported five extension modules for this document:pmx17(the centerpiece, §6, built for both interpreters),pmx17_legacy(the anti-pattern, §8),badmem(deliberate allocator bugs, §14),abi3demo(a real Limited-API/abi3module, §2), andcalls(calling-convention microbenchmark, §12). Numbers marked (measured) came out of those runs.lldboutput in §14 is a real backtrace from a real segfault. Where I read something in the 3.15 docs but could not compile it (I have no 3.15 here), it is flagged (3.15, not compiled here). Every C API name in this document was checked against the headers in this build'sinclude/python3.14/or against docs.python.org. §17 lists what I could not verify.
Contents¶
- Why there is a C API, and what it costs
- The four API tiers — with the compiler as witness
- PEP 803 (
abi3t) and PEP 793 (PyModExport) - Reference semantics: new, borrowed, stolen
- Error handling: the thread-state exception indicator
- The complete extension module
- Defining types in C: static vs heap
- Multi-phase init, per-module state, and subinterpreters
- The buffer protocol (PEP 3118)
- Releasing the GIL — the exact contract, measured
- Free-threading rules for extensions
- Calling protocols:
tp_call, vectorcall,METH_FASTCALL - The binding-generator landscape, compared honestly
- Debugging native extensions
- Lab exercises
- Question bank
- Sources
1. Why there is a C API, and what it costs¶
CPython is a C program. Its "public API" is the set of C declarations in Include/ that
an external .so/.dylib/.pyd may call. Historically that meant: almost all of it.
PyObject was a struct you could dereference. PyTypeObject was a struct you could
statically allocate and fill in. ob_refcnt was a field you incremented with a macro that
expanded to ++.
That decision bought Python the scientific stack. NumPy, SciPy, lxml, Pillow, psycopg, cryptography, PyTorch — every one of them exists because the C API was permissive enough to be fast at the boundary. It is the single largest reason Python won.
It also priced every subsequent runtime change:
| Runtime change | What the C API cost it |
|---|---|
| Removing the GIL | The whole Gilectomy (see 24-the-gil.md §7). Refcounting is in the ABI. |
| Moving objects (compacting GC) | Impossible. Extensions hold raw PyObject* across arbitrary code. |
Changing PyObject layout |
Breaks every statically-allocated type and every PyObject_HEAD struct. |
| Subinterpreters | Blocked for 20 years by module-level C globals (§8). |
| A JIT with unboxed values | Every PyObject* that crosses the boundary must be materialized. |
The last decade of C-API work — PEP 384, 489, 573, 590, 630, 687, 689, 697, 793, 803 — is one long, coordinated effort to narrow the contract without breaking the ecosystem. You cannot understand why the modern API looks the way it does without holding that frame. Almost every "why is this so verbose now?" has the same answer: because the old, terse version exposed a memory layout, and CPython needs to change that layout.
2. The four API tiers — with the compiler as witness¶
There are four tiers, and mixing them up is the most common source of packaging pain.
┌───────────────────────────────────────────────────────────────────────────────┐
│ INTERNAL API Include/internal/pycore_*.h │
│ _PyInterpreterState_GET, _PyDict_GetItem_KnownHash, pycore_critical_section.h │
│ Requires Py_BUILD_CORE. No stability at all — changes in point releases. │
│ If you `#define Py_BUILD_CORE` in a third-party extension, you are on your own│
└───────────────────────────────────────────────────────────────────────────────┘
▲ not for you
┌───────────────────────────────────────────────────────────────────────────────┐
│ UNSTABLE API PyUnstable_* (PEP 689, 3.12) │
│ PyUnstable_Module_SetGIL, PyUnstable_Object_ClearWeakRefsNoCallbacks, │
│ PyUnstable_Eval_RequestCodeExtraIndex ... │
│ Public. Documented. MAY CHANGE IN MINOR RELEASES (3.14 → 3.15) WITHOUT │
│ DEPRECATION. The `PyUnstable_` prefix is the whole point: it is a name you │
│ can grep the ecosystem for. │
└───────────────────────────────────────────────────────────────────────────────┘
▲ use, but pin your CPython
┌───────────────────────────────────────────────────────────────────────────────┐
│ FULL / "CPython" API Include/*.h + Include/cpython/*.h │
│ Everything else. Source-compatible across minor releases (with deprecation), │
│ but NOT binary-compatible: you rebuild for every 3.x. │
│ Wheel tag: cp314-cp314-macosx_11_0_arm64 │
└───────────────────────────────────────────────────────────────────────────────┘
▲ where 95% of extensions live
┌───────────────────────────────────────────────────────────────────────────────┐
│ LIMITED API #define Py_LIMITED_API 0x030B0000 │
│ ↓ compiles to ↓ │
│ STABLE ABI (abi3) PEP 384, 3.2+ │
│ One binary loads on 3.11, 3.12, 3.13, 3.14, ... GIL builds. │
│ Wheel tag: cp311-abi3-macosx_11_0_arm64 File: mymod.abi3.so │
│ Cost: no struct access, no static types, no macros that dereference. │
└───────────────────────────────────────────────────────────────────────────────┘
▲ ~1% of extensions, and they know why
┌───────────────────────────────────────────────────────────────────────────────┐
│ STABLE ABI FOR FREE-THREADING (abi3t) PEP 803, 3.15 ← NEW, see §3 │
│ #define Py_TARGET_ABI3T ; PyObject becomes fully opaque. │
│ Wheel tag: abi3t (or abi3.abi3t for both) File: mymod.abi3t.so │
└───────────────────────────────────────────────────────────────────────────────┘
Two vocabulary points people get wrong constantly:
- Limited API is a compile-time thing. Stable ABI is a link/load-time thing. You opt
into the Limited API (a subset of declarations) in order to produce a binary that
conforms to the Stable ABI. PEP 803 explicitly calls the
Py_LIMITED_APIname "increasingly a misnomer", since for things likePy_TYPEthe macro doesn't remove the API, it selects a forward-compatible implementation (a real DLL function call rather than an inline pointer dereference). That is why the new knob is namedPy_TARGET_ABI3T— a compilation target, not a limitation. abi3is a floor, not a version.Py_LIMITED_API 0x030B0000means "3.11 and later". You get 3.11's subset, and your one wheel loads on everything from 3.11 up.
The compiler as witness — measured¶
Talk is cheap. I took the module from §6 — ordinary, modern, full-API C — and compiled it against two Limited API floors. (measured)
$ clang -DPy_LIMITED_API=0x030A0000 ... -c pmx17.c
pmx17.c:64:5: error: use of undeclared identifier 'Py_buffer'
pmx17.c:69:40: error: use of undeclared identifier 'PyBUF_SIMPLE'
pmx17.c:69:9: error: call to undeclared function 'PyObject_GetBuffer'
... (20 errors)
$ clang -DPy_LIMITED_API=0x030D0000 ... -c pmx17.c
pmx17.c:116:9: error: call to undeclared function 'PyTuple_SET_ITEM'
pmx17.c:206:56: error: incomplete definition of type 'PyTypeObject'
pmx17.c:223:7: error: incomplete definition of type 'PyTypeObject'
pmx17.c:248:5: error: call to undeclared function 'Py_BEGIN_CRITICAL_SECTION'
... (7 errors)
Read the delta. Between a 3.10 floor and a 3.13 floor, the entire buffer protocol became available (it entered the Limited API in 3.11) — 20 errors down to 7. Every remaining error names exactly one of the Limited API's three real constraints:
PyTuple_SET_ITEM— an unchecked macro that dereferences the struct. Limited API gives youPyTuple_SetItem(a function call, with bounds checking) instead.incomplete definition of type 'PyTypeObject'— the struct is opaque. You cannot writetype->tp_alloc(...); you must callPyType_GetSlot(type, Py_tp_alloc). This is why static types are impossible under the Limited API.Py_BEGIN_CRITICAL_SECTION— free-threading primitives are not in the 3.14 Limited API. Which is a nice segue, because as of 3.14 the free-threaded build does not support the Limited API at all.
Two builds, one binary — and where it breaks¶
I built a small module (abi3demo.c) with #define Py_LIMITED_API 0x030B0000, produced
abi3demo.abi3.so, and pointed both interpreters at it. (measured)
$ python3.14 -c "import abi3demo; print(abi3demo.__file__, abi3demo.whoami())"
3.14 -> abi3demo.abi3.so {'compiled_against': '0x30e06f0', 'running_on': '0x30e06f0'}
$ python3.14t -c "import abi3demo; print(abi3demo.whoami())"
SystemError: init function of abi3demo returned uninitialized object
That is not a bug in my module. Look at what the free-threaded interpreter advertises:
$ python3.14t -c "import importlib.machinery as m; print(m.EXTENSION_SUFFIXES)"
['.cpython-314t-darwin.so', '.abi3.so', '.so']
It offers to load .abi3.so, then fails. And it cannot possibly succeed, because the
free-threaded headers refuse to compile the Limited API at all:
/* python3.14t/Include/Python.h, lines 50-53 — verbatim from this build */
// gh-111506: The free-threaded build is not compatible with the limited API
// or the stable ABI.
#if defined(Py_LIMITED_API) && defined(Py_GIL_DISABLED)
# error "The limited API is not currently supported in the free-threaded build"
#endif
The mechanism is the object layout. I compiled the same three-line program against both sets of headers (measured):
| GIL build 3.14.6 | free-threaded 3.14.6 | |
|---|---|---|
sizeof(PyObject) |
16 | 32 |
sizeof(PyModuleDef) |
104 | 120 |
offsetof(PyModuleDef, m_name) |
40 | 56 |
That first row is 16-object-memory-layout.md §2's +16-byte
tax, now visible at the C level rather than through sys.getsizeof. And while I was in
there I resolved the caveat that document flagged — here is the actual free-threaded
struct _object from python3.14t/Include/object.h, which has three fields doc 16's
sketch omitted:
struct _object {
uintptr_t ob_tid; /* owning thread id, or 0 (unowned/immortal/merged) */
uint16_t ob_flags;
PyMutex ob_mutex; /* per-object lock — one byte */
uint8_t ob_gc_bits; /* gc state, since there is no PyGC_Head */
uint32_t ob_ref_local; /* non-atomic, owner only */
Py_ssize_t ob_ref_shared; /* atomic, everyone else */
PyTypeObject *ob_type;
};
The PyModuleDef rows are the real killer, and they are the direct motivation for
PEP 793. A PyModuleDef is a PyObject — it begins with PyModuleDef_HEAD_INIT.
Virtually every extension allocates one statically. A statically-allocated PyObject
has its header baked into your .so at compile time at compile-time offsets. Move the
fields and that static object is garbage on the other build — hence SystemError: init
function returned uninitialized object. One statically allocated struct is the entire
reason "one wheel for both builds" was impossible.
Symbol counts¶
nm -u on the two builds (measured):
$ nm -u abi3demo.abi3.so | grep Py # Limited API build
_PyModuleDef_Init
_Py_BuildValue
_Py_Version # 3 undefined symbols
$ nm -u pmx17.cpython-314-darwin.so | grep Py | tail -4 # full API build
__Py_Dealloc
__Py_FalseStruct
__Py_NoneStruct
__Py_TrueStruct # 26 undefined symbols
The leading double underscore on __Py_NoneStruct (one from the Mach-O _ prefix, one
real) tells the story: the full-API build links against CPython's private data symbols.
Py_None is a macro for &_Py_NoneStruct. Py_DECREF inlines a call to _Py_Dealloc.
Those are the exact things the Limited API hides behind function calls so that CPython can
change them. The Stable ABI's cost is one indirect call per operation; its benefit is
that CPython can move the furniture. See 04-binary-abi-and-linking.md
for the general form of this trade.
3. PEP 803 (abi3t) and PEP 793 (PyModExport)¶
This is the frontier as of Aug 2026, and it is the answer to §2's dead end.
PEP 803 — verified¶
PEP 803 — "abi3t": Stable ABI for Free-Threaded Builds. Authors: Petr Viktorin, Nathan Goldbaum. Status: Final. Standards Track. Requires PEP 703, 793, 697. Created 19-Aug-2025. Python-Version: 3.15. Resolution: 30-Mar-2026. — (verified against peps.python.org/pep-0803/, 2026-08-02)
So the lead was right on both the number and the substance. Specifics, from the PEP text:
- A new stable ABI,
abi3t. Extensions built forabi3t3.x are compatible with free-threading builds of CPython 3.x and above — mirroringabi3's promise for GIL-enabled builds. - Opt in with
Py_TARGET_ABI3T, deliberately not namedPy_LIMITED_API(see §2). - The limited API for free-threaded builds is a subset of the 3.15 Limited API.
PyObjectbecomes fully opaque. You do not writePyObject_HEADin your struct. Instance data is reached throughPyObject_GetTypeData()with the defining class, which you obtain either from aPyCMethod-signature method'sdefining_classargument or via aPy_tp_token+PyType_GetBaseByToken(). This is PEP 697 ("Limited C API for Extending Opaque Types", Final, 3.12) being cashed in.- Wheel/filename tags: ABI tag
abi3t; filenamemymod.abi3t.so. The PEP recommends building for both and taggingabi3.abi3t. - The suffix lists change, and this closes §2's hole (quoting the PEP, for a Linux build):
python3.15:['.cpython-315-x86_64-linux-gnu.so', '.abi3.so', '.abi3t.so', '.so']python3.15t:['.cpython-315-x86_64-linux-gnu.so', '.abi3t.so', '.so']
Free-threaded 3.15 will no longer offer to load .abi3.so — which is exactly the
failure I reproduced in §2 on 3.14t. GIL-enabled builds will load .abi3t.so; the PEP
is candid that this "breaks the conceptual purity of abi3 and abi3t being separate
ABIs" for practical reasons.
- Version detection changes too: PY_VERSION_HEX no longer tells you what you're running
on. Use Py_Version (runtime) and Py_TARGET_ABI3T / Py_LIMITED_API (compile
time). My §2 experiment shows why this matters — abi3demo reported
compiled_against == running_on == 0x30e06f0, because PY_VERSION_HEX is just the
header's version, not the ABI floor.
PEP 793 — the piece that makes it possible¶
PEP 793. Petr Viktorin. Status: Final. Standards Track. Created 23-May-2025. Python-Version: 3.15. Resolution: 23-Oct-2025. — (verified 2026-08-02)
PEP 793 adds a new module export hook, PyModExport_<name>, which returns an array of
module slots directly — no PyModuleDef at all. From the PEP's own abstract: this
"allows extension authors to avoid using a statically allocated PyObject, lifting the
most common obstacle to making one compiled library file usable with both regular and
free-threaded builds of CPython." That is precisely the 104-vs-120-byte PyModuleDef
problem I measured in §2.
New slot IDs replace the PyModuleDef fields (confirmed present in the 3.15
c-api/module.html docs, all marked Part of the Stable ABI since version 3.15):
Py_mod_name, Py_mod_doc, Py_mod_methods, Py_mod_state_size, Py_mod_token, plus
PyModule_FromSlotsAndSpec() for dynamic creation. The classic PyInit_* hook is
soft-deprecated: still supported, still documented, but no new features.
Py_mod_token deserves a note, because it replaces a pattern you will see everywhere in
older code. The old way to ask "is this module mine?" was PyModule_GetDef(module) ==
&my_def — pointer identity on a static struct. With no static struct, you instead declare
static char my_token; and compare via PyModule_GetToken() / PyType_GetModuleByToken().
Same idea (a unique address), no PyObject required.
(3.15, not compiled here.) I read the 3.15.0b4
abi3t-migrationHOWTO andc-api/module.html, but I have only 3.14.6 locally, so none of §3's code shapes were compiled. The 3.15 HOWTO also shows aPySlot/PySlot_STATIC_DATA(...)/PySlot_ENDspelling for slot arrays that I have not seen elsewhere and cannot verify against a header. Treat §3 as read, not run. Everything in §2 and §4–§14 was executed.
The practical decision, today¶
| You are | Do this |
|---|---|
| Shipping a pure C extension, care about wheel count | Target abi3 today; port to abi3t when 3.15 is final and you can drop <3.12 |
| Using Cython / PyO3 / pybind11 / nanobind | Wait for your generator. The 3.15 HOWTO says so explicitly. PyO3 0.29 already has abi3t features (§13) |
| Needing peak performance at the boundary | Full API, one wheel per version. This is what NumPy does |
| Needing free-threading on 3.14 | Full API only. Limited API is a compile error there |
4. Reference semantics: new, borrowed, stolen¶
This is the invariant that kills people, so it gets a diagram.
Every PyObject* crossing a function boundary carries one of three ownership contracts,
and none of them is expressed in the C type system. PyObject * is PyObject * in all
three cases. The contract lives in the documentation and in your head.
╔═══════════════════════════════════════════════════════════════════════════════╗
║ OWNERSHIP FLOW THROUGH ONE C FUNCTION ║
╚═══════════════════════════════════════════════════════════════════════════════╝
static PyObject *f(PyObject *module, PyObject *arg)
│ │
BORROWED ────────────┘ └──────── BORROWED
(module outlives the call; (the caller's stack holds a ref for the
you may read, must not whole call; safe to read, NOT safe to
DECREF) stash in a struct without Py_INCREF)
│
│ PyObject *tmp = PyLong_FromLong(42); ┌── NEW REFERENCE ──┐
│ ────────────────────────────────────────────┤ you own it │
│ │ you must dispose │
│ └───────┬───────────┘
│ │
├── (a) return it ─────────────────────────────▶ ownership TRANSFERS
│ to the caller. done.
│
├── (b) PyTuple_SET_ITEM(t, 0, tmp); ─────────▶ STOLEN. the tuple now
│ PyList_SET_ITEM(l, 0, tmp); owns it. Do NOT decref.
│ PyException_SetCause(e, tmp); Do NOT touch tmp again.
│ PyModule_AddObject(m, "x", tmp); ← steals ONLY ON SUCCESS (!!)
│
├── (c) PyDict_SetItem(d, k, tmp); ──────────▶ NOT stolen. dict took
│ PyList_Append(l, tmp); its own ref. YOU still
│ PyModule_AddObjectRef(m,"x",tmp); owe a Py_DECREF.
│ ... Py_DECREF(tmp);
│
└── (d) something failed ───────────────────────▶ Py_DECREF(tmp);
return NULL;
^^^^^^^^^^^^^ the line
people forget.
┌──────────────────────────────────────────────────────────────────────────┐
│ THE INVARIANT: at every `return` from this function, the number of │
│ Py_INCREFs you performed (including implicit ones from *_New / *_From*) │
│ minus the number of Py_DECREFs equals the number of new references you │
│ are handing back. Not "usually". At EVERY return, including error ones. │
└──────────────────────────────────────────────────────────────────────────┘
The vocabulary, precisely¶
| Term | Meaning | You must |
|---|---|---|
| New reference | The callee incremented the count on your behalf | Py_DECREF it, or transfer it |
| Borrowed reference | You got a pointer, nobody incremented anything | Nothing — but you may not outlive the owner |
| Stolen reference | You gave a function a reference and it took ownership | Nothing. Never touch it again |
Naming heuristics that actually hold up: *_New, *_From*, PyObject_Call*,
PyObject_GetAttr*, PyDict_GetItemRef, Py_NewRef return new references.
PyDict_GetItem, PyList_GetItem, PyTuple_GetItem, PyList_GET_ITEM, PyErr_Occurred,
PyModule_GetDict, PySequence_Fast_GET_ITEM return borrowed ones. The stealing set
is small and worth memorizing outright: PyTuple_SET_ITEM, PyList_SET_ITEM,
PyTuple_SetItem, PyList_SetItem, PyException_SetCause, PyException_SetContext,
PyErr_SetRaisedException, and the legacy PyModule_AddObject.
and approximately nobody writes that. It is deprecated. Use
PyModule_AddObjectis the worst contract in the C API and you should never use it. It steals your reference only if it succeeds. On failure you still own it. Which means the correct call site is:PyModule_AddObjectRef(3.10+, does not steal) orPyModule_Add(3.13+). This one function is responsible for a genuinely large fraction of historical extension leaks.
The five macros¶
Py_INCREF(o) /* o must not be NULL */
Py_DECREF(o) /* o must not be NULL; may run arbitrary Python code via __del__ */
Py_XDECREF(o) /* NULL-safe; the workhorse of error paths and single-exit cleanup */
Py_CLEAR(o) /* the ONLY correct way to drop a reference held in a struct field */
Py_NewRef(o) /* 3.10+: incref and return, so you can write `return Py_NewRef(x);` */
Py_CLEAR is not "Py_XDECREF plus assign NULL", and the difference is the point.
It is defined (in Include/refcount.h) to first set the field to NULL, then decref
the old value. Order matters because Py_DECREF can run __del__, which can re-enter your
object and read that same field. If you wrote:
Py_XDECREF(self->cache); /* __del__ runs here and reads self->cache -> DANGLING */
self->cache = NULL;
you have a use-after-free that only fires when the object being dropped has a finalizer and
that finalizer touches your object. Which is to say: never in your tests, once in
production. In tp_clear and m_clear, always Py_CLEAR.
The two classic bugs¶
Bug 1 — a borrowed reference outliving its owner.
/* WRONG */
PyObject *item = PyList_GetItem(list, 0); /* borrowed */
PyObject *result = PyObject_CallObject(callback, NULL); /* may mutate `list` */
use(item); /* `item` may have been freed by list.clear() inside the callback */
Anything that can run Python code — a call, a comparison, a __hash__, an allocation that
triggers GC, a Py_DECREF that runs __del__ — can invalidate a borrowed reference.
The fix is PyList_GetItemRef (3.13+), which returns a strong reference. This bug is
latent under the GIL and immediate under free-threading; see §11.
Bug 2 — leaking on the error path. The one my §6 module is structured to avoid:
/* WRONG — three leaks hiding in plain sight */
PyObject *a = PyLong_FromLong(1);
PyObject *b = PyLong_FromLong(2);
if (b == NULL) return NULL; /* leaks a */
PyObject *c = PyLong_FromLong(3);
if (c == NULL) return NULL; /* leaks a and b */
return Py_BuildValue("(OOO)", a, b, c);/* leaks a, b, c — BuildValue's "O" increfs! */
The discipline that fixes it, and the reason C extensions are full of goto:
PyObject *a = NULL, *b = NULL, *c = NULL, *res = NULL;
a = PyLong_FromLong(1); if (a == NULL) goto done;
b = PyLong_FromLong(2); if (b == NULL) goto done;
c = PyLong_FromLong(3); if (c == NULL) goto done;
res = Py_BuildValue("(OOO)", a, b, c);
done:
Py_XDECREF(a); Py_XDECREF(b); Py_XDECREF(c);
return res; /* NULL on any failure, with the exception still set */
Initialize everything to NULL, one exit label, Py_XDECREF everything, return the
result variable. goto in C extensions is not sloppiness — it is the language's only
finally.
5. Error handling: the thread-state exception indicator¶
There are no exceptions in C. CPython emulates them with a per-thread-state exception indicator plus a return-value convention. Both halves must be right.
The two conventions¶
| Return type | Success | Failure |
|---|---|---|
PyObject * |
non-NULL |
NULL with an exception set |
int |
0 (or >0 for "found") |
-1 with an exception set |
Py_ssize_t |
>= 0 |
-1 with an exception set |
void |
— | cannot fail, or reports via PyErr_Occurred() |
The invariant runs in both directions and both violations are real bugs:
NULL/-1without an exception set → the interpreter raisesSystemError: <fn> returned NULL without setting an exception.- A set exception with a success return → far worse. The exception leaks into an
unrelated later operation and you get a traceback pointing at innocent code. This is why
you occasionally see a
ValueErrorraised "from" a line that cannot raise it.
Checking PyErr_Occurred correctly¶
PyErr_Occurred() returns a borrowed reference to the exception type, or NULL.
It is the right tool exactly when the return value is ambiguous:
long n = PyLong_AsLong(obj); /* returns -1 on error... and -1 is a valid long */
if (n == -1 && PyErr_Occurred()) {
return NULL;
}
PyLong_AsLong, PyObject_IsTrue, PySequence_Size — anything whose error sentinel is
also a legal value — needs this two-part check. Getting it wrong gives you a program that
works until someone passes -1.
Setting and chaining¶
PyErr_SetString(PyExc_ValueError, "chunk size must be positive");
PyErr_Format(PyExc_TypeError, "expected bytes, got %.100s", Py_TYPE(o)->tp_name);
PyErr_SetFromErrno(PyExc_OSError); /* wraps the C errno */
PyErr_NoMemory(); /* returns NULL, convenient */
Implicit chaining (__context__) is automatic. If an exception is already set when you
call PyErr_SetString, CPython attaches the old one as __context__ — the same rule as
raising inside an except: block in Python. You get it for free.
Explicit chaining (raise X from Y, i.e. __cause__) you must do by hand, with the
modern 3.12+ exception-object API:
PyObject *cause = PyErr_GetRaisedException(); /* NEW ref; clears the indicator */
PyErr_SetString(st->ChecksumError, "not a checksummable buffer");
PyObject *exc = PyErr_GetRaisedException(); /* NEW ref */
PyException_SetCause(exc, cause); /* STEALS cause */
PyErr_SetRaisedException(exc); /* STEALS exc */
return NULL;
PyErr_GetRaisedException / PyErr_SetRaisedException (3.12+) replaced the old
PyErr_Fetch / PyErr_Restore / PyErr_NormalizeException triple-pointer dance. The old
API handed you three separate PyObject*s (type, value, traceback) that might or might not
be normalized; the new one hands you one exception object. If you are reading pre-3.12
extension code, that triple is what you'll find.
Here is that exact code path, running, from the module in §6 (measured, identical on both builds):
chained : ChecksumError | argument is not a checksummable buffer
__cause__ : TypeError("a bytes-like object is required, not 'int'")
__context__ : None
__suppress_context__ : True
Note __suppress_context__ is True — PyException_SetCause sets it, matching Python's
raise ... from ... semantics exactly. I did not assume that; I printed it.
Exceptions where you cannot raise¶
Destructors, tp_dealloc, tp_clear, and callbacks invoked from C code with no error
channel cannot propagate an exception. The correct escape hatch is
PyErr_WriteUnraisable(obj) or, since 3.13, PyErr_FormatUnraisable(fmt, ...),
which routes through sys.unraisablehook. Swallowing the error silently is the wrong
answer; so is calling PyErr_Clear() and pretending.
6. The complete extension module¶
This is the centerpiece. It is real, it compiles clean at -Wall -Wextra, and it imports
and runs on both interpreters on this machine. Everything in §4, §5, §7–§12 shows up in it.
pmx17.c¶
/* pmx17.c — a minimal but *complete* CPython extension module. */
#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include <stdint.h>
#include <stddef.h> /* offsetof */
/* --------------------------------------------------------------------- */
/* 1. Per-module state (PEP 573). Everything a C global would have held. */
/* --------------------------------------------------------------------- */
typedef struct {
PyObject *ChecksumError; /* strong ref */
PyObject *AccumulatorType; /* strong ref */
} pmx_state;
static inline pmx_state *
get_state(PyObject *module)
{
void *st = PyModule_GetState(module);
assert(st != NULL);
return (pmx_state *)st;
}
/* --------------------------------------------------------------------- */
/* 2. Pure C. No PyObject in sight — this is what runs without the GIL. */
/* --------------------------------------------------------------------- */
#define FNV_OFFSET 1469598103934665603ULL
#define FNV_PRIME 1099511628211ULL
static uint64_t
fnv1a64(uint64_t h, const unsigned char *p, Py_ssize_t n)
{
for (Py_ssize_t i = 0; i < n; i++) {
h ^= (uint64_t)p[i];
h *= FNV_PRIME;
}
return h;
}
/* --------------------------------------------------------------------- */
/* 3. Module-level functions */
/* --------------------------------------------------------------------- */
/* checksum(buf) -> int
* METH_O: exactly one argument, no tuple is built, no parsing happens. */
static PyObject *
pmx_checksum(PyObject *module, PyObject *arg)
{
Py_buffer view;
/* PyBUF_SIMPLE: "give me a flat, C-contiguous block". On failure it has
* ALREADY set the exception; we return NULL and add nothing. Returning
* NULL without an exception set is a SystemError (see §5). */
if (PyObject_GetBuffer(arg, &view, PyBUF_SIMPLE) < 0) {
return NULL;
}
uint64_t h;
/* The buffer export pins the memory: the exporter cannot be resized or
* freed while a Py_buffer is outstanding. That is exactly what makes it
* safe to touch view.buf with the GIL released. */
Py_BEGIN_ALLOW_THREADS
h = fnv1a64(FNV_OFFSET, (const unsigned char *)view.buf, view.len);
Py_END_ALLOW_THREADS
PyBuffer_Release(&view);
return PyLong_FromUnsignedLongLong(h);
}
/* The identical computation with the GIL held. Kept only so the two can be
* measured against each other — see §10. Never ship this shape. */
static PyObject *
pmx_checksum_gil(PyObject *module, PyObject *arg)
{
Py_buffer view;
if (PyObject_GetBuffer(arg, &view, PyBUF_SIMPLE) < 0) {
return NULL;
}
uint64_t h = fnv1a64(FNV_OFFSET, (const unsigned char *)view.buf, view.len);
PyBuffer_Release(&view);
return PyLong_FromUnsignedLongLong(h);
}
/* Helper: Py_ssize_t[] -> tuple, or None if the array is absent. */
static PyObject *
sizes_to_tuple(const Py_ssize_t *arr, int n)
{
if (arr == NULL) {
Py_RETURN_NONE;
}
PyObject *t = PyTuple_New(n);
if (t == NULL) {
return NULL;
}
for (int i = 0; i < n; i++) {
PyObject *v = PyLong_FromSsize_t(arr[i]);
if (v == NULL) {
Py_DECREF(t); /* error path: do not leak the tuple */
return NULL;
}
PyTuple_SET_ITEM(t, i, v); /* STEALS our reference to v */
}
return t;
}
/* describe_buffer(obj) -> dict: the full PEP 3118 view. */
static PyObject *
pmx_describe(PyObject *module, PyObject *arg)
{
Py_buffer v;
if (PyObject_GetBuffer(arg, &v, PyBUF_FULL_RO) < 0) {
return NULL;
}
PyObject *shape = NULL, *strides = NULL, *subs = NULL, *res = NULL;
shape = sizes_to_tuple(v.shape, v.ndim);
if (shape == NULL) { goto done; }
strides = sizes_to_tuple(v.strides, v.ndim);
if (strides == NULL) { goto done; }
subs = sizes_to_tuple(v.suboffsets, v.ndim);
if (subs == NULL) { goto done; }
res = Py_BuildValue(
"{s:n,s:i,s:s,s:n,s:O,s:O,s:O,s:O,s:O,s:O}",
"len", v.len,
"ndim", v.ndim,
"format", v.format ? v.format : "B",
"itemsize", v.itemsize,
"readonly", v.readonly ? Py_True : Py_False,
"shape", shape,
"strides", strides,
"suboffsets", subs,
"c_contiguous", PyBuffer_IsContiguous(&v, 'C') ? Py_True : Py_False,
"f_contiguous", PyBuffer_IsContiguous(&v, 'F') ? Py_True : Py_False);
done:
/* Single exit. Every temporary is released exactly once whether we got
* here by success or by any of the three failure branches. Py_XDECREF
* tolerates NULL so the same three lines cover all four cases. */
Py_XDECREF(shape);
Py_XDECREF(strides);
Py_XDECREF(subs);
PyBuffer_Release(&v);
return res;
}
/* checksum_strict(buf): like checksum(), but re-raises any failure as this
* module's own ChecksumError with the original attached as __cause__. */
static PyObject *
pmx_checksum_strict(PyObject *module, PyObject *arg)
{
PyObject *result = pmx_checksum(module, arg);
if (result != NULL) {
return result;
}
PyObject *cause = PyErr_GetRaisedException(); /* new ref */
pmx_state *st = get_state(module);
PyErr_SetString(st->ChecksumError, "argument is not a checksummable buffer");
PyObject *exc = PyErr_GetRaisedException(); /* new ref */
PyException_SetCause(exc, cause); /* STEALS cause */
PyErr_SetRaisedException(exc); /* STEALS exc */
return NULL;
}
static PyMethodDef pmx_methods[] = {
{"checksum", pmx_checksum, METH_O,
"checksum(buf) -> int -- FNV-1a 64 over any buffer, GIL released."},
{"checksum_gil", pmx_checksum_gil, METH_O,
"checksum_gil(buf) -> int -- identical, but holds the GIL. For §10 only."},
{"checksum_strict", pmx_checksum_strict, METH_O,
"checksum_strict(buf) -> int -- raises ChecksumError from the original."},
{"describe_buffer", pmx_describe, METH_O,
"describe_buffer(obj) -> dict -- the raw Py_buffer fields."},
{NULL, NULL, 0, NULL}
};
/* --------------------------------------------------------------------- */
/* 4. A heap type */
/* --------------------------------------------------------------------- */
typedef struct {
PyObject_HEAD
uint64_t hash;
Py_ssize_t nbytes;
} AccumulatorObject;
static PyObject *
Accumulator_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
AccumulatorObject *self = (AccumulatorObject *)type->tp_alloc(type, 0);
if (self == NULL) {
return NULL;
}
self->hash = FNV_OFFSET;
self->nbytes = 0;
return (PyObject *)self;
}
static void
Accumulator_dealloc(PyObject *self)
{
/* The canonical heap-type dealloc. Note the Py_DECREF(tp) at the end:
* an instance of a heap type owns a reference to its own class, and if
* you forget this the class leaks forever. Static types don't do this. */
PyTypeObject *tp = Py_TYPE(self);
PyObject_GC_UnTrack(self);
tp->tp_free(self);
Py_DECREF(tp);
}
static int
Accumulator_traverse(PyObject *self, visitproc visit, void *arg)
{
/* Required since 3.9 for GC-enabled heap types: the instance->class
* edge must be visible to the cycle collector. */
Py_VISIT(Py_TYPE(self));
return 0;
}
static PyObject *
Accumulator_update(PyObject *self, PyObject *arg)
{
AccumulatorObject *acc = (AccumulatorObject *)self;
Py_buffer view;
if (PyObject_GetBuffer(arg, &view, PyBUF_SIMPLE) < 0) {
return NULL;
}
/* No-op on the GIL build; a real per-object lock on the free-threaded
* build. Without it, two threads calling update() concurrently lose
* bytes: the read-modify-write of acc->hash is not atomic. See §11. */
Py_BEGIN_CRITICAL_SECTION(self);
acc->hash = fnv1a64(acc->hash, (const unsigned char *)view.buf, view.len);
acc->nbytes += view.len;
Py_END_CRITICAL_SECTION();
PyBuffer_Release(&view);
Py_RETURN_NONE;
}
/* The same thing WITHOUT the critical section. Correct under the GIL,
* silently lossy under free-threading. Exists only to be measured. */
static PyObject *
Accumulator_update_unlocked(PyObject *self, PyObject *arg)
{
AccumulatorObject *acc = (AccumulatorObject *)self;
Py_buffer view;
if (PyObject_GetBuffer(arg, &view, PyBUF_SIMPLE) < 0) {
return NULL;
}
acc->hash = fnv1a64(acc->hash, (const unsigned char *)view.buf, view.len);
acc->nbytes += view.len;
PyBuffer_Release(&view);
Py_RETURN_NONE;
}
/* METH_FASTCALL: args is a raw C array, nargs is its length. No argument
* tuple is ever allocated (PEP 590, §12). */
static PyObject *
Accumulator_update_many(PyObject *self, PyObject *const *args, Py_ssize_t nargs)
{
for (Py_ssize_t i = 0; i < nargs; i++) {
PyObject *r = Accumulator_update(self, args[i]);
if (r == NULL) {
return NULL; /* args are BORROWED — nothing to release */
}
Py_DECREF(r);
}
Py_RETURN_NONE;
}
static PyObject *
Accumulator_digest(PyObject *self, PyObject *Py_UNUSED(ignored))
{
AccumulatorObject *acc = (AccumulatorObject *)self;
uint64_t h;
Py_BEGIN_CRITICAL_SECTION(self);
h = acc->hash;
Py_END_CRITICAL_SECTION();
return PyLong_FromUnsignedLongLong(h);
}
static PyMethodDef Accumulator_methods[] = {
{"update", Accumulator_update, METH_O, "update(buf)"},
{"update_unlocked", Accumulator_update_unlocked, METH_O, "update(buf), no lock"},
{"update_many", (PyCFunction)(void(*)(void))Accumulator_update_many,
METH_FASTCALL, "update_many(*bufs)"},
{"digest", Accumulator_digest, METH_NOARGS, "digest() -> int"},
{NULL, NULL, 0, NULL}
};
static PyMemberDef Accumulator_members[] = {
{"nbytes", Py_T_PYSSIZET, offsetof(AccumulatorObject, nbytes), Py_READONLY,
"bytes consumed so far"},
{NULL, 0, 0, 0, NULL}
};
static PyType_Slot Accumulator_slots[] = {
{Py_tp_doc, (void *)"Incremental FNV-1a 64 accumulator."},
{Py_tp_new, Accumulator_new},
{Py_tp_dealloc, Accumulator_dealloc},
{Py_tp_traverse, Accumulator_traverse},
{Py_tp_methods, Accumulator_methods},
{Py_tp_members, Accumulator_members},
{0, NULL}
};
static PyType_Spec Accumulator_spec = {
.name = "pmx17.Accumulator",
.basicsize = sizeof(AccumulatorObject),
.itemsize = 0,
.flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC | Py_TPFLAGS_BASETYPE,
.slots = Accumulator_slots,
};
/* --------------------------------------------------------------------- */
/* 5. Multi-phase init (PEP 489) */
/* --------------------------------------------------------------------- */
static int
pmx_exec(PyObject *module)
{
pmx_state *st = get_state(module);
st->ChecksumError = PyErr_NewException("pmx17.ChecksumError", NULL, NULL);
if (st->ChecksumError == NULL) {
return -1;
}
/* PyModule_AddObjectRef does NOT steal. Its predecessor
* PyModule_AddObject stole *only on success* — see §4. */
if (PyModule_AddObjectRef(module, "ChecksumError", st->ChecksumError) < 0) {
return -1;
}
PyObject *t = PyType_FromModuleAndSpec(module, &Accumulator_spec, NULL);
if (t == NULL) {
return -1;
}
st->AccumulatorType = t; /* module state takes the strong ref */
if (PyModule_AddObjectRef(module, "Accumulator", t) < 0) {
return -1;
}
#ifdef Py_GIL_DISABLED
if (PyModule_AddStringConstant(module, "BUILD", "free-threaded") < 0) {
return -1;
}
#else
if (PyModule_AddStringConstant(module, "BUILD", "gil") < 0) {
return -1;
}
#endif
return 0;
}
static int
pmx_traverse(PyObject *module, visitproc visit, void *arg)
{
pmx_state *st = get_state(module);
Py_VISIT(st->ChecksumError);
Py_VISIT(st->AccumulatorType);
return 0;
}
static int
pmx_clear(PyObject *module)
{
pmx_state *st = get_state(module);
Py_CLEAR(st->ChecksumError);
Py_CLEAR(st->AccumulatorType);
return 0;
}
static void
pmx_free(void *module)
{
(void)pmx_clear((PyObject *)module);
}
static PyModuleDef_Slot pmx_slots[] = {
{Py_mod_exec, (void *)pmx_exec},
/* "You may load me into more than one interpreter, each with its own
* GIL." You can only promise this because there are no C globals. */
{Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED},
#ifdef Py_mod_gil
/* "Do not re-enable the GIL on my account." */
{Py_mod_gil, Py_MOD_GIL_NOT_USED},
#endif
{0, NULL}
};
static struct PyModuleDef pmx_module = {
.m_base = PyModuleDef_HEAD_INIT,
.m_name = "pmx17",
.m_doc = "Doc 17's demonstration extension.",
.m_size = sizeof(pmx_state), /* > 0 == per-module state exists */
.m_methods = pmx_methods,
.m_slots = pmx_slots,
.m_traverse = pmx_traverse,
.m_clear = pmx_clear,
.m_free = pmx_free,
};
PyMODINIT_FUNC
PyInit_pmx17(void)
{
/* Multi-phase: return the *definition*, not a module. The import
* machinery calls create/exec later, possibly more than once. */
return PyModuleDef_Init(&pmx_module);
}
Building it — macOS specifics¶
$ PY=~/.local/bin/python3.14
$ clang -O2 -Wall -Wextra -Wno-unused-parameter \
-shared -undefined dynamic_lookup \
$($PY-config --includes) \
-o pmx17$($PY -c "import sysconfig;print(sysconfig.get_config_var('EXT_SUFFIX'))") \
pmx17.c
Four macOS/arm64 notes, because this is where cross-platform build scripts break:
-undefined dynamic_lookup. A Python extension on macOS is a bundle that referencesPy*symbols supplied by whicheverpythonbinary loads it. Historically you passed-undefined dynamic_lookupto tell the linker "these will resolve at load time". Apple's new linker (ld_prime, Xcode 15+) deprecates the flag and it emits a warning on some toolchains — on Apple clang 21.0.0 with this SDK it compiled clean, no warning (measured). The forward-compatible alternative is-Wl,-undefined,dynamic_lookupor linking directly againstlibpython3.14.dylib(present in this distribution atlib/libpython3.14.dylib). On Linux you use-shared -fPICand undefined symbols are fine by default.- The extension suffix is not
.soplus guesswork. Ask sysconfig. Here they are.cpython-314-darwin.soand.cpython-314t-darwin.so(measured) — note thet, which is the free-threaded ABI tag from PEP 703 surfacing in the filename. python3.14-configlives next to the interpreter, not onPATHvia thebinshim. In this uv layout it is~/.local/share/uv/python/cpython-3.14-macos-aarch64-none/bin/python3.14-config. The free-threaded one reports-I.../include/python3.14t— a different include directory, which is how the two builds' headers stay apart.-Wall -Wextrais not optional in this domain. It is the only static checking you get.
It runs — both builds¶
Real output, unedited (measured):
=========== GIL BUILD ===========
interp : 3.14.6 gil_enabled=True
module : pmx17.cpython-314-darwin.so BUILD = gil
checksum : 0xe1d7a701437f78f9
accumulator: digest=0xe1d7a701437f78f9 nbytes=11 matches one-shot: True
1-D bytes : {'len': 4, 'ndim': 1, 'format': 'B', 'itemsize': 1, 'readonly': True,
'shape': (4,), 'strides': (1,), 'suboffsets': None,
'c_contiguous': True, 'f_contiguous': True}
2-D int32 : {'len': 48, 'ndim': 2, 'format': 'i', 'itemsize': 4, 'readonly': False,
'shape': (3, 4), 'strides': (16, 4), 'suboffsets': None,
'c_contiguous': True, 'f_contiguous': False}
strided 1D : {'len': 16, 'ndim': 1, 'format': 'i', 'itemsize': 4, 'readonly': False,
'shape': (4,), 'strides': (12,), 'suboffsets': None,
'c_contiguous': False, 'f_contiguous': False}
chained : ChecksumError | argument is not a checksummable buffer
__cause__ : TypeError("a bytes-like object is required, not 'int'")
__context__ : None
__suppress_context__ : True
=========== FREE-THREADED BUILD ===========
interp : 3.14.6 gil_enabled=False
module : pmx17.cpython-314t-darwin.so BUILD = free-threaded
checksum : 0xe1d7a701437f78f9
accumulator: digest=0xe1d7a701437f78f9 nbytes=11 matches one-shot: True
... (identical) ...
Same source, two builds, two binaries, identical behaviour — and gil_enabled=False stays
False after the import, which is the Py_mod_gil slot doing its job (§11).
7. Defining types in C: static vs heap¶
Accumulator above is a heap type, created at runtime by PyType_FromModuleAndSpec.
The older shape is a static type: a PyTypeObject you declare as a file-scope C global
and fill in field by field.
/* the OLD way — a statically allocated PyObject */
static PyTypeObject AccumulatorType = {
PyVarObject_HEAD_INIT(NULL, 0)
.tp_name = "pmx17.Accumulator",
.tp_basicsize = sizeof(AccumulatorObject),
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_new = Accumulator_new,
.tp_dealloc = Accumulator_dealloc,
.tp_methods = Accumulator_methods,
};
/* ... then PyType_Ready(&AccumulatorType); in module init */
It is shorter and it is why every tutorial written before ~2020 uses it. Here is why it is now the wrong default:
| Static type | Heap type (PyType_FromSpec family) |
|
|---|---|---|
| Storage | file-scope C global, one per process | heap object, one per module instance |
| Subinterpreters | shared across all of them → state leaks | isolated per interpreter |
| Limited API | impossible (PyTypeObject is opaque) |
the only option |
abi3t / free-threading |
impossible (statically allocated PyObject) |
fine |
| Module access from a method | PyType_GetModule does not work |
works — the type knows its module |
tp_dealloc |
plain | must Py_DECREF(Py_TYPE(self)) |
| Attribute assignment from Python | not allowed | allowed (it's a real class) |
| Cost | zero at runtime | one type object built at import |
The single argument that ends the debate: a static PyTypeObject is a statically
allocated PyObject, which is exactly what PEP 793 and PEP 803 need to eliminate (§2,
§3). Static types are on the same road as single-phase init.
The three heap-type gotchas¶
1. The dealloc dance. An instance of a heap type holds a strong reference to its class
(taken by tp_alloc). If your tp_dealloc doesn't release it, the class object never
dies — a leak of the whole type, its dict, its methods, and transitively its module. The
canonical form, exactly as in §6:
PyTypeObject *tp = Py_TYPE(self);
PyObject_GC_UnTrack(self); /* only if HAVE_GC */
/* ... clear your own fields with Py_CLEAR ... */
tp->tp_free(self);
Py_DECREF(tp); /* <-- the line that is always missing */
Note the order: cache tp before freeing self, since after tp_free the memory is
gone.
2. tp_traverse must visit the type. Since 3.9 (bpo-40217), a GC-enabled heap type's
tp_traverse must Py_VISIT(Py_TYPE(self)). Skip it and the instance→class edge is
invisible to the cycle collector, so a class whose instances reference it can never be
collected. See 22-garbage-collection.md for why an
incomplete tp_traverse is a correctness bug and not a performance one.
3. PyType_FromModuleAndSpec vs PyType_FromSpec. Use the ModuleAndSpec variant
whenever the type's methods need module state. It associates the type with the module, so
PyType_GetModuleState(Py_TYPE(self)) works inside a method. That association is the whole
PEP 573 mechanism (§8). PyType_FromMetaclass (3.12+) is the newest and most general
entry point — it additionally lets you specify the metaclass, which the older two hard-code
to type.
8. Multi-phase init, per-module state, and subinterpreters¶
The bug that a C global now is¶
static long counter = 0; /* one per PROCESS */
static PyObject *cached_exception = NULL; /* one per PROCESS */
For 25 years this was normal. Three things broke it:
- Reloading.
importlib.reload()on a single-phase module doesn't re-run initialization;sys.modulesgets a fresh module object whose__dict__is shared with the old one, because the contents come from a per-process struct. - Subinterpreters (PEP 684, per-interpreter GIL, 3.12; PEP 734,
concurrent.interpreters, 3.14). Two interpreters, one C global. Your "module state" silently becomes cross-interpreter shared mutable state — with no lock, and, under PEP 684, no shared GIL either. - Free-threading. The GIL used to be an implicit mutex around every C global. It
isn't any more. See
24-the-gil.md§9.
The full fix is a stack of PEPs, all by roughly the same people:
| PEP | Title | Status / version | What it gives you |
|---|---|---|---|
| 3121 | Extension Module Initialization and Finalization | Final, 3.0 | m_size, PyModule_GetState |
| 489 | Multi-phase extension module initialization | Final, 3.5 | PyModuleDef_Init, Py_mod_create, Py_mod_exec |
| 630 | Isolating Extension Modules | Informational, Final | the how-to guide for the whole migration |
| 573 | Module State Access from C Extension Methods | Final, 3.9 | PyType_FromModuleAndSpec, PyType_GetModule, PyCMethod |
| 687 | Isolating modules in the standard library | Final, 3.12 | the stdlib eating its own dog food |
| 684 | A Per-Interpreter GIL | Final, 3.12 | Py_MOD_PER_INTERPRETER_GIL_SUPPORTED becomes meaningful |
| 734 | Multiple Interpreters in the Stdlib | Final, 3.14 | concurrent.interpreters |
| 793 | (new module export hook) | Final, 3.15 | PyModExport_* — no PyModuleDef at all (§3) |
Single-phase vs multi-phase, mechanically¶
SINGLE-PHASE (PyModule_Create) MULTI-PHASE (PyModuleDef_Init, PEP 489)
────────────────────────────── ──────────────────────────────────────
import machinery
│ │
▼ ▼
PyInit_foo() PyInit_foo()
│ creates the module object │ returns &foo_def (a PyModuleDef,
│ right here, runs all your init │ which IS a PyObject — see §2)
│ code, returns a live module │
▼ ▼
interpreter has a module, and NO interpreter reads m_slots BEFORE running
chance to inspect it beforehand. any of your code. It can now check
Py_mod_multiple_interpreters and Py_mod_gil.
│
├─ Py_mod_create (optional): return a
│ custom module object
├─ allocate m_size bytes of state
├─ set __spec__, __name__, __loader__
└─ Py_mod_exec(module): YOUR init code,
with the module already fully formed
The load-bearing difference is the word "before". With multi-phase init the interpreter
can read your declarations without executing your code. That is the only reason
Py_mod_gil can work at all: the free-threaded interpreter must decide whether to re-enable
the GIL before your module runs anything.
PEP 793 spells this out as the design flaw it fixes: because single-phase modules can only be interrogated by calling them, CPython currently has to temporarily switch to the main interpreter, call the hook there, and then switch back and redo the import. The PEP calls this "unnecessary and fragile extra work" that "highlights the underlying design issue".
Measured: subinterpreters see through the lie¶
I built pmx17_legacy.c — 35 lines, single-phase, one C global — as the control. Then, on
the GIL build with concurrent.interpreters (PEP 734, stdlib since 3.14) (measured):
sub: pmx17 imported OK. ChecksumError id = 0x7b11efc10
sub: pmx17 imported OK. ChecksumError id = 0x7b11ebc10
main: pmx17.ChecksumError id = 0x7b11a0810
main: legacy module in a subinterpreter -> ExecutionFailed
ImportError: module pmx17_legacy does not support loading in subinterpreters
Three interpreters, three distinct ChecksumError objects — because the exception lives
in per-module state, allocated once per module instance. And the single-phase module is
refused outright: CPython will not load it into a subinterpreter, because it cannot
guarantee isolation. That is not a warning you can ignore; it is an ImportError.
The per-module state pattern, end to end¶
m_size = sizeof(my_state)in thePyModuleDef. (-1means "single-phase, no state";0means "multi-phase, no state".)PyModule_GetState(module)inside module-level functions — theselfargument of aMETH_*module function is the module.- From a method of a type, you need the module first:
PyType_GetModuleState(Py_TYPE(self))if the type was created withPyType_FromModuleAndSpec; or- use the
PyCMethodsignature withMETH_METHOD | METH_FASTCALL | METH_KEYWORDS, which passesPyTypeObject *defining_classexplicitly. This is the PEP 573 contribution, and it exists becausePy_TYPE(self)is wrong for subclasses. m_traverse/m_clearmust visit and clear everyPyObject*in your state. A module holding a type holding a method holding the module is a cycle; withoutm_traverseit leaks.m_freeruns at module teardown; delegating tom_clearis the standard idiom.
9. The buffer protocol (PEP 3118)¶
PEP 3118 — Revising the buffer protocol. Travis Oliphant, Carl Banks. Final, 3.0. (verified 2026-08-02.)
The buffer protocol is how a C extension says "here is a block of memory, in this shape,
with this element type — read it directly, do not copy it, and do not free it until I say
so." It is the reason NumPy, memoryview, array, bytes, Pillow, and Arrow can hand
data to each other for free.
The struct¶
typedef struct {
void *buf; /* start of the logical block */
PyObject *obj; /* the EXPORTER — a strong ref, keeps it alive */
Py_ssize_t len; /* total bytes = product(shape) * itemsize */
Py_ssize_t itemsize; /* bytes per element */
int readonly;
int ndim;
char *format; /* struct-module syntax: "i", "d", "3f", ... */
Py_ssize_t *shape; /* ndim entries */
Py_ssize_t *strides; /* ndim entries, IN BYTES */
Py_ssize_t *suboffsets; /* ndim entries, or NULL */
void *internal;
} Py_buffer;
buf, shape, strides, suboffsets, format and internal all belong to the
exporter. obj is a strong reference — which is exactly why holding a Py_buffer
makes it safe to touch buf after releasing the GIL (§10). Every successful
PyObject_GetBuffer must be paired with exactly one PyBuffer_Release.
Request flags — ask for the least you can handle¶
PyBUF_SIMPLE flat, contiguous, no shape/strides given. ← the 90% case
PyBUF_WRITABLE fail if the exporter is read-only
PyBUF_FORMAT fill in `format`
PyBUF_ND fill in shape (implies SIMPLE)
PyBUF_STRIDES fill in strides (implies ND)
PyBUF_C_CONTIGUOUS / _F_CONTIGUOUS / _ANY_CONTIGUOUS
PyBUF_INDIRECT fill in suboffsets — the exporter may be a pointer array
PyBUF_FULL STRIDES|WRITABLE|FORMAT|INDIRECT
PyBUF_FULL_RO same, read-only allowed
The flags are a negotiation. PyBUF_SIMPLE on a non-contiguous memoryview raises
BufferError — which is correct and is the point: you asked for a flat block and the
exporter cannot honestly give you one. Asking for more than you can handle is the bug;
asking for the minimum makes the interpreter reject bad input for you.
Strides, measured¶
Real output from pmx17.describe_buffer (measured):
>>> pmx17.describe_buffer(b"abcd")
{'len': 4, 'ndim': 1, 'format': 'B', 'itemsize': 1, 'readonly': True,
'shape': (4,), 'strides': (1,), 'suboffsets': None,
'c_contiguous': True, 'f_contiguous': True}
>>> m = memoryview(array.array('i', range(12))).cast('B').cast('i', (3, 4))
>>> pmx17.describe_buffer(m)
{'len': 48, 'ndim': 2, 'format': 'i', 'itemsize': 4, 'readonly': False,
'shape': (3, 4), 'strides': (16, 4), 'suboffsets': None,
'c_contiguous': True, 'f_contiguous': False}
>>> pmx17.describe_buffer(memoryview(array.array('i', range(12)))[::3])
{'len': 16, 'ndim': 1, 'format': 'i', 'itemsize': 4, 'readonly': False,
'shape': (4,), 'strides': (12,), 'suboffsets': None,
'c_contiguous': False, 'f_contiguous': False}
Read the third one carefully. [::3] on a 12-element int32 array produced a view with
len=16, shape=(4,), strides=(12,), and c_contiguous=False — and zero bytes
were copied. strides=(12,) means "advance 12 bytes to reach the next element" — three
int32s. That is the entire idea of a strided view:
underlying array (12 × int32 = 48 bytes)
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ 10 │ 11 │
└─▲──┴────┴────┴─▲──┴────┴────┴─▲──┴────┴────┴─▲──┴────┴────┘
│ │ │ │
└── stride 12 ─┴── stride 12 ─┴── stride 12 ─┘
view: buf = &array[0], shape = (4,), strides = (12,), len = 16
"4 elements of 4 bytes each, spaced 12 bytes apart"
Address of element i: buf + i*strides[0]
For an N-d view: buf + Σ(index[k] * strides[k])
And suboffsets — the field nobody uses and everybody should recognize — handles the
case where the data is not one block at all but a pointer-indirection array (the C
int ** layout, PIL's old row-pointer images). Where suboffsets[k] >= 0, you must
dereference the pointer you land on and then add the suboffset. It exists so that
protocols like NumPy's can talk to libraries that never had a flat layout. If your code
requests PyBUF_SIMPLE/PyBUF_ND/PyBUF_STRIDES you will never see a non-NULL
suboffsets, because the exporter must refuse rather than lie — only PyBUF_INDIRECT
opens that door.
Where this shows up¶
memoryviewis the buffer protocol wearing a Python costume. Slicing it is O(1).- NumPy is a buffer exporter and consumer;
np.frombufferandnp.asarrayof a buffer-exporting object are zero-copy.34-going-native.mdcovers strides, dtypes and views properly. - Arrow deliberately went further, defining its own C data interface rather than using PEP 3118, because PEP 3118 has no notion of nulls, nested types, or dictionary encoding. Knowing why Arrow didn't reuse it is a better answer than knowing that it didn't.
bytes(view)copies.viewdoes not. The most common accidental copy in Python data code is abytes()call on something that was already zero-copy.
The pinning contract¶
While a Py_buffer is outstanding, the exporter must not reallocate. This is enforced —
try it:
>>> ba = bytearray(b"hello")
>>> mv = memoryview(ba)
>>> ba.append(1)
BufferError: Existing exports of data: object cannot be re-sized
That is not politeness; it is the guarantee that lets §10 release the GIL and keep
dereferencing view.buf from another thread. A Py_buffer you forgot to release is a
bytearray that can never grow again — a real, and genuinely confusing, production bug
shape.
10. Releasing the GIL — the exact contract, measured¶
24-the-gil.md §3 established why the eval loop drops the GIL and what
_PyThreadState_Swap(NULL) protects. This section is the extension author's side of the
same protocol.
What the macro actually is¶
Verbatim from include/python3.14/ceval.h in this build:
#define Py_BEGIN_ALLOW_THREADS { \
PyThreadState *_save; \
_save = PyEval_SaveThread();
#define Py_BLOCK_THREADS PyEval_RestoreThread(_save);
#define Py_UNBLOCK_THREADS _save = PyEval_SaveThread();
#define Py_END_ALLOW_THREADS PyEval_RestoreThread(_save); \
}
Three things follow directly from those five lines:
- It opens a C block.
_saveis a local in a{...}scope.Py_END_ALLOW_THREADScloses the brace. Unbalanced macros are a syntax error, which is the one part of this contract the compiler does check. - You cannot
returnout of the middle. The header says so in capital letters. If you must bail early, insertPy_BLOCK_THREADSfirst — which is why that macro exists. PyEval_SaveThread()does two things: it releases the GIL and it detaches the thread state (setting the current thread state toNULL). Those must move together — see24-the-gil.md§3.4, where desynchronizing them producesPy_FatalError("tstate mix-up").
The header also warns: WARNING: NEVER NEST CALLS TO Py_BEGIN_ALLOW_THREADS AND
Py_END_ALLOW_THREADS!!! — because _save would shadow, and you'd restore the wrong
state.
The contract, stated as rules¶
Between Py_BEGIN_ALLOW_THREADS and Py_END_ALLOW_THREADS you promise:
- No
PyObject*is touched. Not read, not written, notPy_INCREFed. NotPy_None. Not a borrowed reference you're "just reading". APy_DECREFhere is a data race with every other thread. - No C API call, except the handful explicitly documented as GIL-free
(
PyMem_RawMalloc/RawFree,PyGILState_Ensure,PyEval_RestoreThread). - No exception is set or checked. The exception indicator lives in the thread state you just detached.
- Any pointer you dereference is pinned by something that outlives the block — a
Py_bufferexport (§9), amalloced copy, or memory you own outright.
And you gain: other Python threads run. On the GIL build that is the only way to get parallelism out of a CPU-bound native routine.
Measured — and the result is more interesting than expected¶
Same 64 MB FNV-1a hash, two entry points differing only by the macro pair. N threads, min
of 3 runs, speedup = (1-thread time × N) / N-thread time (measured, M3 Pro):
=========== GIL BUILD (python3.14) ===========
threads | RELEASES GIL speedup | HOLDS GIL speedup
1 | 0.075s 1.01x | 0.078s 0.94x
2 | 0.077s 1.95x | 0.149s 0.98x
4 | 0.078s 3.83x | 0.301s 0.98x
8 | 0.088s 6.83x | 0.607s 0.97x
=========== FREE-THREADED BUILD (python3.14t) ===========
threads | RELEASES GIL speedup | HOLDS GIL speedup
1 | 0.075s 0.96x | 0.077s 0.97x
2 | 0.081s 1.77x | 0.085s 1.76x
4 | 0.095s 3.02x | 0.079s 3.76x
8 | 0.091s 6.34x | 0.105s 5.67x
Three readings, in order of increasing interest:
1. The GIL build's "holds GIL" column is a flat line at 0.97×. Eight threads, eight cores available, and wall time grows exactly linearly: 0.078 → 0.149 → 0.301 → 0.607. That is the GIL, drawn from the inside. Two identical C loops; one macro pair; 7× the throughput.
2. The GIL build reaches 6.83× on 8 threads. Not 8×, because this is a 6P+6E core machine and the E-cores are slower — a detail that would be invisible on a homogeneous x86 box and is worth remembering when you benchmark on Apple Silicon.
3. On the free-threaded build, both columns scale. The "holds GIL" version reaches
5.67× on 8 threads — because there is no GIL to hold. Py_BEGIN_ALLOW_THREADS still
detaches the thread state (which the GC needs), but it no longer gates other threads.
That third point is the one worth internalizing: Py_BEGIN_ALLOW_THREADS is a
GIL-build-shaped optimization that becomes near-irrelevant to throughput on a free-threaded
build. It is not irrelevant to correctness — you still must release around blocking
calls so the stop-the-world cycle collector can run
(24-the-gil.md §8.5) — but the dramatic speedup was always a story
about the GIL, and the GIL is what's going away.
Foreign threads: PyGILState_Ensure¶
A thread created by C code (a pthread you spawned, an audio callback, a Qt worker, a Rust
std::thread) has no PyThreadState. Calling any C API from it is undefined behaviour.
void *worker(void *arg) /* a thread CPython has never seen */
{
PyGILState_STATE gstate = PyGILState_Ensure(); /* creates a tstate if needed */
/* ... full C API access here ... */
PyGILState_Release(gstate); /* must be LIFO with Ensure */
return NULL;
}
PyGILState_Ensureis reentrant and idempotent: it detects an existing thread state and reuses it. That's the whole reason the opaquePyGILState_STATEreturn value exists —Releaseneeds to know whether this call was the one that created the state.- Pairs must nest strictly (LIFO). Crossing them corrupts the thread-state stack.
- It still applies on the free-threaded build. The official HOWTO is explicit: "if you
create a thread outside of Python, you must call
PyGILState_Ensure()before calling into the Python API to ensure that the thread has a valid Python thread state." Removing the GIL did not remove the thread state. - It applies only to the main interpreter by default. With subinterpreters you want
PyThreadState_New/PyThreadState_Swapagainst the rightPyInterpreterState.
11. Free-threading rules for extensions¶
24-the-gil.md §9 made the claim: the genuinely new hazards live in C
extensions, not in Python-level code. This section is that claim, executed.
The opt-in¶
An extension is presumed GIL-requiring. You must say otherwise, and there are two ways:
/* Multi-phase (preferred) — a slot, readable BEFORE your code runs. */
static PyModuleDef_Slot slots[] = {
{Py_mod_exec, exec_fn},
{Py_mod_gil, Py_MOD_GIL_NOT_USED},
{0, NULL}
};
/* Single-phase (legacy) — a call, guarded, because the function only
* exists on the free-threaded build. */
PyMODINIT_FUNC PyInit_mymodule(void) {
PyObject *m = PyModule_Create(&moduledef);
if (m == NULL) return NULL;
#ifdef Py_GIL_DISABLED
PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
#endif
return m;
}
Note the PyUnstable_ prefix (PEP 689): this API is public, documented, and may change
without deprecation in 3.15. It is spelled that way on purpose so the ecosystem can grep
for it. The values are Py_MOD_GIL_USED ((void*)0, the default) and
Py_MOD_GIL_NOT_USED ((void*)1) — verified in include/python3.14/moduleobject.h.
The failure mode, measured¶
If you don't declare it, importing your module turns the GIL back on for the entire process, at runtime, silently except for a warning. Real output, free-threaded 3.14.6 (measured):
$ python3.14t gilslot.py
<frozen importlib._bootstrap>:491: RuntimeWarning: The global interpreter lock (GIL) has
been enabled to load module 'pmx17_legacy', which has not declared that it can run safely
without the GIL. To override this behavior and keep the GIL disabled (at your own risk),
run with PYTHON_GIL=0 or -Xgil=0.
startup : False
after import pmx17 : False (declares Py_MOD_GIL_NOT_USED)
after import legacy : True (no Py_mod_gil slot)
This is the single most important operational fact in free-threading rollout. One
transitive dependency — a logging handler, a JSON accelerator, a metrics client — that
hasn't declared the slot, and your carefully benchmarked free-threaded service is running
with the GIL on. It is a RuntimeWarning, which nobody's log aggregator alerts on.
Make it fail loudly instead:
$ python3.14t -W error::RuntimeWarning app.py
RuntimeWarning: The global interpreter lock (GIL) has been enabled to load module ...
Or assert sys._is_gil_enabled() is False at the end of startup. Do one of these in CI.
Why borrowed references get much more dangerous¶
Under the GIL, a borrowed reference is invalidated only if your own thread runs Python code (§4, Bug 1). That's a rule you can follow by inspection: look for calls that can re-enter the interpreter.
Without the GIL, another thread can free the object between your load and your use, with no call of yours in between.
/* Under the GIL: subtly wrong. Without it: a use-after-free with a race window. */
PyObject *item = PyList_GetItem(list, 0); /* borrowed */
/* <-- another thread: list.clear() */
Py_ssize_t n = PyList_Size(item); /* reads freed memory */
The replacement APIs return strong references. All of these are verified present in this build's headers:
| Borrowed (unsafe under concurrency) | Strong-reference replacement | Since |
|---|---|---|
PyList_GetItem, PyList_GET_ITEM |
PyList_GetItemRef |
3.13 |
PyDict_GetItem, PyDict_GetItemWithError |
PyDict_GetItemRef |
3.13 |
PyDict_GetItemString |
PyDict_GetItemStringRef |
3.13 |
PyDict_SetDefault |
PyDict_SetDefaultRef |
3.13 |
PyObject_GetAttr (raising on missing) |
PyObject_GetOptionalAttr |
3.13 |
PyObject_GetAttrString |
PyObject_GetOptionalAttrString |
3.13 |
PyImport_AddModule |
PyImport_AddModuleRef |
3.13 |
PyWeakref_GetObject |
PyWeakref_GetRef |
3.13 |
(PyDict_GetItemRef, PyList_GetItemRef, PyDict_GetItemStringRef,
PyDict_SetDefaultRef, PyObject_GetOptionalAttr and PyObject_GetOptionalAttrString
were each confirmed by grepping this build's include/python3.14/ headers — see §17.)
Note the *_GetItemRef signature change: they return int (1 found, 0 not found,
-1 error) and write the object through an out-parameter. That is deliberate — it removes
the old "NULL might mean not-found or might mean error, call PyErr_Occurred to tell"
ambiguity from §5 at the same time.
Critical sections¶
Py_BEGIN_CRITICAL_SECTION(op) locks op's per-object mutex — the PyMutex ob_mutex
field you can see in the free-threaded struct _object in §2. From
include/python3.14/cpython/critical_section.h, verbatim:
/* On the free-threaded build: */
#define Py_BEGIN_CRITICAL_SECTION(op) \
{ \
PyCriticalSection _py_cs; \
PyCriticalSection_Begin(&_py_cs, _PyObject_CAST(op))
#define Py_END_CRITICAL_SECTION() \
PyCriticalSection_End(&_py_cs); \
}
/* On the GIL build (Py_GIL_DISABLED undefined): */
#define Py_BEGIN_CRITICAL_SECTION(op) {
#define Py_END_CRITICAL_SECTION() }
Literally a bare brace pair on the GIL build. Zero cost, zero risk to add. There is a
two-object form Py_BEGIN_CRITICAL_SECTION2(a, b) for operations touching two containers
(the implementation handles deadlock avoidance, so argument order doesn't affect
correctness), and *_MUTEX variants that take a PyMutex* directly.
Four rules from the official HOWTO that surprise people:
- Critical sections may be temporarily suspended. If code inside blocks — acquires another lock, does I/O, calls back into Python — all critical-section locks held by the thread are released. Entering one does not give you exclusive access for the section's duration. Reload anything you cached across such a call.
- Only the top-most (most recently entered) critical section's lock is guaranteed held. Outer nested ones may be suspended.
- At most two objects. Need three? Restructure.
- Re-locking the same object won't deadlock, but it's less efficient than a purpose-built reentrant lock.
Measured: what the critical section actually buys¶
Eight threads × 20,000 calls × 64 bytes = 10,240,000 expected bytes, three runs (measured):
=========== GIL BUILD ===========
gil_enabled=True expected nbytes = 10,240,000
update run 0: 10,240,000 OK
update run 1: 10,240,000 OK
update run 2: 10,240,000 OK
update_unlocked run 0: 10,240,000 OK ← the GIL was the lock
update_unlocked run 1: 10,240,000 OK
update_unlocked run 2: 10,240,000 OK
=========== FREE-THREADED BUILD ===========
gil_enabled=False expected nbytes = 10,240,000
update run 0: 10,240,000 OK
update run 1: 10,240,000 OK
update run 2: 10,240,000 OK
update_unlocked run 0: 9,932,352 LOST 307,648 bytes (3.0%)
update_unlocked run 1: 9,722,880 LOST 517,120 bytes (5.0%)
update_unlocked run 2: 9,080,640 LOST 1,159,360 bytes (11.3%)
That is the whole migration risk in one table. The unlocked code is 100% correct on the
GIL build, three times out of three — because acc->nbytes += view.len happened inside a
single C call, and the GIL made every C call atomic with respect to other Python threads.
On the free-threaded build the same code silently loses 3–11% of its data, non-deterministically,
with no error, no warning, and no crash. It does not fail; it lies.
This is why the migration risk lives in extensions. Your Python code's races were already races. Your C code's non-races just became races, because the thing that made them safe was an implementation detail of the interpreter.
Allocation domains got stricter¶
16-object-memory-layout.md §4 called mixing the three
domains "undefined behaviour" and a best practice. On the free-threaded build the official
HOWTO upgrades it to a hard requirement: only Python objects may be allocated with the
object domain, and all Python objects must be. Use PyMem_Malloc for buffers, never
PyObject_Malloc. §14 shows how to catch violations.
Other free-threading hazards¶
staticcaches inside functions.static PyObject *cached = NULL; if (!cached) cached = ...;is now a race. Move it to module state (§8) or usePyMutex/std::call_once.- Free lists and object pools in your extension need locking or thread-local storage.
tp_dealloccan now run concurrently with other threads touching adjacent objects.- Wheel tags. Free-threaded wheels are
cp314t, and you need a separate build. On 3.14 you cannot use the Limited API there at all (§2); on 3.15 you can, viaabi3t(§3).
12. Calling protocols: tp_call, vectorcall, METH_FASTCALL¶
The layers¶
f(a, b, key=c)
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ PyObject_Call(callable, args_tuple, kwargs_dict) — the OLD path │
│ builds a tuple. builds a dict. calls tp_call. │
└───────────────────────────────┬────────────────────────────────────────┘
│ tp_call(self, args, kwargs)
▼
┌────────────────────────────────────────────────────────────────────────┐
│ VECTORCALL (PEP 590, 3.8) │
│ PyObject_Vectorcall(callable, PyObject *const *args, │
│ size_t nargsf, PyObject *kwnames) │
│ │
│ args ──▶ [ self? ][ a ][ b ][ c ] a flat C array on the C stack │
│ └─ kwnames = ("key",) a tuple of NAMES │
│ nargsf = npositional | PY_VECTORCALL_ARGUMENTS_OFFSET │
│ │
│ NO TUPLE. NO DICT. Zero allocations for the common call. │
│ The callable opts in with Py_TPFLAGS_HAVE_VECTORCALL + tp_vectorcall_ │
│ offset pointing at a `vectorcallfunc` field in the instance. │
└───────────────────────────────┬────────────────────────────────────────┘
│ and at the METH_* level:
▼
METH_NOARGS f(self, NULL) — no args at all
METH_O f(self, PyObject *arg) — exactly one, unparsed
METH_FASTCALL f(self, PyObject *const *args, Py_ssize_t nargs)
METH_FASTCALL|METH_KEYWORDS
f(self, PyObject *const *args, Py_ssize_t nargs,
PyObject *kwnames)
METH_METHOD|METH_FASTCALL|METH_KEYWORDS (PyCMethod, PEP 573)
f(self, PyTypeObject *defining_class, args, nargs, kwnames)
METH_VARARGS f(self, PyObject *args_tuple) — a tuple IS built
METH_VARARGS|METH_KEYWORDS
f(self, PyObject *args, PyObject *kwargs) — tuple AND dict
PY_VECTORCALL_ARGUMENTS_OFFSET is the clever bit: the caller may set that bit to promise
that args[-1] is writable, so a bound-method call can prepend self in place rather
than copying the array. Free performance for method calls, at the cost of one flag.
Measured — the cost of each convention¶
Five trivial C functions (all return None), one Python function, min of 7 × 3,000,000
calls, GIL build (measured):
| Call form | ns/call | vs METH_O |
|---|---|---|
METH_O f(x) |
7.3 | 1.00× |
METH_NOARGS f() |
8.9 | 1.22× |
METH_FASTCALL f(x) |
9.2 | 1.26× |
pure Python def f(a): return None |
11.0 | 1.51× |
METH_VARARGS f(x) + PyArg_ParseTuple |
20.5 | 2.81× |
METH_VARARGS+METH_KEYWORDS f(x) |
23.0 | 3.15× |
METH_VARARGS+METH_KEYWORDS f(a=x) |
45.9 | 6.29× |
Four things worth extracting:
METH_VARARGScosts 2.8×METH_Ofor a one-argument function that does nothing. The tuple allocation plusPyArg_ParseTuple's format-string interpretation is ~13 ns of pure overhead per call. If your C function is called in a tight loop and takes one argument,METH_Ois free money.- Passing that argument by keyword costs 6.3×. 45.9 ns to call a function that
returns
None. Keyword arguments at a C boundary are genuinely expensive when the callee uses the old convention. - A pure-Python function (11.0 ns) beats
METH_VARARGS(20.5 ns). Read that twice. "Rewrite it in C" is not automatically faster if you keep the 1990s calling convention. This is the single most useful number in this section. METH_FASTCALLis slower thanMETH_Ohere (9.2 vs 7.3) — but that is an artifact of my microbenchmark: my FASTCALL function checksnargs != 1andMETH_Ochecks nothing. FASTCALL's win appears with two or more arguments, whereMETH_Oisn't available and the alternative isMETH_VARARGS. Don't over-read a 2 ns delta on a do-nothing function.
Argument Clinic¶
Writing METH_FASTCALL|METH_KEYWORDS argument parsing by hand is miserable and
error-prone. Argument Clinic is CPython's own preprocessor for exactly this: you write a
declarative docstring-shaped block in your .c file, run Tools/clinic/clinic.py, and it
generates the parsing code, the docstring, the signature (so inspect.signature works on
your C function), and the PyMethodDef entry.
/*[clinic input]
pmx17.checksum
buf: Py_buffer
/
Compute FNV-1a 64 over a buffer.
[clinic start generated code]*/
It is officially an internal CPython tool — the devguide says so — and its output pins you to a CPython version's conventions. But it is the standard answer inside CPython and in several large extensions, and reading its generated code is the fastest way to learn what optimal argument parsing looks like. If you are writing an extension by hand with more than a few functions taking keywords, use it or use a binding generator (§13).
13. The binding-generator landscape, compared honestly¶
All ecosystem claims below are dated. Verify before relying on any of them. Versions and dates were pulled from the projects' own changelogs and from the GitHub/PyPI APIs on 2026-08-02.
| Raw C API | Cython | pybind11 | nanobind | PyO3 | ctypes | cffi | HPy | |
|---|---|---|---|---|---|---|---|---|
| What it is | you write CPython C | Python-like lang → C | C++11 header-only | C++17 header-only | Rust proc-macros | stdlib FFI, no build | FFI, ABI or API mode | alternative C API |
| Language | C | Cython | C++ | C++ | Rust | Python | Python + C decls | C |
| Build complexity | low (one clang) |
medium (.pyx → C) |
medium (C++ toolchain) | medium (C++17 + CMake) | high (cargo + maturin) | none | low–medium | low |
| Compile time | fast | medium | slow (heavy templates) | ~4× faster than pybind11¹ | slow (Rust) | n/a | fast | fast |
| Binary size | smallest | medium | large | ~5× smaller than pybind11¹ | large (Rust std) | n/a | small | small |
| Runtime overhead at boundary | zero (it is the API) | very low | ~10× nanobind¹ | very low | very low | high (per-call marshalling) | medium | low on CPython |
abi3 support |
yes, by hand | yes (3.0+, limited_api) |
yes (py_limited_api) |
yes (stable-ABI builds) | yes (abi3-py3xx) |
n/a (no build) | yes (API mode) | ABI-stable by design |
abi3t (PEP 803) |
3.15, Py_TARGET_ABI3T |
not yet² | not yet² | not yet² | yes — 0.29.0 (2026-06-11) | n/a | yes — 2.1.0 (2026-07-06) | no |
| Free-threading | you do the work | 3.1.0 (2025-05-08), freethreading_compatible |
2.13.0 (2024-06-25), py::mod_gil_not_used() |
2.2.0 (2024-10-03) | 0.23.0 (2024-11-15), #[pymodule(gil_used=false)]; opt-out since 0.28.0 (2026-02-01) |
n/a | 2.0.0 | no |
| Critical sections | Py_BEGIN_CRITICAL_SECTION |
yes (3.1+ primitives) | py::scoped_critical_section (3.0.0, 2025-07-10) |
nb::ft_mutex / ft_lock_guard³ |
PyList::locked_for_each, etc. |
n/a | no | no |
| Subinterpreters | Py_mod_multiple_interpreters |
yes | 3.0.0 py::multiple_interpreters::per_interpreter_gil() |
partial³ | partial³ | n/a | no | no |
| Debuggability | best — it's your code | good (annotated C, cython -a) |
poor (template soup in gdb) | medium | good (Rust backtraces) | poor (segfaults w/ no info) | medium | medium |
| Memory safety | none | none | none (C++) | none (C++) | Rust's | none | none | handles, not raw ptrs |
| Last release | — | 3.2.9 (2026-07-24) | 3.0.4 (2026-04-19) | 2.13.0 (2026-06-18) | 0.29.0 (2026-06-11) | stdlib | 2.1.0 (2026-07-06) | 0.9.0 (2023-09-22) |
| Last commit | — | 2026-08-01 | 2026-08-01 | 2026-07-26 | 2026-08-01 | — | 2026-07-26 | 2025-05-26 |
¹ nanobind's own published benchmark headline: "bindings compile up to ~4× faster and produce ~5× smaller binaries with ~10× lower runtime overheads compared to pybind11", and vs Cython "3–12× binary size reduction, 1.6–4× compilation time reduction, similar runtime performance." These are vendor-reported numbers on nanobind's own microbenchmark. They are plausible and widely corroborated in direction, but I did not reproduce them.
² "Not yet" as of 2026-08-02 means I found no abi3t entry in the project's changelog. The
3.15 abi3t migration HOWTO itself says: "If your extension uses a code generator (like
Cython) or language binding (like PyO3), it's best to wait until that tool has support."
Python 3.15 is still pre-release (rc1), so this will move quickly.
³ Flagged as partial/unverified — see §17.
Is HPy still active? — the honest answer¶
No, not meaningfully. HPy's premise was excellent: replace raw PyObject* with opaque
handles (HPy), so that the API makes no promise about object identity, layout, or
refcounting. That would have made CPython free to move objects, and would have let PyPy and
GraalPy run extensions at native speed instead of emulating CPython's refcounting.
The numbers as of 2026-08-02 (measured, via the GitHub and PyPI APIs):
- Latest release: 0.9.0, published 2023-09-22 — nearly three years old.
- Last commit to
master: 2025-05-26 — over 14 months ago. - 1,139 stars. Repository not archived, but not moving either.
HPy's website still describes the project as "under active development... working hard towards a stable release", which as of Aug 2026 the commit history does not support. Treat the site copy as stale.
Why it stalled, and why it matters anyway. HPy required every extension to be ported,
with no incremental path and no immediate payoff on CPython. Meanwhile CPython absorbed most
of HPy's good ideas into the mainline API on an incremental path: opaque types (PEP 697),
per-module state (PEP 573), heap types everywhere, PyModExport (PEP 793), and abi3t
(PEP 803) — which delivers HPy's central promise, "one binary across interpreter
configurations", without a rewrite. HPy lost by being right too early and too expensively.
That is a genuinely useful lesson about API migrations at ecosystem scale.
How to choose¶
ctypes: for calling an existing shared library a handful of times. Zero build. Per call it is slow (it marshals argument types at runtime) and every mistake in anargtypes/restypedeclaration is a silent segfault. Great for a one-off; never for a hot path.cffi:ctypesdone properly. ABI mode parses C declarations at runtime (no compiler); API mode generates and compiles a real extension, which is faster and type-checked by the C compiler. Preferred whenever you're wrapping a C library rather than writing new native code. Was PyPy's recommended path.- Cython: the right answer when you have Python code that needs to be fast and
gradually typed, and when you're already in the scientific stack.
cython -aproducing annotated HTML that shows exactly which lines still touch the C API is a debugging superpower no other tool here matches. - pybind11: the right answer when you have an existing C++ library to expose and you want the largest community and the most Stack Overflow answers. Pay for it in compile time and binary size.
- nanobind: pybind11's author-adjacent successor for the same job, when compile time and binary size matter and you can require C++17. The migration is real work but mostly mechanical.
- PyO3: the right answer for new native code where memory safety is worth a build-system
step change. It is also the most aggressive about free-threading — free-threaded support
became opt-out in 0.28.0, and
Python::with_gilwas renamedPython::attach, which is a nicely honest acknowledgement that "the GIL" is no longer the thing you're acquiring. - Raw C API: when you need total control, minimum size, no toolchain dependency, or you are writing something CPython-version-specific. And — as this document argues — when you need to understand what every other tool on this list is generating.
14. Debugging native extensions¶
Why a segfault means "a refcount bug three frames back"¶
Here is a real, reproduced example. badmem.over_decref(x) does exactly one wrong thing:
Py_DECREF on a borrowed argument. The script decrefs 200 lists, prints, allocates some
garbage, then sums the lists' lengths.
import badmem
big = [[i] for i in range(200)]
for x in big:
badmem.over_decref(x) # each list's refcount is now 1 too low
print("no crash yet -- the objects are still 'alive'", flush=True)
for i in range(200):
junk = [object() for _ in range(2000)] # churn the allocator
print("total:", sum(len(x) for x in big))
$ python3.14 crash.py
no crash yet -- the objects are still 'alive'
[1] 60912 segmentation fault python3.14 crash.py
$ echo $?
139
And the backtrace (measured, real lldb output):
$ lldb -b -o "run crash.py" -o "bt 12" -- ~/.local/bin/python3.14
Process 60912 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
frame #0: 0x00000001008d9b60 python3.14`list_dealloc + 56
(lldb) bt 12
* thread #1, stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
* frame #0: python3.14`list_dealloc + 56
frame #1: python3.14`_TAIL_CALL_STORE_FAST + 168
frame #2: python3.14`gen_iternext + 316
frame #3: python3.14`builtin_sum + 964
frame #4: python3.14`_TAIL_CALL_CALL + 320
frame #5: python3.14`_PyEval_Vector + 780
frame #6: python3.14`PyEval_EvalCode + 160
frame #7: python3.14`run_mod + 292
frame #8: python3.14`pyrun_file + 164
frame #9: python3.14`_PyRun_SimpleFileObject + 256
frame #10: python3.14`_PyRun_AnyFileObject + 80
frame #11: python3.14`pymain_run_file_obj + 164
badmem does not appear anywhere in that stack. The crash is in list_dealloc, called
from builtin_sum, on the last line of the script — hundreds of thousands of allocations
after the actual bug. The stack tells you truthfully where the program died and nothing at
all about why.
That is the shape of essentially every refcount bug:
the BUG the DAMAGE the CRASH
─────── ────────── ─────────
one extra Py_DECREF → refcount hits 0 early → some later code
in your function object is freed and dereferences the
(frame you never see) its memory reused reused memory
(the stack you get)
The one free clue CPython gives you is in the fatal-error report:
CPython lists loaded extension modules on fatal errors and in faulthandler output,
precisely because the culprit is usually one of them. If your crash report names three
extensions, those are your three suspects.
(Incidental bonus from that trace: _TAIL_CALL_STORE_FAST and _TAIL_CALL_CALL are
3.14's tail-calling interpreter — see 20-eval-loop.md.)
PYTHONMALLOC=debug — the highest-value tool in this document¶
16-object-memory-layout.md §4 introduced the three
allocation domains and promised this section. Here it is, working.
I wrote badmem.c with three deliberate bugs: PyMem_Malloc freed with PyObject_Free,
PyMem_RawMalloc freed with PyMem_Free, and a one-byte buffer overrun. First, the
default build (measured):
$ python3.14 -c "import badmem; badmem.mismatch(); badmem.mismatch_raw(); badmem.overrun(); print('...')"
...all three bugs: no complaint, exit 0
Three memory bugs, clean exit. Now flip one environment variable:
$ PYTHONMALLOC=debug python3.14 -c "import badmem; badmem.mismatch()"
Debug memory block at address p=0x107d6fca0: API 'm'
64 bytes originally requested
The 7 pad bytes at p-7 are FORBIDDENBYTE, as expected.
The 8 pad bytes at tail=0x107d6fce0 are FORBIDDENBYTE, as expected.
Data at p: cd cd cd cd cd cd cd cd ... cd cd cd cd cd cd cd cd
Enable tracemalloc to get the memory block allocation traceback
Fatal Python error: _PyMem_DebugRawFree: bad ID: Allocated using API 'm', verified using API 'o'
Python runtime state: initialized
Current thread 0x00000001f13bdd80 (most recent call first):
File "<string>", line 1 in <module>
Extension modules: badmem (total: 1)
Read bad ID: Allocated using API 'm', verified using API 'o'. The debug allocator stores a
one-byte API identifier in the header of every block — 'r' raw, 'm' mem, 'o'
object — and checks it at free time. Domain mismatch, caught at the exact instruction,
with a Python-level traceback.
The overrun is caught the same way, by guard bytes (measured):
$ PYTHONMALLOC=debug python3.14 -c "import badmem; badmem.overrun()"
Debug memory block at address p=0x103530bb0: API 'm'
16 bytes originally requested
The 7 pad bytes at p-7 are FORBIDDENBYTE, as expected.
The 8 pad bytes at tail=0x103530bc0 are not all FORBIDDENBYTE (0xfd):
at tail+0: 0x58 *** OUCH
at tail+1: 0xfd
...
Data at p: cd cd cd cd cd cd cd cd cd cd cd cd cd cd cd cd
0x58 is 'X' — the byte I wrote one past the end — sitting in the 0xfd guard region.
The debug allocator's byte patterns are worth memorizing because you will see them in
crashes:
| Pattern | Meaning |
|---|---|
0xcd (CLEANBYTE) |
freshly allocated, never written. Seeing it in "real" data = uninitialized read |
0xdd (DEADBYTE) |
freed memory. Seeing it = use-after-free |
0xfd (FORBIDDENBYTE) |
guard padding before/after the block. Modified = overrun/underrun |
And add PYTHONTRACEMALLOC=5 to get the allocation site, not just the free site
(measured):
$ PYTHONMALLOC=debug PYTHONTRACEMALLOC=5 python3.14 -c "import badmem; badmem.overrun()"
...
Memory block allocated at (most recent call first):
File "<string>", line 1
Run your extension's test suite under PYTHONMALLOC=debug PYTHONTRACEMALLOC=5 in CI.
It costs a couple of seconds and it converts a class of bug that manifests as
"segfault next Tuesday" into "test failure with a line number".
The rest of the toolbox¶
A debug build of CPython (--with-pydebug) is the heavier hammer. It gives you:
Py_REF_DEBUG (a process-wide total refcount, so sys.gettotalrefcount() before and after
a loop detects leaks of any object), Py_TRACE_REFS (a linked list of all live objects),
assertions throughout the interpreter, and PYTHONMALLOC=debug on by default. It is
roughly 2–3× slower and ABI-incompatible with release builds — extensions must be
rebuilt against it (the d ABI flag). See 13-cpython-source-map.md
for building one.
The leak test it enables is worth writing down:
import sys
def leaks(fn, warmup=5, n=1000):
for _ in range(warmup): fn() # let caches settle
before = sys.gettotalrefcount()
for _ in range(n): fn()
return sys.gettotalrefcount() - before # should be ~0, not n
If the delta is proportional to n, you leak one reference per call. sys.gettotalrefcount
exists only on debug builds.
Sanitizers. ASan catches use-after-free, heap overflow, and leaks with a real allocation-site backtrace; UBSan catches alignment, integer overflow, and invalid casts.
$ clang -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 ... -o mymod...so mymod.c
$ ASAN_OPTIONS=detect_leaks=1 python3.14 -X faulthandler test.py
Two practical warnings. First, you get a wall of false positives from CPython itself unless
you use a suppression file — CPython ships one at Misc/ASAN.supp for exactly this reason,
and you generally want PYTHONMALLOC=malloc so ASan sees individual allocations instead of
pymalloc's arenas. Second, LeakSanitizer does not work on macOS/arm64; detect_leaks is
a Linux-only feature in practice. This is one of the places you need a Linux box (or a
container) even if you develop on a Mac. Use ASan/UBSan on macOS for memory errors, and a
Linux CI job for leak detection.
lldb, not gdb, on macOS. gdb requires code-signing gymnastics on macOS and is effectively unsupported on arm64. lldb is what ships with the Command Line Tools and what the trace above came from. Useful invocations:
$ lldb -- python3.14 crash.py # then: run / bt / frame variable / p *(PyObject*)0x...
$ lldb -p <pid> # attach to a hung process
$ lldb -b -o "run x.py" -o "bt 20" -- python3.14 # batch, for CI
CPython ships Tools/gdb/libpython.py, which teaches gdb to print PyObject*s and Python
frames. There is no equally maintained lldb equivalent; the community lldb_libpython
scripts exist but are patchy. Practically: debug native crashes with lldb on macOS, and do
your Python-level stack inspection with faulthandler or py-spy dump instead.
faulthandler. Free, always available, no rebuild:
On segfault it prints the Python stack. That is exactly the information lldb can't give you, and combining the two — lldb for the C frames, faulthandler for the Python frames — is usually enough to localize a crash.
Checklist for a native crash you cannot explain:
-X faulthandler→ which Python line?PYTHONMALLOC=debug PYTHONTRACEMALLOC=5→ domain error? overrun? use-after-free?- lldb
bt→ which C function? Is it a*_dealloc? (If yes: refcount bug, look for who decrefs that type.) gc.collect()/gc.disable()around the suspect region → does GC timing change it? (If yes:tp_traverseis wrong.)- Debug build +
sys.gettotalrefcount()delta per call → which function leaks or over-frees? - ASan on Linux → the allocation-site backtrace you actually wanted at step 3.
- Only then: read your code.
15. Lab exercises¶
Reading this leaves you at rung 3 on README §14's ladder — fluent, and one "why?" from collapse. Every lab below is designed to move one specific claim from rung 3 to rung 4 (built or broken it, and measured). Labs 4, 6 and 7 are the ones that reach rung 5, because they force you to predict before you measure and to say where your model stops.
1 — Build the module, then break it three ways. Type pmx17.c in (don't paste it —
typing it is the point) and build it for both interpreters. Then introduce, one at a time:
(a) delete the Py_DECREF(tp) from Accumulator_dealloc; (b) delete the
PyBuffer_Release from pmx_checksum; © change PyModule_AddObjectRef to
PyModule_AddObject without adding the compensating Py_DECREF. For each: write down
what you predict will happen, then find an experiment that detects it. (a) needs
sys.gettotalrefcount or a growing gc.get_objects(); (b) shows up as
BufferError: Existing exports of data on a bytearray; © is a slow leak. Proves §4
and §7 — that these are not style rules.
2 — Find your Limited API wall. Compile pmx17.c with -DPy_LIMITED_API at 3.10,
3.11, 3.12, 3.13. Tabulate the error count at each floor and attribute every distinct
error to one of §2's three constraints (macro dereference / opaque struct / not-in-the-subset).
Then actually port one function — pmx_checksum — to compile clean at 3.11, and diff the
before/after. Proves §2, and it is the fastest way to internalize what "Limited" means.
3 — Reproduce the abi3-on-free-threaded failure. Build an abi3 module, confirm it
imports on python3.14, confirm python3.14t lists .abi3.so in EXTENSION_SUFFIXES,
and confirm it fails with SystemError. Then compile the three-line program from §2 that
prints sizeof(PyObject) and sizeof(PyModuleDef) against both header sets, and write one
paragraph explaining the failure from the numbers. Proves §2–§3, and gives you the
one-paragraph answer to "what is PEP 803 for?"
4 — The GIL-release table, on your hardware. Build both checksum variants and
reproduce §10's 2×2 table. Predict all sixteen numbers before running it. You will
probably get the GIL-build column right and the free-threaded "holds GIL" column wrong —
that is the point. Then explain why 8 threads gives 6.83× and not 8× on your machine.
Proves §10; the prediction step is what makes it rung 5.
5 — Break the critical section. Reproduce §11's data-loss table. Then vary: chunk size
(8 bytes vs 64 KB), thread count, and iteration count, and find the regime where
update_unlocked looks correct. Now explain why "I tested it and it worked" is not
evidence. Proves §11, and it is the single most important lab in this document for anyone
about to ship a free-threaded extension.
6 — Audit a real dependency. Pick an installed extension module you depend on
(_ssl, _json, orjson, numpy.core._multiarray_umath, a database driver). Determine,
without reading its docs: (a) does it declare Py_mod_gil? (b) does it support
subinterpreters? © is it abi3? Use python3.14t -W error::RuntimeWarning -c "import X",
concurrent.interpreters, and the filename. Write down the method you used — that method
is the deliverable, not the answer. Proves §8 and §11 apply to code you didn't write.
7 — Debug a crash you didn't write. Have someone else (or a script) insert exactly one
refcount error into a copy of pmx17.c — an extra decref, a missing incref, a missing
Py_VISIT, or a wrong allocation domain. Find it using §14's checklist, in order, and
record which step actually localized it. Do this three times with different bugs.
Proves §14 and §4, and it is the closest thing to the real experience of owning a native
extension in production.
8 — Calling conventions on your data. Reproduce §12's table, then extend it: add a
2-argument and a 5-argument function in METH_VARARGS and METH_FASTCALL, and find the
argument count at which FASTCALL clearly wins. Then take one hot METH_VARARGS function
from a real project and estimate, from your numbers, what converting it would buy at your
call rate. Proves §12, and produces the argument you'd actually need to justify the work.
9 — Zero-copy end to end. Extend describe_buffer to also report the address of
view.buf. Then show that numpy.frombuffer(b), memoryview(b)[::2], and
array.array('i', ...) all hand your C code pointers into the same allocation, and that
bytes(view) does not. Then find one place in a codebase you own where a copy is happening
that didn't need to. Proves §9 is a production technique, not trivia.
10 — Port it to a binding generator. Reimplement pmx17 in one of Cython, pybind11,
nanobind, or PyO3. Measure: lines of code, cold compile time, stripped binary size, ns/call
for checksum(b""), and whether it declares Py_mod_gil by default. Compare against the
raw-C numbers. Proves §13 — and one honest measured row beats the whole table above.
16. Question bank¶
Staff-level. If you can't answer from your own model, the section to reread is noted.
- Limited API, Stable ABI,
abi3,abi3t,PyUnstable_, internal API — define all six and say which one you'd ship a wheel against and why. (§2, §3) - Free-threaded 3.14 lists
.abi3.soinEXTENSION_SUFFIXESbut cannot load one. Explain the mechanism, from the object layout up. (§2) - What does PEP 803 change about wheel tags, and what does PEP 793 have to do with it? (§3)
- A function returns a
PyObject*. Name the three ownership contracts it could be under and say how you'd determine which, given only the docs. (§4) - Why is
Py_CLEAR(self->x)not the same asPy_XDECREF(self->x); self->x = NULL;? Construct the crash. (§4) PyModule_AddObjectvsPyModule_AddObjectRef— what is the difference and why is the first one deprecated? (§4)- Your C function returns
NULL. What else must be true, and what happens if it isn't? Now the reverse case. (§5) - Implement
raise MyError from originalin C, and say which calls steal references. (§5) - Why is a static
PyTypeObjectnow the wrong default? Give the reason that has nothing to do with subinterpreters. (§7, §2) - What does an instance of a heap type own that an instance of a static type does not, and what happens if
tp_deallocforgets it? (§7) - Single-phase vs multi-phase init: what can the interpreter do in one and not the other, and why does
Py_mod_gildepend on it? (§8) - Your module has
static PyObject *cache = NULL;. Name three separate things that breaks. (§8, §11) - Why is it safe to dereference
view.bufafterPy_BEGIN_ALLOW_THREADS, and what exactly guarantees it? (§9, §10) - A
memoryviewslice reportsstrides=(12,)andc_contiguous=False. What is the underlying data, and how do you compute element i's address? What aresuboffsetsfor? (§9) - Write out what
Py_BEGIN_ALLOW_THREADSexpands to and derive three rules from the expansion alone. (§10) - Same C loop, one macro pair difference: 0.088s vs 0.607s at 8 threads on the GIL build, but 0.091s vs 0.105s on the free-threaded build. Explain both columns. (§10)
- When must you call
PyGILState_Ensure, and does that change on a free-threaded build? (§10) - Code that is correct 3/3 runs on the GIL build loses 3–11% of its data on the free-threaded build with no error. What is the bug class, and why does the GIL hide it? (§11)
- Why do borrowed references become qualitatively more dangerous without the GIL, and name the strong-reference replacements for
PyDict_GetItemandPyList_GetItem. (§11, §4) - Entering a critical section does not guarantee exclusive access for its duration. Why not, and what does that mean for values you cached before a blocking call? (§11)
- A pure-Python function costs 11 ns/call; a C function with
METH_VARARGScosts 20.5 ns. Explain, and say what you'd change. (§12) - What does
PY_VECTORCALL_ARGUMENTS_OFFSETbuy, and who benefits? (§12) - HPy solved a real problem and is effectively dormant. What was the problem, what solved it instead, and what is the transferable lesson? (§13)
- Your extension segfaults in
list_deallocinsidebuiltin_sum. Where is the bug, and what is your first diagnostic step? (§14) PYTHONMALLOC=debugreportsbad ID: Allocated using API 'm', verified using API 'o'. Translate, and say what the fix is. (§14,16-object-memory-layout.md§4)- You see
0xddbytes in a struct field. What happened? What about0xcd? (§14) - You develop on macOS/arm64 and need leak detection on your extension. What's your plan? (§14)
17. Sources¶
Primary — the C API itself
- C API Reference — the whole thing. Read the ownership annotation on every function you call; it is there, and it is the contract. Verdict: this is the only authority; everything else, including this document, is commentary.
- Extending and Embedding the Python Interpreter — the official tutorial. Verdict: good for shape, dated in places on heap types; prefer the how-to guides below.
- Defining Extension Modules (3.15) and c-api/module.html (3.15) — the canonical home of PEP 793's PyModExport_*, Py_mod_name, Py_mod_token, Py_mod_methods, Py_mod_state_size, PyModule_FromSlotsAndSpec. Verdict: read this before writing any new module targeting 3.15.
- The headers in your own build: Include/object.h, Include/refcount.h, Include/ceval.h, Include/moduleobject.h, Include/cpython/critical_section.h, Include/cpython/lock.h. Verdict: the ground truth. Every macro expansion and struct layout in this document came from grepping these, not from memory.
Primary — free-threading
- C API Extension Support for Free Threading (official HOWTO) — Py_mod_gil, critical sections, the borrowed→strong replacement table, the allocation-domain hardening. Verdict: short, dense, and mandatory before touching a free-threaded extension. Source of the "critical sections may be suspended" rules in §11.
- Python support for free threading — the user-facing companion. Verdict: read for the overhead numbers.
- PEP 703 — Making the GIL Optional §Backwards Compatibility. Verdict: read alongside 24-the-gil.md §8.
- Python Free-Threading Guide and its compatibility tracker — community-maintained ecosystem status. Verdict: the right place to check live package status; I did not transcribe its contents here because they change weekly.
PEPs — all headers verified against peps.python.org on 2026-08-02
- PEP 384 — Defining a Stable ABI — von Löwis, Final, 3.2. Verdict: short; read the Rationale for why the boundary is drawn where it is.
- PEP 489 — Multi-phase extension module initialization — Viktorin/Behnel/Coghlan, Final, 3.5. Verdict: the single most important PEP in this document.
- PEP 573 — Module State Access from C Extension Methods — Viktorin/Coghlan/Snow/Plch, Final, 3.9. Verdict: read the Motivation; it explains why Py_TYPE(self) is the wrong answer.
- PEP 590 — Vectorcall — Shannon/Demeyer, Final, 3.8. Verdict: read §Specification for the nargsf bit trick.
- PEP 3118 — Revising the buffer protocol — Oliphant/Banks, Final, 3.0. Verdict: long and NumPy-flavoured; skim the struct and the flag table, skip the format-string grammar until you need it.
- PEP 630 — Isolating Extension Modules — Viktorin, Informational, Final. Verdict: the practical how-to for §8's whole migration. Start here, not at PEP 489.
- PEP 689 — Unstable C API tier — Viktorin, Final, 3.12. Verdict: two pages; explains the PyUnstable_ prefix.
- PEP 697 — Limited C API for Extending Opaque Types — Viktorin, Final, 3.12. Verdict: prerequisite for understanding abi3t.
- PEP 793 — new module export hook — Viktorin, Final, 3.15, resolved 23-Oct-2025. Verdict: read Background & Motivation even if you never write a PyModExport_*; it is the clearest existing statement of why static PyObjects are a problem.
- PEP 803 — "abi3t": Stable ABI for Free-Threaded Builds — Viktorin/Goldbaum, Final, 3.15, resolved 30-Mar-2026, requires 703/793/697. Verdict: the answer to the §2 experiment. Read Specification and Rejected Ideas.
- PEP 684 — A Per-Interpreter GIL (Final, 3.12) and PEP 734 — Multiple Interpreters in the Stdlib (Final, 3.14) — Snow. Verdict: context for §8; the extension-facing consequence is one module slot.
- Migrating to Stable ABI for free threading (abi3t) — 3.15 HOWTO. Verdict: the step-by-step port. Note its own advice to wait if you use a binding generator.
Binding libraries — changelogs are the authority, not the marketing pages
- Cython / CHANGES.rst. 3.1.0 (2025-05-08) added freethreading_compatible; 3.2.3 (2025-12-14) made Py_mod_gil settable by C macro. Verdict: cython -a is the feature that justifies the tool.
- pybind11 / changelog. 2.13.0 (2024-06-25) added py::mod_gil_not_used(); 3.0.0 (2025-07-10) added py::scoped_critical_section and subinterpreter support.
- nanobind / changelog / benchmarks. 2.2.0 (2024-10-03) added free-threading. Verdict on the benchmark page: vendor numbers on a vendor microbenchmark — directionally credible, not independently verified here.
- PyO3 / CHANGELOG. 0.23.0 (2024-11-15) free-threading; 0.28.0 (2026-02-01) made it opt-out; 0.29.0 (2026-06-11) added abi3t features and dropped 3.13t. Verdict: currently the most aggressive on free-threading of anything in §13.
- cffi — 2.0.0 free-threading, 2.1.0 (2026-07-06) abi3t.
- HPy / github.com/hpyproject/hpy. Verdict: effectively dormant — last release 0.9.0 (2023-09-22), last commit 2025-05-26, checked 2026-08-02. The website's "under active development" copy is stale. Read the c-api-next-level manifesto anyway; it is the clearest statement of what is wrong with the C API.
Debugging
- PYTHONMALLOC and Memory Management. Verdict: §14's proof that one env var beats an afternoon in a debugger.
- CPython devguide — Debug tools / Running tests under ASan. Verdict: the source for Misc/ASAN.supp and the debug-build flags.
- faulthandler — free Python-level stacks on segfault.
- lldb: help bt, help frame. There is no well-maintained lldb equivalent of CPython's Tools/gdb/libpython.py; see §14.
Sibling docs
- 14-pyobject-and-types.md — the tp_* slots §7 fills in.
- 15-refcounting-and-ownership.md — §4 at the concept level.
- 16-object-memory-layout.md §2 (the +16 header, confirmed at the C level in §2 here) and §4 (the domains, exercised in §14).
- 22-garbage-collection.md — why tp_traverse is correctness, not performance.
- 24-the-gil.md §3 (the handoff protocol §10 hooks into), §9 (the migration risk §11 measures).
- 26-free-threading.md — the full migration; §11 is its C-side chapter.
- 34-going-native.md — NumPy strides, Arrow, and choosing §13's tool for a real workload.
- 44-packaging-and-environments.md — wheels, ABI tags, and shipping what §2 and §3 describe.
Appendix — what I could not verify¶
Stated plainly, because the C API is full of plausible-but-wrong names and the honest move is to list the gaps rather than smooth them over.
Verified by grepping this build's headers (all present, all spelled as written):
PyDict_GetItemRef, PyDict_GetItemStringRef, PyDict_SetDefaultRef, PyList_GetItemRef,
PyObject_GetOptionalAttr, PyObject_GetOptionalAttrString, PyType_FromSpec,
PyType_FromModuleAndSpec, PyType_FromMetaclass, PyType_GetModuleState,
PyModule_GetState, PyModule_AddObjectRef, PyModuleDef_Init, PyModule_FromDefAndSpec,
Py_mod_create, Py_mod_exec, Py_mod_multiple_interpreters, Py_mod_gil,
Py_MOD_GIL_USED, Py_MOD_GIL_NOT_USED, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED,
PyUnstable_Module_SetGIL, Py_BEGIN_CRITICAL_SECTION, Py_BEGIN_CRITICAL_SECTION2,
PyMutex_Lock, PyGILState_Ensure, PyObject_GetBuffer, PyBuffer_Release,
PyBuffer_IsContiguous, METH_FASTCALL, PyObject_Vectorcall, PyErr_GetRaisedException,
PyErr_SetRaisedException, PyException_SetCause, PyException_SetContext,
PyErr_FormatUnraisable, Py_CLEAR, Py_XDECREF, Py_NewRef, Py_XNewRef,
PyUnstable_Object_ClearWeakRefsNoCallbacks, Py_T_PYSSIZET, Py_READONLY.
Verified by compiling and running: everything in §6, plus §2's Limited-API error output,
§10's scaling table, §11's data-loss table, §12's ns/call table, and §14's crash and
PYTHONMALLOC output.
Read but NOT compiled or run (I have no 3.15 locally): everything in §3 —
Py_TARGET_ABI3T, PyModExport_<name>, Py_mod_name, Py_mod_token, Py_mod_methods,
Py_mod_state_size, PyModule_FromSlotsAndSpec, PyModule_GetToken,
PyType_GetModuleByToken, PyType_GetBaseByToken, PyObject_GetTypeData. The names come
from peps.python.org/pep-0803, peps.python.org/pep-0793, and the 3.15.0b4 docs; the
semantics I describe are paraphrase, not execution. The 3.15 abi3t HOWTO additionally
shows a PySlot / PySlot_STATIC_DATA(...) / PySlot_END slot-array spelling that I could
not corroborate against any header or reference page — treat that particular spelling as
unverified.
Ecosystem claims I could not verify directly (dated 2026-08-02, from changelogs and the
GitHub/PyPI APIs, not from building anything):
- nanobind's nb::ft_mutex / nb::ft_lock_guard names — I saw free-threading support
attributed to 2.2.0 in the changelog but did not confirm those two identifiers in the
nanobind headers.
- nanobind and PyO3 subinterpreter support ("partial" in §13's table) — I found no explicit
changelog statement either way and am inferring from the absence of a
Py_mod_multiple_interpreters equivalent. Assume nothing; check.
- The "not yet" abi3t entries for Cython, pybind11 and nanobind mean "I found no
changelog entry", not "the maintainers have said no". 3.15 is at rc1; this will change.
- nanobind's 4×/5×/10× headline figures are the project's own published benchmark numbers,
reproduced here as attributed claims, not as measurements of mine.
- The macOS -undefined dynamic_lookup deprecation history: I know the flag compiled clean
with Apple clang 21.0.0 on this SDK (measured). The broader claim that newer Xcode
linkers warn on it is from memory of the toolchain's release notes and I did not
reproduce a warning here.
Next: 18-lexer-parser-ast.md begins Tier 3 and goes back up the
stack — but if this document was interesting, the two docs that actually continue it are
26-free-threading.md (§11 at service scale) and
34-going-native.md (§9 and §13 applied to a real numerical
workload). And do Lab 5 before you ship a free-threaded extension.