Skip to content
← All articles

When to write a C or Rust extension

The most expensive optimisation available: a build toolchain, cross-platform wheels now multiplied by free-threaded variants, and either a new class of memory bugs or a new language. The framing that predicts success before you write anything is per-call boundary cost — move the loop, not the loop body.

This is the most expensive optimisation on the menu, and the cost is not the code. It is a build toolchain in CI, cross-platform wheels for every Python version you support — now multiplied by free-threaded variants — a debug story that involves gdb, and either a new class of memory bugs (C, Cython) or a second language for every maintainer (Rust).

Which means the question worth answering first is not “how do I write an extension” but “will an extension help, and how would I know before building one”.

The framing that predicts the answer: per-call boundary cost

Some numbers to anchor it:

call cost
Python function called from Python ~62.5 ns
Cython function called from Python ~30 ns
C function called from C ~3 ns

The middle row is the one that surprises people. A Cython function is twice as fast to call as a Python one — and still an order of magnitude slower than a real C call, because crossing the boundary means converting arguments, managing references, and building a Python object for the result. Cython’s own documentation calls ~30 ns “rather slow by the standards of compiled languages”.

The killer example, and it is worth carrying: summing a million integers by passing a Python list into a Cython function was no faster than built-in sum(). All the runtime went into converting a million PyObject* into C integers. The loop was compiled; the data was not.

So: move the loop, not the loop body. Compiling a function that is called a million times from a Python loop buys you the difference between 62.5 ns and 30 ns, per call, minus the conversion cost of its arguments — which is frequently a net loss. Compiling a function that is called once and does the million iterations internally, on data that is already in a native layout, is where the order of magnitude lives.

That single principle is enough to reject most extension proposals without writing any code.

💡cProfile says a helper has 4.2 s of tottime across 3,000,000 calls. Someone proposes rewriting it in Cython. What is wrong with the evidence, before you even discuss the proposal? click to reveal

The profiler is instrumenting Python calls and not C calls, so it systematically overstates the cost of Python code relative to the native alternative. Three million instrumented calls carry three million units of profiler overhead attributed to the Python function — and the C replacement it is being compared against would carry none. The measurement is biased in exactly the direction of the conclusion someone wants to draw, which is the shape of evidence you should always distrust.

The docs say this outright: the profilers “introduce overhead for Python code, but not for C-level functions, and so the C code would seem faster than any Python one.”

What to do instead: re-measure the unprofiled path with timeit, or with a sampling profiler that does not instrument. And then notice what the call count is telling you — 3,000,000 calls of a small helper is a call-count problem, and the cheapest fix is to call it fewer times. Batch the loop, or hoist it. If you can turn 3,000,000 calls into 3,000 calls that each handle a thousand items, you have most of the win and none of the toolchain.

The four options, as a genuine trade-off

Cython. Lowest adoption cost: it is Python-with-types, you can convert one function at a time, and the rest of the module stays as it is. In exchange, you have inherited C’s hazards — you can write out of bounds, you can get reference counting wrong — inside code that still looks like Python. Best when a small hot kernel needs typed loops over typed buffers.

mypyc. Compiles type-annotated Python to a C extension. The compelling property for anyone who has done this course: your existing --strict annotations do double duty, first as a checker contract and then as compilation information. There is no new language and often no new source. The cost is that it supports a subset of Python, so some code needs restructuring, and the speedups are workload-dependent. mypy itself is compiled with it, which is a real reference.

PyO3 + maturin. Rust. Memory-safe by construction, the best wheel-building tooling in the ecosystem, and the best free-threading readiness — PyO3 has taken Py_mod_gil and the free-threaded ABI seriously. The cost is a second language that every maintainer must be able to review, which is an organisational decision more than a technical one.

ctypes / cffi. No build step at all — call an existing shared library directly. The right answer when the native code already exists and you just need to reach it. The highest per-call marshalling cost of the four, so the same “move the loop” rule applies with extra force.

Free-threading multiplies the wheel matrix

PEP 703’s free-threaded build is a separate ABI. An extension that does not declare the Py_mod_gil slot causes the interpreter to silently re-enable the GIL with a warning when it is imported — so you pay free-threading’s 5-10% single-thread overhead and 15-20% memory increase and get none of the parallelism, because of one dependency.

PEP 803 (⚠3.15) adds the abi3t Stable ABI so extension authors can ship one wheel across free-threaded versions, which will help. Until then, “does this dependency have a free-threaded wheel?” is a question with real deployment consequences, and shipping your own extension means answering it for your users.

The ordering

Before native code, in this order:

  1. A better algorithm. The 11.16 join went from 1.6 × 10⁹ comparisons to 40,000 by building a dict. No compiler recovers that.
  2. A better data layout. array.array or a NumPy array instead of a list of boxed objects (article 11.23). This is frequently the same win a C extension would give you, because it is the same underlying change — contiguous unboxed memory — without the toolchain.
  3. A better library. Somebody has probably already written and shipped wheels for your hot kernel: orjson, msgspec, polars, regex, numpy, scipy. Using theirs means you are not maintaining it.
  4. Then native code.
💡You have done all three and the remaining hot spot is genuinely a tight numeric loop in Python. Between Cython, mypyc and PyO3, what would decide it? click to reveal

Three questions, roughly in this order.

What does the code look like? If it is already fully annotated, structurally ordinary Python across several modules, mypyc is the least disruptive: no new syntax, no new language, and the annotations you already wrote are the input. If it is one small kernel of typed loops over buffers, Cython is a better fit, because that is precisely what its typed memoryviews are for.

Who maintains it? PyO3 means every reviewer needs enough Rust to review it. On a team with Rust experience that is an asset; on one without, you have created a module only one person can touch, which is a worse outcome than a slow function.

What is the distribution story? If you ship wheels to external users across many platforms and Python versions — now including free-threaded variants — maturin’s tooling is the best in the ecosystem and that advantage compounds every release. If it is an internal service on one platform, distribution barely matters and the question collapses to the first two.

The tie-breaker worth stating: prefer the option that fails safely. Cython gives you C’s memory hazards in code that reads like Python, which is the combination most likely to produce a bug nobody spots in review. mypyc and PyO3 are both memory-safe by construction. If the performance is comparable — and it often is — that is not a small difference.

Under the Hood: Objects, Memory, Speed · step 35 of 35

That's the end of this track. Review it or pick another.

← Back to Under the Hood: Objects, Memory, Speed