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.

ctypes call flow from Python through _ctypes and libffi to libadd.so


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_addtest_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:

  1. CDLLdlopen("libadd.so"), dlsym("add") → raw function pointer.
  2. lib.add(2, 3)_FuncPtr callable (ctypes.CDLL.__init__.<locals>._FuncPtr), not builtin_function_or_method from Part II’s PyCFunctionObject.
  3. _ctypes converts Python int → C int, calls through libffi, returns result as Python int.

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.

CFFI ABI in-line vs API out-of-line paths

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_abitest_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_apiadd_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

Three approaches: ctypes, CFFI, hand-written C 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