Native extensions

Expert45 min

Call C from Python with ctypes, compare cffi, Cython and Rust with PyO3, and understand the stable ABI, including abi3t for free-threaded builds in 3.15.

What you will learn
  • Call a C library function with ctypes, declaring argument and result types
  • Pass a Python callback to C, and explain the cost of crossing the boundary
  • Choose between ctypes, cffi, Cython and PyO3 for a given job
  • Explain what the stable ABI (abi3) buys a package, and what abi3t adds in 3.15
  • Inspect the running interpreter's ABI
On this page

When a profile shows a hot loop that will not vectorise, or you need a library that only exists in C, C++ or Rust, you cross into native code. CPython makes that easy, and it was designed for it: much of the standard library is C. This lesson tours the options, from no compiler at all (ctypes) to full extension modules, and explains the ABI rules that decide which Python versions a compiled wheel works with. The examples call the C library on Linux. On macOS, find_library("c") also works. On Windows, load ctypes.cdll.msvcrt instead.

ctypes: call C without compiling anything

ctypes is in the standard library. It loads a shared library and calls its functions, converting arguments at the boundary. You must describe each function’s signature yourself: argtypes for the parameters and restype for the result.

ctypes_libc.py
import ctypes
import ctypes.util
libc = ctypes.CDLL(ctypes.util.find_library("c")) # Linux: libc.so.6
libc.strlen.argtypes = [ctypes.c_char_p]
libc.strlen.restype = ctypes.c_size_t
print(libc.strlen(b"performance"))
libc.labs.argtypes = [ctypes.c_long]
libc.labs.restype = ctypes.c_long
print(libc.labs(-(2 ** 40)))
buf = ctypes.create_string_buffer(32) # writable char[32]
libc.snprintf(buf, 32, b"%d items at %s", 3, b"4.50")
print(buf.value)
Output
11
1099511627776
b'3 items at 4.50'

c_char_p takes bytes, not str: C strings are bytes, and you choose the encoding. create_string_buffer allocates memory that C may write to. Never pass an immutable bytes object to a function that writes. snprintf takes a variable number of arguments, so it gets no argtypes, and ctypes converts Python int and bytes by default.

Nothing checks your declarations against the real C signature. Get one wrong and you get a wrong answer or a crash, not an exception (exercise 2). For anything beyond a few calls, generate the declarations or use a tool that reads the C header.

Callbacks: C calling Python

C functions that take function pointers, such as qsort, can call back into Python. CFUNCTYPE builds a C-callable wrapper from a Python function:

ctypes_qsort.py
import ctypes
import ctypes.util
libc = ctypes.CDLL(ctypes.util.find_library("c"))
IntArray = ctypes.c_int * 6
values = IntArray(42, 7, 19, 3, 88, 1)
CMP = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_int))
calls = 0
@CMP
def compare(a, b): # C calls back into Python
global calls
calls += 1
return a[0] - b[0]
libc.qsort(values, len(values), ctypes.sizeof(ctypes.c_int), compare)
print(list(values))
print("callback ran:", calls > 0)
Output
[1, 3, 7, 19, 42, 88]
callback ran: True

Keep a reference to the callback object for as long as C might call it. If it is garbage-collected while C still holds the pointer, the program crashes. And notice the cost: every comparison now crosses from C into Python and back, so this sort is far slower than sorted().

The cost of the boundary

Each ctypes call converts its arguments, releases and reacquires the GIL, and builds the result object. That costs far more than a call to a built-in. A native function pays off only when each call does a lot of work, so pass a whole array in one call, not one element per call. Exercise 3 measures the gap.

The other options

cffi reads C declarations written as C, so you paste a signature from a header instead of translating it into argtypes. Its ABI mode, shown here, needs no compiler. Its API mode compiles a small extension, which is faster and type-checked by the C compiler. It is a third-party package.

from cffi import FFI
ffi = FFI()
ffi.cdef("size_t strlen(const char *s);")
libc = ffi.dlopen(None) # the standard C library
print(libc.strlen(b"performance"))

Cython compiles a superset of Python to C. Its pure-Python mode uses ordinary annotations, so the file still runs unchanged under CPython. Typed loops over typed memory views become plain C loops. Build with cythonize -i dot.py.

