You can choose between the raw C API and the tools that wrap it, review C extension code against a complete list of invariants, and write a specification precise enough that generated code is reviewable rather than guesswork.
Should you write raw C at all?
Everything in Lessons 01–13 is real and unavoidable — but it is not always yours to manage. Several tools handle the boilerplate and some of the invariants for you. The concepts still apply; you now understand what those tools are doing, which is what makes their error messages readable.
| Approach | You write | Handles for you | Best when |
|---|---|---|---|
| Raw C API | C, plus all the glue | Nothing | Maximum control; minimal dependencies; contributing to CPython or a project that already does this; the wrapped library is small. |
| Cython | Python-like .pyx with C type annotations |
Refcounting, exceptions, the module boilerplate, GIL release via with nogil |
Speeding up existing Python code incrementally; numeric kernels; the most common answer for "make this loop fast". |
| pybind11 | Modern C++ | Refcounting via smart pointers, type conversion, exceptions, class binding | Wrapping an existing C++ library. Mature, huge ecosystem. |
| nanobind | Modern C++ (C++17) | The same, with much smaller binaries and faster compiles | New C++ binding projects. By the same author as pybind11; explicitly the successor design. |
| cffi | Python, plus C declarations as a string | Everything — you never write extension C | Wrapping a C library with a clean header. Works on PyPy. Weak for performance-critical inner loops. |
ctypes |
Python only | Everything, at runtime | Quick calls into a system library with no build step. Slow per call, fragile about struct layout. Fine for prototypes. |
| PyO3 / maturin | Rust | Refcounting via types, exceptions, memory safety, the build | New native code where you would otherwise write C. The safety argument is real and the tooling is excellent. |
| HPy | C against an alternative API | A handle-based API with no exposed refcounts | Portability across CPython, PyPy and GraalPy. Promising; smaller ecosystem. |
A decision path
- Wrapping a C library with a clean header, performance not critical → cffi.
- Wrapping C++ → nanobind (new) or pybind11 (existing).
- Making Python code fast, staying close to Python → Cython.
- Writing substantial new native logic, greenfield → Rust with PyO3.
- You need exact control, minimal dependencies, or you are working in a codebase that already uses the C API → raw C.
Note that every one of these still requires the knowledge in this course when things go wrong: a Cython segfault is a refcount bug, a pybind11 deadlock is a GIL bug, and a nanobind free-threading failure is the audit from Lesson 11.
The review checklist
Use this on your own code and on anything generated for you. It is organized by the lesson that explains each item.
Objects and references (Lessons 01–02)
- Every argument's type is checked before any concrete-API or struct access.
- Every function that returns
PyObject *returns a strong reference, or NULL. - Every strong reference acquired is released on every path, including error paths.
- No borrowed reference is held across a call that can run Python code.
- Struct fields are released with
Py_CLEAR, never barePy_DECREF. - Stealing functions (
PyTuple_SetItem,PyList_SetItem,Py_BuildValue'sN) are not followed by a decref. - Multi-step functions use NULL-initialized pointers and a single
gotocleanup block.
Types (Lesson 03)
tp_nameincludes the module prefix.tp_newleaves every field safe fortp_dealloceven iftp_initnever runs.tp_deallocends withPy_TYPE(self)->tp_free(self), and for heap types decrefs the type afterwards.- Heap types are used unless there is a specific reason not to.
Py_TPFLAGS_BASETYPEis present only if subclassing is actually safe.
Garbage collection (Lesson 04)
- Any type storing an arbitrary
PyObject *hasPy_TPFLAGS_HAVE_GC,tp_traverseandtp_clear. tp_traversevisits exactly the owned object fields, plusPy_TYPE(self)for heap types, and nothing else.tp_traverseallocates nothing and calls no Python code.tp_deallocstarts withPyObject_GC_UnTrack.- Module state with object fields has
m_traverse,m_clearandm_free. - Non-Python resources are released in
tp_finalizeortp_dealloc, nevertp_clear.
Errors (Lesson 05)
- Every error return is paired with a set exception, and every success return with a clear indicator.
- Conversion functions that can return
-1legitimately are disambiguated withPyErr_Occurred(). - Cleanup paths that can run Python code save and restore the in-flight exception.
- Long loops that hold the interpreter call
PyErr_CheckSignals(). - Exceptions with nowhere to go are reported via
PyErr_FormatUnraisable, not swallowed.
Functions and modules (Lessons 06, 08)
- The calling convention matches the signature;
METH_FASTCALLon hot paths. - Optional parsed arguments have initialized C defaults.
- Every
Py_bufferfroms*/y*/w*is released on every path. - Multi-phase initialization;
PyInit_<name>matches the built module name. - No
PyObject *in C globals; state lives in per-module state. - The exec function is safe to run more than once per process.
Py_mod_multiple_interpretersandPy_mod_gildeclare the truth.
Threads (Lessons 10–11)
- Blocking calls and long compute are wrapped in
Py_BEGIN_ALLOW_THREADS. - No C API call, no object access, and no unpinned pointer inside a detached region.
- Foreign-thread entry uses
PyGILState_Ensure/Releaseand checksPy_IsFinalizing. - Locks that another thread might hold while needing the interpreter are acquired detached, or are
PyMutex. - If free-threading is declared: no borrowed-reference APIs on shared mutable containers, no unlocked mutable statics, allocator families not mixed.
Briefing a coding agent
Generated extension code fails in characteristic ways: it uses single-phase init, static types, C globals, legacy borrowed-reference APIs, and it omits GC support. Every one of those is a pattern that was correct in 2010 and is heavily represented in training data. The fix is to specify the target explicitly.
What to decide before you ask
| Python versions | e.g. "3.13 and 3.14, no older" |
| Module name and import path | e.g. mypkg._speedups |
| Type style | Heap types via PyType_Spec, or static |
| State | Per-module state struct; list its fields |
| Isolation | Subinterpreter support: yes/no; per-interpreter GIL: yes/no |
| Free-threading | Declare Py_MOD_GIL_NOT_USED or not |
| ABI | Version-specific, or Limited API with a minimum version |
| Threading | Which calls release the interpreter; what needs locking |
| Error mapping | Library error codes → exception types |
| Data path | Buffer protocol, bytes copies, or object-by-object |
| Build | setuptools / meson-python / scikit-build-core; editable install expected |
A prompt template
Write a CPython C extension module.
TARGET
- CPython 3.13 and 3.14 only. Use the modern C API; no deprecated functions.
- Module: mypkg._speedups (entry point PyInit__speedups)
- Build: setuptools with pyproject.toml + setup.py; must work with
`pip install -e . --no-build-isolation`.
- ABI: version-specific (not Limited API).
STRUCTURE — non-negotiable
- Multi-phase initialization (PEP 489): PyInit_ returns PyModuleDef_Init(&def).
- Per-module state struct; m_size = sizeof(state). NO PyObject* in C globals.
- All types are heap types via PyType_Spec + PyType_FromModuleAndSpec.
- Every type storing PyObject* has Py_TPFLAGS_HAVE_GC, tp_traverse (visiting
Py_TYPE(self)), tp_clear, and a tp_dealloc that starts with
PyObject_GC_UnTrack and ends tp_free then Py_DECREF(type).
- Module state has m_traverse, m_clear, m_free.
- Declare {Py_mod_gil, Py_MOD_GIL_NOT_USED} and
{Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED}.
- Use PyList_GetItemRef / PyDict_GetItemRef, never the borrowed variants.
- Use PyModule_AddObjectRef, never PyModule_AddObject.
API
THREADING
ERRORS
Python exception mapping>
ALSO PRODUCE
- pyproject.toml, setup.py
- pytest tests covering: normal use, every documented error, wrong argument
types, a use-after-close, and a reference-count stability loop
(call N times, assert sys.getrefcount of an argument does not grow).
- A short README with the build and test commands.
CONSTRAINTS
- Single-exit cleanup with goto and NULL-initialized pointers.
- Every error return sets an exception; every conversion that can return -1
is disambiguated with PyErr_Occurred().
- No private API (no leading-underscore CPython symbols).
The "STRUCTURE — non-negotiable" block is what steers generation away from 2010-era patterns.
What to review by hand, always
Read the generated code for these specifically, in this order. They are the items that compile cleanly and fail at runtime:
- Every error path. Does each
gotoor early return release everything acquired so far? This is where generated code leaks. tp_traverseagainst the struct definition. Field by field. A missingPy_VISITis a crash; an extra one is a leak.- Ownership at every call. Check each API call against the documentation for new versus borrowed versus stealing. Do not trust a comment that says "borrowed".
- Everything between
Py_BEGIN_ALLOW_THREADSandPy_END_ALLOW_THREADS. NoPycall, no object access, no unpinned pointer. - Buffer releases on error paths.
- The free-threading declaration against the actual code. If it says
Py_MOD_GIL_NOT_USED, verify the audit from Lesson 11 actually holds.
- Plausible-looking API names that do not exist.
PyList_FetchItem,PyDict_GetItemRefString,PyErr_SetFromErrnoWithMessage— check every unfamiliar symbol against the documentation. The compiler catches these; a reviewer skimming does not. - Correct-looking refcounting that is off by one. The compiler cannot catch it, the tests usually cannot either, and it surfaces as a leak or a crash under load. This is why the refcount-stability test belongs in the generated test suite.
Tests that catch what review misses
import gc, sys, threading, pytest
import mypkg._speedups as m
def test_refcount_stable():
arg = object()
m.consume(arg) # warm up caches
before = sys.getrefcount(arg)
for _ in range(1000):
m.consume(arg)
assert sys.getrefcount(arg) == before
def test_no_leak_of_results():
gc.collect()
before = len(gc.get_objects())
for _ in range(1000):
m.make_thing()
gc.collect()
assert len(gc.get_objects()) - before < 100
def test_traverse_matches_fields():
t = m.Thing(payload=[1, 2, 3])
refs = gc.get_referents(t) # this calls tp_traverse
assert type(t) in refs # heap type must visit Py_TYPE(self)
assert any(r == [1, 2, 3] for r in refs)
def test_errors_set_exceptions():
with pytest.raises(TypeError):
m.consume(object(), object(), object())
with pytest.raises(ValueError):
m.scale(1.0, 0.0)
def test_threaded_stress():
thing = m.Thing(payload=[])
barrier = threading.Barrier(8)
def worker():
barrier.wait()
for _ in range(10_000):
thing.touch()
ts = [threading.Thread(target=worker) for _ in range(8)]
[t.start() for t in ts]; [t.join() for t in ts]
Note that test_refcount_stable is meaningless on a free-threaded build (Lesson 11); guard it with @pytest.mark.skipif(not sys._is_gil_enabled(), ...) and rely on the object-count test there instead.
Where to go from here
- Read a real module end to end.
Modules/_sqlite/for a wrapper,Modules/_asynciomodule.cfor a complex type,Modules/zlibmodule.cfor interpreter release. These are reviewed by core developers and reflect current practice. - Build CPython yourself with
--with-pydebugand run your extension against it. The assertions you get for free are worth more than any amount of code review. - Follow discuss.python.org's C API category. The API is being actively reshaped — PEP 793, free-threading, the Limited API — and the discussions are where the direction is set before it reaches the documentation.
Further reading
- Extending and Embedding the Python Interpreter. The full official guide.
- Python/C API Reference Manual. The reference you will keep open.
- CPython Developer's Guide. Building CPython, debug builds, and the internals documentation.
- discuss.python.org — C API. Where the API's direction is decided.
- pythoncapi-compat. Header that backports modern C API functions to older Pythons; removes most version-conditional code.