AlgoMaster Logo

C Extensions (ctypes, cffi)

Low Priority15 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Python's biggest weakness is also its biggest myth: "Python is slow." It's only slow when a hot loop runs in pure Python bytecode. The moment you call into a C library, you get C-speed work for free. This lesson covers when reaching for C makes sense, the four common ways to do it, and how to use ctypes and cffi to call C code from Python without writing a single line of CPython C-API glue.

Why Call C from Python

Most of the time you don't need to. NumPy, Pandas, re, json, hashlib, and sqlite3 are already C under the hood, and a well-written Python loop using them is fast enough for almost any e-commerce workload. There are three situations where dropping to C is the right call:

  1. A hot loop in pure Python is the bottleneck. A function that totals a million cart line items in pure Python may run 50x slower than the same loop in C.
  2. You need to wrap an existing C library. A vendor ships libpricing.so for inventory pricing or fraud scoring, and there's no Python wrapper.
  3. You need OS-level features the stdlib doesn't expose. Low-level file system calls, hardware interfaces, or a kernel feature too new for Python.

For everything else, profile first. The slowest function in your checkout pipeline is almost never the place you think.

The Four Common Options

Python has a family of tools for calling C, each with different trade-offs. The chapter focuses on ctypes and cffi because they need no compilation on the Python side, but it helps to know the full landscape first.

ToolCompile Step NeededType SafetyPerformanceBest For
ctypesNone (stdlib)Manual (you declare types)GoodQuick wrapping of an existing .so/.dll/.dylib
cffiOptional (ABI mode = no, API mode = yes)Better (parses real C headers)Good to greatRobust bindings, especially when C headers are available
CythonYes (compiles .pyx to C)Strong (typed)ExcellentSpeeding up pure-Python hot loops, mixed Python/C code
PyO3 (Rust)Yes (cargo build)Strong (Rust types)ExcellentNew native extensions written in Rust, memory safety guarantees

ctypes ships with Python, so there's nothing to install. cffi is pip install cffi away and is what cryptography, psycopg, and many other libraries use under the hood. Cython is a separate language that looks like Python with type hints and compiles to C. PyO3 is the modern Rust ecosystem's answer to the C API.

The boundary diagram below shows where Python and C meet for the two tools this lesson focuses on.

The binding layer in the middle is the only thing you write on the Python side. It tells Python what's in the shared library, what each function expects, and what each function returns. The shared library itself is already compiled, sitting on disk as .so (Linux), .dylib (macOS), or .dll (Windows).

ctypes: Calling a C Function in 30 Seconds

ctypes loads a shared library at runtime and lets you call functions inside it. The library is found by name, the function is looked up by name, and you call it like any Python function.

Every Unix system has libm, the C math library. It contains sqrt, pow, sin, cos, and friends. A first call looks like this:

Four things happened. CDLL(...) opened the shared library. libm.sqrt looked up the symbol sqrt inside it. Setting argtypes and restype told ctypes how to marshal a Python float into a C double and how to interpret the return value. Then the call itself works like any function call.

The argtypes/restype declaration looks optional, but it isn't. Skip it and ctypes defaults to int for both argument and return type, which silently corrupts a double result.

What's wrong with this code?

The C function sqrt takes a double and returns a double. Without argtypes, ctypes tries to pass 2.0 as an int, which gets converted to 2. Without restype, the 8-byte return value is interpreted as a 4-byte int. The "result" is garbage. Always declare types before the first call.

Fix:

C Type Mappings

ctypes exposes a Python type for every C primitive. The names follow the obvious pattern: c_int, c_double, c_char, c_void_p, and so on.

C typectypes typePython type for input
intc_intint
unsigned intc_uintint
longc_longint
floatc_floatfloat
doublec_doublefloat
char * (string)c_char_pbytes
wchar_t *c_wchar_pstr
void *c_void_pint (address) or None
T * (pointer)POINTER(T)array or ctypes.byref(obj)

Strings are the easy place to trip up. C strings are bytes, not Unicode text. To pass a Python str you encode it first; to read a returned char * you decode it.

Encoding to bytes is mandatory. Passing a Python str to a c_char_p argument without .encode(...) raises TypeError: bytes or integer address expected.

ctypes With Structs

C programs pass complex data as structs. To talk to them, you mirror the struct on the Python side by subclassing ctypes.Structure and declaring _fields_ in the same order and types as the C definition.

Suppose the inventory system ships a C function that computes the discounted price of a product:

The implementation is one line in C:

You'd compile it once with gcc -shared -fPIC -o libpricing.so pricing.c (or the macOS/Windows equivalent) to produce a shared library. From Python, the binding looks like this:

A few things to call out. _fields_ must match the C struct's field order and types exactly. Mismatched offsets cause silent corruption (the function reads a different field than you think it's reading). ctypes.POINTER(Product) is the type for a pointer to a Product, used in argtypes. ctypes.byref(headphones) passes the address of headphones without making a copy.