import cython
def dot(a: cython.double[:], b: cython.double[:]) -> cython.double:
total: cython.double = 0
i: cython.Py_ssize_t
for i in range(a.shape[0]):
total += a[i] * b[i]
return total

Rust with PyO3 writes the extension in Rust, and maturin builds and packages it (maturin new --bindings pyo3 fastdot, then maturin develop to install into the active virtual environment):

use pyo3::prelude::*;
#[pyfunction]
fn dot(a: Vec<f64>, b: Vec<f64>) -> f64 {
a.iter().zip(&b).map(|(x, y)| x * y).sum()
}
#[pymodule]
fn fastdot(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(dot, m)?)?;
Ok(())
}

These three snippets were last run in October 2026, on CPython 3.15.0 with cffi 2.1.1, Cython 3.3.0, and PyO3 0.29.3 with maturin 1.15.0. All three are third-party projects that change faster than Python, so follow their own documentation when it disagrees with this page.

How to choose:

  • ctypes: a few calls into an existing C library, and no build step allowed.
  • cffi: many functions from a C library, declared from its header.
  • Cython: speeding up your own numeric Python code step by step, or wrapping C and C++.
  • PyO3: new native code where memory safety matters, or an existing Rust library.
  • The C API directly: rarely, when you need full control. The next module tours it.

The stable ABI: one wheel for many versions

A compiled extension normally works with exactly one Python version and build, which is what tags such as cp315 in a wheel name record. The stable ABI (abi3), available since Python 3.2, is a subset of the C API whose binary interface CPython promises not to change. An extension that uses only that subset (the Limited API, chosen by defining Py_LIMITED_API) and is built for 3.x works on 3.x and every later version, so a package can ship one wheel instead of one per release. PyO3, Cython and the main build back ends can all target it.

Free-threaded builds could not use abi3, because it exposes the layout of PyObject, which is different without the GIL (Free-threaded Python). Requires version 3.15 or later PEP 803 adds abi3t, a stable ABI for free-threaded builds that makes PyObject opaque. You select it with the Py_TARGET_ABI3T macro, or more usually in your build tool. Support is arriving release by release. When this lesson was checked (October 2026), PyO3 0.29 had an abi3t feature and maturin 1.15 tagged wheels abi3.abi3t. For meson-python, scikit-build-core and others, check each tool’s release notes. It needs real source changes, such as the new PyModExport_* module export hook. An extension built for both is tagged abi3.abi3t in its wheel name and works on both builds.

Requires version 3.15 or later To see which ABI the running interpreter has, read sys.abi_info:

abi_check.py
import sys
import sysconfig
info = sys.abi_info
print("pointer bits:", info.pointer_bits)
print("free-threaded build:", info.free_threaded)
print("debug build:", info.debug)
print("extension suffix:", sysconfig.get_config_var("EXT_SUFFIX"))
Output
pointer bits: 64
free-threaded build: False
debug build: False
extension suffix: .cpython-315-x86_64-linux-gnu.so

That output is from the standard build on x86-64 Linux. On python3.15t the second line says True and the suffix contains cpython-315t.

When not to go native

Native code adds a compiler or a Rust toolchain to your build, wheels for every platform you support, and a class of bugs (crashes, memory corruption) that Python does not have. Go native only when the hot spot is CPU-bound Python, the algorithm is already right, and NumPy or an existing compiled package cannot do the job. If the time is spent waiting on I/O, native code changes nothing. If the work splits into large independent pieces, running them in parallel on several cores may be a cheaper first step than a rewrite (Concurrency models). And if a crossing happens per element, the boundary cost can make native code slower than the Python it replaced.

Common pitfalls

Leaving restype at its default. ctypes assumes c_int, which truncates 64-bit results and pointers.

Passing str where C wants char *. Encode it first, and use a mutable buffer if C writes to it.

Letting a callback be collected. Keep a reference for as long as C holds the pointer.

Calling native code in a tight Python loop. Cross the boundary once per batch, not once per element.

Assuming abi3 covers free-threading. A free-threaded interpreter loads only abi3t (or version-specific cp315t) extensions.

ExerciseParse numbers with strtod

Write parse_number(text) that calls C’s strtod through ctypes and returns a tuple of the parsed value and the unparsed rest of the input, as bytes. strtod takes a const char * and a char ** that it sets to the first character it did not use, and it returns a double. Expected output for b"2.5e3 metres", b"-0.125" and b"abc":

