Lesson 14

Alternatives, Checklist and Agent Brief

Whether to write raw C at all, a review checklist that covers every invariant in this course, and how to specify the work precisely enough that a coding agent produces something correct.

Learning objective

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.

ApproachYou writeHandles for youBest 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

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)

Types (Lesson 03)

Garbage collection (Lesson 04)

Errors (Lesson 05)

Functions and modules (Lessons 06, 08)

Threads (Lessons 10–11)

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 versionse.g. "3.13 and 3.14, no older"
Module name and import pathe.g. mypkg._speedups
Type styleHeap types via PyType_Spec, or static
StatePer-module state struct; list its fields
IsolationSubinterpreter support: yes/no; per-interpreter GIL: yes/no
Free-threadingDeclare Py_MOD_GIL_NOT_USED or not
ABIVersion-specific, or Limited API with a minimum version
ThreadingWhich calls release the interpreter; what needs locking
Error mappingLibrary error codes → exception types
Data pathBuffer protocol, bytes copies, or object-by-object
Buildsetuptools / 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:

  1. Every error path. Does each goto or early return release everything acquired so far? This is where generated code leaks.
  2. tp_traverse against the struct definition. Field by field. A missing Py_VISIT is a crash; an extra one is a leak.
  3. 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".
  4. Everything between Py_BEGIN_ALLOW_THREADS and Py_END_ALLOW_THREADS. No Py call, no object access, no unpinned pointer.
  5. Buffer releases on error paths.
  6. The free-threading declaration against the actual code. If it says Py_MOD_GIL_NOT_USED, verify the audit from Lesson 11 actually holds.
Two failure modes to expect specifically

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

Further reading