backend / python core / performance / 04_native_extensions.md

Native extensions — Cython, Numba, ctypes, PyO3

3 min read source

Native extensions — Cython, Numba, ctypes, PyO3

When pure Python is too slow even after algorithm and stdlib optimization, drop to native code. Each tool fits a different niche.

Cython — Python superset that compiles to C

Annotate Python code with C-like types; Cython generates a compiled .so/.pyd:

# fib.pyx
def fib(int n):
    cdef int a = 0, b = 1, i
    for i in range(n):
        a, b = b, a + b
    return a

Compile via setup.py or pyproject.toml with the Cython build backend. Result is a regular Python module that’s 10-1000x faster on tight loops with C types.

When to use: existing performance-critical Python code that you can annotate; numerical pipelines where vectorization isn’t enough.

Trade-off: build complexity (C compiler required), .pyx files aren’t pure Python.

Numba — JIT for numerical Python

Decorator-based; works on NumPy-heavy code:

from numba import jit
import numpy as np

@jit(nopython=True)
def sum_squares(arr):
    s = 0.0
    for x in arr:
        s += x * x
    return s

sum_squares(np.arange(1_000_000.0))   # JIT compiles on first call, then fast

nopython=True (alias @njit) is required for real speed — falls back to slow mode otherwise.

When to use: heavy numerical loops over NumPy arrays where vectorization isn’t expressive enough.

Limitations: only supports a subset of Python (no arbitrary dicts of objects, limited string handling, no exceptions inside @jit functions).

mypyc — compile typed Python

Used by mypy itself for ~4x speedup. Compiles type-annotated .py files to C extensions:

mypyc my_module.py

When to use: a fully type-annotated Python codebase; you want speed without rewriting.

Limitations: type annotations must be precise; some Python features have caveats; not as widely deployed as Cython.

ctypes — call C libraries from Python

For wrapping existing C libraries without writing C extension code:

import ctypes

libc = ctypes.CDLL("libc.so.6")
libc.printf(b"Hello %s\n", b"World")

# Or call your own .so:
mylib = ctypes.CDLL("./libmath.so")
mylib.add.argtypes = [ctypes.c_int, ctypes.c_int]
mylib.add.restype = ctypes.c_int
print(mylib.add(2, 3))

When to use: integrating with system libraries, vendor SDKs distributed as .so/.dll.

Trade-off: type marshaling is manual and error-prone; no help with memory safety.

cffi is a higher-level alternative.

PyO3 — Rust extensions

State of the art for new performance-critical Python libraries:

use pyo3::prelude::*;

#[pyfunction]
fn sum_squares(arr: Vec<f64>) -> f64 {
    arr.iter().map(|x| x * x).sum()
}

#[pymodule]
fn fastmath(_py: Python, m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sum_squares, m)?)?;
    Ok(())
}

Use maturin to build a wheel. Modern Python libraries increasingly use Rust + PyO3: tokenizers (HuggingFace), polars, pydantic-core (v2), ruff, uv.

When to use: building a new library where Python is too slow; you (or your team) know Rust.

Trade-off: requires Rust toolchain; longer compile times than Cython.

CPython C API — the original approach

Write a .c file that uses Python.h:

#include <Python.h>

static PyObject* myfunc(PyObject* self, PyObject* args) {
    int x;
    if (!PyArg_ParseTuple(args, "i", &x)) return NULL;
    return PyLong_FromLong(x * 2);
}

static PyMethodDef Methods[] = {
    {"myfunc", myfunc, METH_VARARGS, "Multiply by 2."},
    {NULL, NULL, 0, NULL}
};

static struct PyModuleDef moduledef = {
    PyModuleDef_HEAD_INIT, "mymodule", NULL, -1, Methods
};

PyMODINIT_FUNC PyInit_mymodule(void) {
    return PyModule_Create(&moduledef);
}

Don’t choose this for new projects. Use Cython or PyO3 instead. The raw C API is verbose, error-prone, and unstable across Python versions.

Decision tree

Need to speed up Python code?
├── Pure-Python CPU loops?
│   ├── Numerical / NumPy-heavy?
│   │   ├── Vectorize with NumPy             ← simplest
│   │   ├── Numba @jit                        ← if loops won't vectorize
│   │   └── Cython                            ← maximum control
│   └── General logic?
│       ├── Cython                            ← most mature
│       └── mypyc                             ← if fully typed
├── Wrap existing C lib?
│   ├── ctypes                                ← simple cases
│   └── cffi                                  ← cleaner for nontrivial APIs
└── Building new perf-critical library?
    ├── PyO3 + Rust                           ← modern, safe, fast
    └── Cython                                ← if Rust isn't available

Interview angle

  • “When would you write a C extension instead of using Python?” → Pure-Python CPU bottleneck, after profiling, and after stdlib + NumPy aren’t enough.
  • “Difference between Cython and Numba?” → Cython is AOT compilation of annotated Python to C; Numba is a JIT for numerical Python (NumPy-focused).
  • “Why are libraries like Polars and Ruff written in Rust?” → Memory safety + performance + good Python interop via PyO3.