(2500.0, b' metres')
(-0.125, b'')
(0.0, b'abc')
Show solution
ctypes_strtod.py
import ctypes
import ctypes.util
libc = ctypes.CDLL(ctypes.util.find_library("c"))
libc.strtod.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p)]
libc.strtod.restype = ctypes.c_double
def parse_number(text):
"""Return (value, unparsed rest) using C's strtod."""
raw = ctypes.c_char_p(text)
end = ctypes.c_char_p()
value = libc.strtod(raw, ctypes.byref(end))
return value, end.value
print(parse_number(b"2.5e3 metres"))
print(parse_number(b"-0.125"))
print(parse_number(b"abc"))
Output
(2500.0, b' metres')
(-0.125, b'')
(0.0, b'abc')

byref(end) passes the address of end, which is the char ** the function writes to. Reading end.value follows that pointer into the original buffer, so raw must stay alive until then. Without restype = c_double, ctypes would read the result from an integer register and return garbage. strtod depends on the C locale, which Python leaves as "C" for numbers unless you change it.

ExerciseThe vanishing absolute value

Predict what this prints on 64-bit Linux, explain it, and fix it so it prints 8589934592.

ctypes_restype.py
import ctypes
import ctypes.util
libc = ctypes.CDLL(ctypes.util.find_library("c"))
libc.labs.argtypes = [ctypes.c_long] # argument declared ...
print(libc.labs(-(2 ** 33))) # ... but not the result type
Show solution

It prints 0. labs correctly returns the 64-bit long 8589934592, which is 2 to the power 33. ctypes assumes the result is a 32-bit c_int and keeps only the low 32 bits, which are all zero. No error is raised. Declare the result type:

ctypes_restype_fixed.py
import ctypes
import ctypes.util
libc = ctypes.CDLL(ctypes.util.find_library("c"))
libc.labs.argtypes = [ctypes.c_long]
libc.labs.restype = ctypes.c_long # the default is c_int
print(libc.labs(-(2 ** 33)))
Output
8589934592

With a pointer result, the same mistake truncates the address, and using it crashes the process.

ExercisePrice the boundary

Measure the cost of a ctypes call by timing libc.strlen(word) against len(word) for word = b"performance" (best of 5 runs of 20,000 calls each). Print whether both give the same answer, and whether len() is at least 3 times faster, and write the measured ratio to standard error. Expected output: same answer: True and len() at least 3x faster: True. Then explain in one sentence when calling C from Python is worth it.

Show solution
ctypes_overhead.py
import ctypes
import ctypes.util
import sys
import timeit
libc = ctypes.CDLL(ctypes.util.find_library("c"))
libc.strlen.argtypes = [ctypes.c_char_p]
libc.strlen.restype = ctypes.c_size_t
word = b"performance"
via_c = min(timeit.repeat(lambda: libc.strlen(word), number=20_000,
repeat=5))
via_len = min(timeit.repeat(lambda: len(word), number=20_000, repeat=5))
print("same answer:", libc.strlen(word) == len(word))
print("len() at least 3x faster:", via_c > 3 * via_len)
print(f"measured: {via_c / via_len:.0f}x", file=sys.stderr) # varies
Output
same answer: True
len() at least 3x faster: True

On my machine the ctypes call was about 9 times slower, and 14 times with the JIT on. The ratio depends on the C library and the build as well as the CPU, which is why the threshold is only 3x. The call is slow even though strlen itself does almost nothing: the time goes into converting arguments and results. Calling C is worth it when one call does enough work to hide that fixed cost, such as processing a whole buffer or array.

Summary

  • ctypes calls C with no compiler. Always set argtypes and restype, because mistakes give wrong answers or crashes, not exceptions.
  • Every crossing has a fixed cost, so cross once per batch.
  • cffi reads C declarations, Cython compiles annotated Python, and PyO3 with maturin builds Rust extensions.
  • The stable ABI (abi3) lets one wheel serve many versions. Requires version 3.15 or later abi3t does the same for free-threaded builds, and sys.abi_info describes the running interpreter.
  • Next: The JIT and the interpreter, how CPython itself runs your code faster.