Python C Extensions: Part III — ctypes and CFFI
This article is Part III of the Python C extension series. Part I — Overview covers hand-written extension functions and PyTypeObject binding. Part II — Execution covers bytecode, tp_call, and how C extension methods run without a nested eval loop.
Part III covers FFI (foreign function interface): calling existing C libraries from Python with ctypes (stdlib) or CFFI (third-party), instead of writing a new PyInit_* module for every library.
The ctypes documentation recommends this path when your goal is calling C library functions or system calls — it is often more portable across Python implementations than compiling a custom C extension. That does not replace Part I’s Config / capsule patterns when you need full Python types and attribute integration.
Runnable demos live in the python repository. Build the shared library once, then run each test:
make -C c_ext_ffi_clib
python3 c_ext_ctypes_add/test_ctypes_add.py
python3 c_ext_cffi_abi/test_cffi_abi.py
python3 c_ext_cffi_api/test_cffi_api.py
| Section | Demo folder |
|---|---|
| §7.2 | c_ext_ctypes_add (+ c_ext_ffi_clib) |
| §7.3 ABI | c_ext_cffi_abi |
| §7.3 API | c_ext_cffi_api |
| §7.4 contrast | c_ext_config_basic (Part I hand-written Config) |
Section 7: FFI vs Hand-Written C Extensions
Hand-written extension (Part I): you compile mymodule.cpython-312-….so that links against libpython. Import runs PyInit_mymodule; your C code calls library functions with normal compiler-generated calls and uses the Python C API (PyArg_ParseTuple, PyTypeObject, …) for Python integration.
FFI (ctypes / CFFI): you load an existing plain C shared library (libadd.so — no Python.h) at runtime. A stdlib or pip-provided C extension (_ctypes or _cffi_backend) marshals Python values and invokes the C function through libffi.
On Linux with CPython 3.12 you will not find _ctypes as /usr/lib/python3.12/_ctypes. It is a versioned extension module:
import _ctypes
print(_ctypes.__file__)
# e.g. /usr/lib/python3.12/lib-dynload/_ctypes.cpython-312-aarch64-linux-gnu.so
Both FFI backends link libffi:
$ ldd .../_ctypes.cpython-312-aarch64-linux-gnu.so
libffi.so.8 => /lib/aarch64-linux-gnu/libffi.so.8
$ ldd .../_cffi_backend.cpython-312-aarch64-linux-gnu.so
libffi.so.8 => /lib/aarch64-linux-gnu/libffi.so.8
Your target library (libadd.so) does not need libffi — only the FFI machinery does.

7.1 What libffi Does
When you compile C normally, the compiler knows each function’s signature and emits a fixed call. libffi solves the opposite problem at runtime: given a function pointer and a type description, it places arguments in the correct registers/stack slots per the platform ABI and jumps to the pointer.
Hand-written extensions on the hot path do not use libffi: system(command) and Config_process(self, …) are direct C calls. You still marshal Python ↔ C with the Python C API, not libffi.
ctypes and CFFI ABI mode use libffi because the callee’s signature is only known from Python metadata (argtypes, cdef) at runtime. See CFFI overview — ABI mode.
7.2 ctypes Mechanism
ctypes is itself a C extension module (_ctypes). Your code is Python-only.
Full source: c_ext_ctypes_add — test_ctypes_add.py; shared C library in c_ext_ffi_clib.
Shared plain C library (c_ext_ffi_clib/add.c):
int add(int a, int b)
{
return a + b;
}
Build libadd.so with make -C c_ext_ffi_clib, then call from Python (CDLL, function prototypes):
import ctypes
from pathlib import Path
lib = ctypes.CDLL(str(Path("c_ext_ffi_clib/libadd.so").resolve()))
lib.add.argtypes = [ctypes.c_int, ctypes.c_int]
lib.add.restype = ctypes.c_int
assert lib.add(2, 3) == 5
assert "FuncPtr" in type(lib.add).__name__
Call chain:
CDLL→dlopen("libadd.so"),dlsym("add")→ raw function pointer.lib.add(2, 3)→_FuncPtrcallable (ctypes.CDLL.__init__.<locals>._FuncPtr), notbuiltin_function_or_methodfrom Part II’sPyCFunctionObject._ctypesconverts Pythonint→ Cint, calls through libffi, returns result as Pythonint.
Wrong argtypes / restype can corrupt the stack or crash — there is no compile-time check.
7.3 CFFI — ABI and API Modes
CFFI (documentation) offers four combinations of ABI vs API and in-line vs out-of-line (other modes). The demos below use the two most common for calling an existing library.