You can also read and write fields back from C-modified structs. If discounted_price wrote to p->price (it doesn't here, but it could), the change would be visible in Python through headphones.price after the call.

ctypes Pitfalls

A short list of things that cause real bugs:

  • Forgetting `argtypes` and `restype`. As shown earlier, the defaults (int for both) are wrong for most functions. Always declare them before the first call.
  • Wrong field order or types in `Structure`. No compile-time check exists. Mismatches produce silent corruption.
  • Ownership of returned pointers. If a C function returns char * and Python wraps it with c_char_p, who frees the memory? ctypes doesn't know. If the library malloc'd it and expects you to free it, you have to call free explicitly. If the library returned a pointer to a static buffer, calling free is a crash.
  • The GIL is held by default. Calling a long-running C function (say, a 5-second crypto computation) blocks every other Python thread. To release the GIL during the call, use ctypes.CDLL(libname, use_errno=True) and the function attribute _flags_ |= ctypes._FUNCFLAG_USE_ERRNO patterns, or wrap the function with ctypes.PYFUNCTYPE configured to release. The common shortcut is to load via cdll for "hold the GIL" semantics and WinDLL/manual settings for the alternative on Windows. For long calls, consider running the C work in a separate thread.
  • Calling-convention mismatch on Windows. CDLL uses cdecl. Some Windows APIs use stdcall and need WinDLL. Mixing them up corrupts the stack.
  • Mismatched library architecture. A 32-bit Python can't load a 64-bit .so, and vice versa. The error is rarely friendly.

cffi: The Friendlier Option

cffi is a third-party library (pip install cffi) that solves several ctypes pain points. The biggest difference is that you declare types using actual C syntax, not Python objects. You paste the relevant header into a string, and cffi parses it.

cffi has two modes: ABI mode (no compiler needed, like ctypes) and API mode (a small C compiler step at install time, faster and safer). The chapter sticks to ABI mode for examples since it works without any build infrastructure.

The same libm.sqrt example using cffi in ABI mode:

Two things to notice. ffi.cdef("double sqrt(double x);") is real C syntax, the kind you'd see in a header file. cffi parses this and figures out the argument and return types itself, no c_double declarations needed. ffi.dlopen(...) opens the library, like ctypes.CDLL.

The same Product struct example in cffi:

ffi.new("Product *", {...}) allocates a Product and returns a pointer to it, with the fields populated from the dict. You pass that pointer directly to the C function. There's no byref or POINTER ceremony, the binding reads more like C and less like glue code.

When cffi shines is wrapping a real library. You take the project's header file, paste the relevant declarations into ffi.cdef, and the binding is done. The C compiler isn't needed for ABI mode, and types are checked against the declarations rather than left to you to get right by hand.

ctypes vs cffi: When to Pick Which

Both tools cover the same ground. The choice comes down to taste and the size of the binding.

Aspectctypescffi
InstallStdlibpip install cffi
Type declarationsPython objects (c_int, c_double, ...)C syntax in ffi.cdef(...) strings
Struct definitionsclass P(Structure): _fields_ = [...]Pasted C typedef struct {...}
Compile stepNeverOptional (API mode for speed/safety)
Reading headersManual translationMostly copy-paste
Safer error messagesGenericBetter, since cffi knows the C types
Best forQuick scripts, small bindingsLarger projects, code from real headers

For a one-off script that calls two or three functions out of libc, ctypes is fine and saves an install. For a full binding to a real C library with dozens of functions and structs, cffi is less painful, especially because you can keep the cdef block in sync with the upstream header.

The cryptography library, psycopg (PostgreSQL driver), and many native database drivers ship cffi bindings in API mode. That's the same machinery you'd use for production work.

Callbacks: Calling Python Back from C

Some C libraries take callbacks: a function pointer you pass in, and the library calls back into your code. Both ctypes and cffi support this.

A typical use case: a C library that processes a list of products and lets you supply a custom pricing rule.

The Python side with ctypes:

CFUNCTYPE describes the signature of a C function pointer. Wrapping a Python function in that type produces a callable C function pointer that the library can call. The Python function runs on whatever thread the C code is on, so it needs the GIL.

The big pitfall: don't let the wrapped callback get garbage-collected while C still holds a pointer to it. Keep a reference in Python (e.g., assign it to a module-level variable) for as long as the C library might call it. Forgetting this causes a crash that's hard to diagnose because it looks like memory corruption in C, not a Python bug.

When NOT to Reach for C

The most common mistake is reaching for ctypes or cffi before profiling. The order of operations matters:

  1. Measure first. cProfile, time.perf_counter, py-spy. Don't guess.
  2. Try the obvious Python fixes. Vectorize with NumPy. Cache results. Use a set instead of a list lookup. Pick a better algorithm.
  3. Try a faster Python. PyPy or the free-threaded build can be a drop-in win for pure-Python code.
  4. Then consider C. Only after the above hasn't moved the needle.

A real example. Suppose your checkout pipeline spends 200ms summing line items for orders with 1,000 products. The naive C extension might shave that to 5ms, but a NumPy np.sum gets you to 8ms with no compilation, no new dependency, and no foreign-function-interface headaches. The Python solution wins almost every time.

The other reason to avoid C extensions is operational pain. A pure-Python package installs anywhere with pip install. A C extension needs the right compiler, the right header files, and the right shared library on every machine that uses it. Wheels (precompiled binaries on PyPI) help a lot, but only if you remember to build them for every platform and Python version your users have.

C extensions are the right call for genuine, measured, hot loops or for wrapping libraries that have no Python equivalent. They're the wrong call for "Python feels slow." Almost always, the bottleneck is something else.

The decision tree puts C extensions at the very bottom, after every cheaper option fails. That ordering matches how experienced engineers think about performance.

Quiz

C Extensions Quiz

10 quizzes