cheat sheet
cffi
Package-level reference for cffi on PyPI — install, ABI vs API modes, build patterns, and integration with system libraries.
cffi
What it is
cffi is the C Foreign Function Interface for Python — a library by Armin Rigo (PyPy author) and Maciej Fijałkowski for calling C code from Python without writing CPython extension modules by hand. It is the binding layer underneath cryptography, pynacl, bcrypt, argon2-cffi, psycopg2-binary's alternative psycopg, and dozens of other packages that wrap C libraries.
Reach for cffi when you need to: call a function from libc, wrap a third-party C library, or build a small native helper for a hot loop. Two modes are supported — ABI mode (parse C declarations at runtime; no compile step) for quick one-off bindings, and API mode (ffi.set_source + compile-time wrapper) for production-grade modules.
Install
pip install cffi
Output: (none — exits 0 on success)
uv add cffi
Output: resolved + added to pyproject.toml
poetry add cffi
Output: updated lockfile + virtualenv install
The wheel ships compiled binaries for CPython and PyPy on all supported platforms. Source builds need libffi-dev (Linux) or the equivalent (brew install libffi on macOS, the Visual Studio Build Tools on Windows).
Versioning & Python support
- Current line is the
1.16.x/1.17.xseries in 2025-26. - Supports CPython 3.8+ and PyPy3 8.x+ on recent releases.
cffiis in maintenance mode — feature stable, with security and platform-compat releases. Major API changes are very rare; pincffi>=1.16,<2and forget about it.- Free-threaded Python (3.13t) builds work but the project does not yet make GIL-freedom guarantees.
Package metadata
- Maintainer: Armin Rigo, Maciej Fijałkowski, with PyPy / community contributors
- Project home: github.com/python-cffi/cffi
- Docs: cffi.readthedocs.io
- PyPI: pypi.org/project/cffi
- License: MIT
- Governance: PyPy community; long-term stable maintenance
- First released: 2013
- Downloads: hundreds of millions per month (as a transitive dep of
cryptography)
Optional dependencies & extras
cffi has no PyPI extras. Its only hard runtime dep is pycparser (used to parse C declarations).
System build deps differ per platform:
- Linux:
libffi-dev+ a C compiler. The wheel includes a vendored libffi on manylinux. - macOS:
libffi(preinstalled or via Homebrew). - Windows: Visual Studio Build Tools (only for source builds; wheels are typical).
Alternatives
| Package | Trade-off |
|---|---|
ctypes (stdlib) | Pure-Python ABI binding; zero install. Fewer guardrails, no struct generation, slower for large interfaces. |
cython | Source-to-C transpiler; lets you write Python-ish syntax that compiles to a C extension. Larger learning curve. |
pybind11 | C++-first; the de-facto binding tool for modern C++ libraries. Header-only. |
nanobind | pybind11 successor; lower overhead and faster compile. C++17+. |
PyO3 | Rust-first; used by cryptography's newer surfaces and polars. |
swig | Multi-language; older; rarely chosen for new projects. |
mypyc | Compile typed Python directly to a C extension; not a binding tool but adjacent. |
Common gotchas
- ABI mode is fragile across platforms. Calling
libcwith raw declarations breaks when the upstream changes a struct layout. Use ABI mode for prototypes only; ship API mode. ffi.cdefis NOT a full C parser. It accepts a strict subset — no preprocessor directives (#include,#ifdef), no inline functions, no GCC attributes. Strip them or use API mode withset_source(... include_dirs=...).set_sourcebuilds a compile-time wrapper. That generates a.cfile, compiles it, and produces a_module.so. Forgettingffi.compile()(or the equivalent in asetup.py) means no module to import.new(...)allocates ON THE PYTHON GC. The returned cdata is GC-managed; pass it to C ascdataand the lifetime is the Python wrapper's lifetime. Keep a Python reference alive for as long as C holds the pointer.- Strings are bytes.
ffi.new("char[]", b"hello")is correct."hello"(str) raisesTypeError. Use.encode()at the boundary. ffi.string(p)reads until NUL. For non-NUL-terminated buffers, slice withbytes(ffi.buffer(p, n)).- Callbacks need lifetime management.
ffi.callback(...)returns a cdata you must keep alive; if the callback fires after the cdata is GC'd, the process crashes. - macOS arm64 vs x86_64 wheels. A universal2 wheel does the right thing; a single-arch wheel under Rosetta does not. Verify with
python -c "import platform; print(platform.machine())".
Real-world recipes
The recipes below walk the two modes (ABI and API), a verify-mode hybrid, embedding C source, and pointer casts — the common patterns when wrapping a small or medium C library.
Recipe 1 — ABI mode: call sqrt from libm.
from cffi import FFI
ffi = FFI()
ffi.cdef("double sqrt(double x);")
libm = ffi.dlopen("m") # libm.so / .dylib / on Windows: math is in msvcrt
print(libm.sqrt(2.0))
Output: 1.4142135623730951 — ABI mode parses the declaration at runtime, no compile step.
Recipe 2 — API mode: declare a struct, allocate, and pass to C.
# build_helper.py — run once: `python build_helper.py`
from cffi import FFI
ffi = FFI()
ffi.cdef("typedef struct { int x; int y; } point; int sum_xy(point *p);")
ffi.set_source("_helper", """
typedef struct { int x; int y; } point;
int sum_xy(point *p) { return p->x + p->y; }
""")
ffi.compile()
Output: _helper.c, _helper.<tag>.so generated. Then:
from _helper import ffi, lib
p = ffi.new("point *", [3, 4])
print(lib.sum_xy(p))
Output: 7 — typed struct allocated by Python, read by C.
Recipe 3 — Verify mode: ensure declarations match the real header.
from cffi import FFI
ffi = FFI()
ffi.cdef("""
typedef struct { int year; int month; int day; } date_t;
int compute_jdn(const date_t *d);
""")
ffi.set_source("_dates", '#include "dates.h"', sources=["dates.c"])
ffi.compile()
Output: the build fails loudly if dates.h doesn't define date_t exactly as declared. Catches struct-drift between Python and C.
Recipe 4 — Embedding a C string buffer and exposing it to Python.
from cffi import FFI
ffi = FFI()
ffi.cdef("const char *greet(void);")
ffi.set_source("_greet", 'const char *greet(void) { return "Hello, Alice Dev"; }')
ffi.compile()
from _greet import ffi as ffi2, lib
print(ffi2.string(lib.greet()).decode())
Output: Hello, Alice Dev — ffi.string reads the NUL-terminated C string into Python bytes.
Recipe 5 — Pointer cast: reinterpret a buffer as uint8_t[].
from cffi import FFI
ffi = FFI()
buf = ffi.new("char[16]", b"\x01\x02\x03\x04")
as_u8 = ffi.cast("uint8_t *", buf)
print([as_u8[i] for i in range(4)])
Output: [1, 2, 3, 4] — view the same memory as unsigned bytes.
Production deployment notes
- Always API-mode for shipped libraries. ABI mode is fine for scripts but breaks under symbol-versioning differences between OS distributions.
- Build wheels per platform. Use
cibuildwheelto produce manylinux + macOS + Windows wheels in CI; never rely on sdist + source build at install time in production. - Pin
pycparserindirectly.cffipulls it in; a pin in yourrequirements.txtonly matters during source builds. - Vendor-bundled libffi vs system libffi. Manylinux wheels vendor libffi. If you build from source on a host without libffi-dev, the build fails with
pkg-config --cflags libffierrors. - Strip debug symbols. Use
setup.py'sbuild_extwith-Wl,--strip-all(Linux) to keep wheel size down.
Performance tuning
- API mode is ~10× faster than ABI mode for the same call surface because dispatch is statically resolved.
- Batch small calls. Crossing the FFI boundary is fast (~50-100 ns) but not free; aggregate work in C.
- Use
ffi.buffer(p, n)for bulk memory — zero-copy view into Python memoryview / bytes. - Release the GIL in C callbacks if work is long-running.
Py_BEGIN_ALLOW_THREADSinside the C source, or useffi.embedding_init_codepatterns. ffi.new("char[N]")vsffi.new_allocator()— for tight loops, the custom allocator avoids GC overhead.
Version migration guide
< 1.14— pre-Python-3.9 era. Upgrade.1.14 → 1.15—set_sourcedefaults tightened; someextra_compile_argssemantics changed.1.15 → 1.16—cffi.recompilerrewrites; minor source-compat tweaks.1.16 → 1.17— Python 3.13 support added; some deprecation warnings now errors.
# Pre-1.15: no explicit packaging args needed
ffi.set_source("_mod", "...")
# 1.16+: pass packaging hints if cross-compiling
ffi.set_source("_mod", "...", py_limited_api="cp38") # stable ABI wheel
Output: stable-ABI wheel — one binary works across all CPython 3.8+ versions.
Security considerations
- C is unsafe.
cffidoesn't add memory safety — out-of-bounds writes still crash or corrupt the process. dlopenpaths are an attack surface.ffi.dlopen("m")searchesLD_LIBRARY_PATH(or PATH on Windows); a malicious shared object planted there is loaded. Use absolute paths in security-sensitive code.- Callbacks executed from C can leak Python exceptions. Wrap callback bodies in
try/exceptand translate to a sentinel return value. set_source(..., sources=[...])is a build-time arbitrary-code vector. Don't feed user input into the source list.- NUL-byte injection in
ffi.string()returns truncated data. Validate inputs at the Python boundary, not the C boundary.
Testing & CI integration
- Test both ABI and API modes if you support both; the compiled extension can drift from the runtime declarations.
- Use
pytest'stmp_pathfixture to compile API-mode modules into a fresh directory per test. - For wheels, run a real install + import in a clean venv as a CI step — sdist installs catch declarations missing from the bundled header set.
# tests/test_helper.py
import subprocess, sys, pathlib
def test_helper_builds(tmp_path):
src = tmp_path / "build.py"
src.write_text("""
from cffi import FFI
ffi = FFI()
ffi.cdef("int add(int a, int b);")
ffi.set_source("_h", "int add(int a, int b) { return a + b; }")
ffi.compile()
""")
subprocess.check_call([sys.executable, str(src)], cwd=tmp_path)
assert any(p.suffix in {".so", ".pyd"} for p in tmp_path.iterdir())
Output: test passes; verifies the build pipeline produces a loadable extension.
Ecosystem integrations
cryptography—cffiis the glue to bundled libcrypto.pynacl— wraps libsodium.bcrypt— wraps libbcrypt.argon2-cffi— Argon2 password hashing.xattr,pyobjc,psycopg— selected uses across the ecosystem.pycparser— the C declaration parser; ships as acffidep.milksnake—cffi-based wheel builder for Rust extensions (largely superseded by PyO3 + maturin).
Compatibility matrix
| Python | cffi line | Notes |
|---|---|---|
| 3.7 | 1.15 and earlier | EOL'd; CVE-only patches. |
| 3.8 | 1.16+ | Floor for recent releases. |
| 3.9 | 1.16+ | Supported. |
| 3.10 | 1.16+ | Supported. |
| 3.11 | 1.16+ | Improved perf via faster argument unpack. |
| 3.12 | 1.16+ | Supported. |
| 3.13 | 1.17+ | GIL builds only; free-threaded experimental. |
Troubleshooting common errors
| Error / Symptom | Likely cause | Fix |
|---|---|---|
OSError: cannot load library 'm' | Wrong library name for OS | Linux: m; macOS: m; Windows: msvcrt for math. Use ffi.dlopen(None) for the main process. |
cffi.CDefError: parse error | Used preprocessor directives or unsupported C constructs in cdef | Strip #include / #ifdef; or move to API mode with set_source. |
OSError: ... undefined symbol | Declared a function that the lib doesn't export | Check nm or dumpbin /exports; verify spelling. |
Segmentation fault after Python GC | C is holding a freed pointer | Keep a Python reference to the cdata for as long as C uses it. |
RuntimeError: function not implemented from callback | Callback raised an exception that propagated to C | Catch inside the callback; return a sentinel. |
pycparser errors on real headers | #include <stdio.h> not handled | Use the pycparser fake-headers trick or move to API mode. |
Build fails with fatal error: ffi.h | Missing system libffi | apt-get install libffi-dev / brew install libffi. |
When NOT to use this
- Pure-Python is fast enough. Don't add a C dep for code that doesn't matter; the install / build friction is real.
- You need C++ classes. Use
pybind11ornanobind;cffionly handles C ABI. - You're writing a new native library. Write Rust + PyO3 + maturin; modern, safe, fast wheels.
- You only need POSIX syscalls.
ctypesis in the stdlib and avoids the build dep. - Embedded systems.
cffirequires libffi and a compiler in the build env; not always available.
Worked example: wrap a small C library and ship a wheel
The end-to-end "I have a libfoo.so and want a Python binding" path looks like this. Build the binding in _foo_build.py, register it as a setup.py build step, and cibuildwheel to fan out across platforms.
Step 1 — write the build script (_foo_build.py).
from cffi import FFI
ffi = FFI()
ffi.cdef("""
int foo_compute(const char *input, int length);
const char *foo_version(void);
""")
ffi.set_source(
"_foo",
'#include "foo.h"',
libraries=["foo"], # link against libfoo
library_dirs=["/usr/local/lib"],
include_dirs=["/usr/local/include"],
)
if __name__ == "__main__":
ffi.compile(verbose=True)
Output: running python _foo_build.py emits _foo.c and a compiled _foo.<tag>.so.
Step 2 — wire it into setup.py so wheels build the extension at install time.
from setuptools import setup
setup(
name="pyfoo",
version="0.1.0",
py_modules=["pyfoo"],
cffi_modules=["_foo_build.py:ffi"],
setup_requires=["cffi>=1.16"],
install_requires=["cffi>=1.16"],
)
Output: pip install . builds the extension and installs it in the venv.
Step 3 — write the Python-side wrapper (pyfoo.py) that's the user-facing surface.
from _foo import ffi, lib
def compute(text: str) -> int:
encoded = text.encode("utf-8")
return lib.foo_compute(encoded, len(encoded))
def version() -> str:
return ffi.string(lib.foo_version()).decode("utf-8")
Output: users get pyfoo.compute("hello") and pyfoo.version() — no FFI artifacts leak.
Step 4 — build wheels for every supported platform with cibuildwheel.
# .github/workflows/wheels.yml (abbreviated)
- uses: pypa/cibuildwheel@v2
env:
CIBW_BEFORE_BUILD: "pip install cffi"
CIBW_BUILD: "cp39-* cp310-* cp311-* cp312-* cp313-*"
Output: manylinux + macOS + Windows wheels, ready to upload to PyPI with twine. The system C library (libfoo) needs to be present on every build runner — either install it via the workflow or use a custom Docker image.
FAQ
Q: How do I pass a Python list to a C function that expects int *?
A: Allocate a C array, copy, pass:
arr = ffi.new("int[]", [1, 2, 3, 4])
lib.process_ints(arr, 4)
Output: C sees a contiguous int[4]; the Python list is unaffected.
Q: Can I call Python from C via a callback?
A: Yes — @ffi.callback("type signature") decorates a Python function and returns a cdata you pass to C. Keep the cdata alive (a module-level variable is safest). When the callback fires, your Python code runs holding the GIL.
Q: API mode says "cannot find header". Fix?
A: Add include_dirs=["/path/to/headers"] to set_source. If the lib is installed via pkg-config, run pkg-config --cflags libfoo and pass the returned -I paths.
Q: How do I handle errno?
A: ffi.errno reads/writes the thread-local errno. Call it immediately after the C call; intervening Python code can clobber it.
Q: Can I link against a static library?
A: Yes — pass extra_link_args=["-L/path", "-l:libfoo.a"] (Linux) or use platform-specific linker syntax. Static linking is usually a wheel-build choice, not a per-import one.
Q: Does cffi work with PyPy? A: Yes, and very well — cffi was developed by the PyPy team partly to provide a fast FFI for PyPy. The same code typically runs unchanged on CPython and PyPy.
Cdata lifetime: the rule you must internalize
The single most common cffi bug is dangling C pointers. The rule:
A
cdataobject lives as long as the Python reference to it. When that reference is dropped, the underlying memory is freed. If C code holds a copy of the pointer, the next access crashes (or worse, corrupts memory).
Concretely:
# WRONG — buf is GC'd before C reads it
def get_pointer():
buf = ffi.new("char[64]", b"data")
return buf # the cdata is still alive here...
ptr = get_pointer() # ...and stays alive across the call boundary
# Subtler: passing a fresh allocation inline
lib.async_callback(ffi.new("ctx_t *", [1, 2])) # cdata GC'd as soon as the call returns
Fix: keep a Python-side reference for as long as C needs it:
ctx = ffi.new("ctx_t *", [1, 2])
lib.async_callback(ctx)
# Hold `ctx` in a module-level dict / set until C calls back signaling "done".
Output: the memory survives across the async boundary; C reads it safely.
For long-lived buffers, prefer ffi.gc(cdata, finalizer) — a cdata wrapper that runs a deallocator on GC. Useful when wrapping a library that has its own foo_free:
raw = lib.foo_alloc(1024)
managed = ffi.gc(raw, lib.foo_free)
# managed runs lib.foo_free(raw) automatically when the wrapper is collected.
Output: explicit deterministic deallocation; no manual try/finally needed for cleanup.
See also
- Concept: API — designing the Python boundary above a C library