ABI mode (in-line)
Like ctypes: no C compiler step for your glue. Uses _cffi_backend + libffi. Mis-declared cdef can crash (CFFI warning).
Full source: c_ext_cffi_abi — test_cffi_abi.py
from cffi import FFI
ffi = FFI()
ffi.cdef("int add(int a, int b);")
lib = ffi.dlopen("c_ext_ffi_clib/libadd.so")
assert lib.add(2, 3) == 5
ffi.cdef accepts C declarations (paste from a header). ffi.dlopen loads the .so at runtime — same class of dynamic call as ctypes.CDLL.
API mode (out-of-line)
CFFI generates a real C extension module (main mode): cdef + set_source + compile. The compiler checks struct layouts; repeated calls are faster than ABI mode.
Full source: c_ext_cffi_api — add_build.py, setup.py, test_cffi_api.py
Build script (add_build.py):
from pathlib import Path
from cffi import FFI
ffibuilder = FFI()
ffibuilder.cdef("int add(int a, int b);")
_here = Path(__file__).resolve().parent
_add_c = _here.parent / "c_ext_ffi_clib" / "add.c"
ffibuilder.set_source(
"_add_cffi",
'#include "add.h"',
sources=[str(_add_c)],
include_dirs=[str(_here)],
)
setup.py uses cffi_modules (same pattern as the CFFI distributing example):
setup(
name="add_cffi",
cffi_modules=["add_build.py:ffibuilder"],
setup_requires=["cffi>=1.0.0"],
)
Build and use:
cd c_ext_cffi_api
python3 setup.py build_ext --inplace
from _add_cffi import lib
assert lib.add(2, 3) == 5
The generated _add_cffi.abi3.so is a C extension — but CFFI wrote the glue; you only supplied cdef and linked add.c.
7.4 ctypes vs CFFI vs Hand-Written Extension

| ctypes | CFFI ABI | CFFI API | Hand-written (Part I) | |
|---|---|---|---|---|
| pip / stdlib | stdlib | needs cffi |
needs cffi + build |
setup.py + compiler |
| You write | Python + argtypes |
Python + cdef |
cdef + build script |
C + Python C API |
| Glue module | _ctypes + libffi |
_cffi_backend + libffi |
generated .so |
your mymodule.so |
| Callable type | _FuncPtr |
via backend | lib.add in generated module |
builtin_function_or_method |
| Type safety | runtime only | runtime only (ABI) | compile-time (API) | C compiler |
Custom PyTypeObject |
poor fit | poor fit | limited | full (Config, §2.2) |
| Target library | any .so |
any .so |
link sources / libs | whatever you link or embed |
Execution contrast (Part II): config.process() on c_ext_config_basic yields PyCFunctionObject → direct Config_process in C. lib.add(2, 3) via ctypes goes through _FuncPtr → _ctypes → libffi → add in libadd.so — no PyMethodDef on your side, no Config type.
7.5 When to Use Which
- ctypes — stdlib, quick
dlopen, simple C signatures; good when you cannot add dependencies. See ctypes. - CFFI ABI — nicer
cdef,ffi.new, buffers; same runtime model as ctypes. See CFFI overview. - CFFI API — repeated calls, structs, compile-time layout checks; still less boilerplate than hand-written extensions for flat C APIs.
- Hand-written C extension — custom Python types, capsules, tight error handling, maximum control (Parts I–II).
The ctypes introduction states the C extension interface is specific to CPython; ctypes and CFFI are often more portable across Python implementations for calling C functions because your integration code stays in Python (plus CFFI’s generated shim in API mode). They do not replace C extensions when the product is a Python module with classes and methods designed in C.
For ctypes struct mirroring, keepalive, and handles vs user API (fd, capsule, internal Structure — not the public wrapper classes) beyond §7.2’s scalar add() — see Part IV — Complex ctypes Structs and Handles.
References
- Part I — Overview
- Part II — Execution
- Part IV — Complex ctypes Structs and Handles
- ctypes — Python 3 documentation
- CFFI documentation — overview, ABI example, API / main mode, cdef
- Extending Python with C or C++ — when you still need a hand-written extension