cheat sheet

cffi

Package-level reference for cffi on PyPI — install, ABI vs API modes, build patterns, and integration with system libraries.

#pip#package#binding#c#ffiupdated 05-31-2026

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

bash
pip install cffi

Output: (none — exits 0 on success)

bash
uv add cffi

Output: resolved + added to pyproject.toml

bash
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.x series in 2025-26.
  • Supports CPython 3.8+ and PyPy3 8.x+ on recent releases.
  • cffi is in maintenance mode — feature stable, with security and platform-compat releases. Major API changes are very rare; pin cffi>=1.16,<2 and 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

PackageTrade-off
ctypes (stdlib)Pure-Python ABI binding; zero install. Fewer guardrails, no struct generation, slower for large interfaces.
cythonSource-to-C transpiler; lets you write Python-ish syntax that compiles to a C extension. Larger learning curve.
pybind11C++-first; the de-facto binding tool for modern C++ libraries. Header-only.
nanobindpybind11 successor; lower overhead and faster compile. C++17+.
PyO3Rust-first; used by cryptography's newer surfaces and polars.
swigMulti-language; older; rarely chosen for new projects.
mypycCompile typed Python directly to a C extension; not a binding tool but adjacent.

Common gotchas

  1. ABI mode is fragile across platforms. Calling libc with raw declarations breaks when the upstream changes a struct layout. Use ABI mode for prototypes only; ship API mode.
  2. ffi.cdef is 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 with set_source(... include_dirs=...).
  3. set_source builds a compile-time wrapper. That generates a .c file, compiles it, and produces a _module.so. Forgetting ffi.compile() (or the equivalent in a setup.py) means no module to import.
  4. new(...) allocates ON THE PYTHON GC. The returned cdata is GC-managed; pass it to C as cdata and the lifetime is the Python wrapper's lifetime. Keep a Python reference alive for as long as C holds the pointer.
  5. Strings are bytes. ffi.new("char[]", b"hello") is correct. "hello" (str) raises TypeError. Use .encode() at the boundary.
  6. ffi.string(p) reads until NUL. For non-NUL-terminated buffers, slice with bytes(ffi.buffer(p, n)).
  7. 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.
  8. 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.

python
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.

python
# 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:

python
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.

python
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.

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 Devffi.string reads the NUL-terminated C string into Python bytes.

Recipe 5 — Pointer cast: reinterpret a buffer as uint8_t[].

python
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 cibuildwheel to produce manylinux + macOS + Windows wheels in CI; never rely on sdist + source build at install time in production.
  • Pin pycparser indirectly. cffi pulls it in; a pin in your requirements.txt only 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 libffi errors.
  • Strip debug symbols. Use setup.py's build_ext with -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_THREADS inside the C source, or use ffi.embedding_init_code patterns.
  • ffi.new("char[N]") vs ffi.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.15set_source defaults tightened; some extra_compile_args semantics changed.
  • 1.15 → 1.16cffi.recompiler rewrites; minor source-compat tweaks.
  • 1.16 → 1.17 — Python 3.13 support added; some deprecation warnings now errors.
python
# 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. cffi doesn't add memory safety — out-of-bounds writes still crash or corrupt the process.
  • dlopen paths are an attack surface. ffi.dlopen("m") searches LD_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/except and 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's tmp_path fixture 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.
python
# 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

  • cryptographycffi is 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 a cffi dep.
  • milksnakecffi-based wheel builder for Rust extensions (largely superseded by PyO3 + maturin).

Compatibility matrix

Pythoncffi lineNotes
3.71.15 and earlierEOL'd; CVE-only patches.
3.81.16+Floor for recent releases.
3.91.16+Supported.
3.101.16+Supported.
3.111.16+Improved perf via faster argument unpack.
3.121.16+Supported.
3.131.17+GIL builds only; free-threaded experimental.

Troubleshooting common errors

Error / SymptomLikely causeFix
OSError: cannot load library 'm'Wrong library name for OSLinux: m; macOS: m; Windows: msvcrt for math. Use ffi.dlopen(None) for the main process.
cffi.CDefError: parse errorUsed preprocessor directives or unsupported C constructs in cdefStrip #include / #ifdef; or move to API mode with set_source.
OSError: ... undefined symbolDeclared a function that the lib doesn't exportCheck nm or dumpbin /exports; verify spelling.
Segmentation fault after Python GCC is holding a freed pointerKeep a Python reference to the cdata for as long as C uses it.
RuntimeError: function not implemented from callbackCallback raised an exception that propagated to CCatch inside the callback; return a sentinel.
pycparser errors on real headers#include <stdio.h> not handledUse the pycparser fake-headers trick or move to API mode.
Build fails with fatal error: ffi.hMissing system libffiapt-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 pybind11 or nanobind; cffi only handles C ABI.
  • You're writing a new native library. Write Rust + PyO3 + maturin; modern, safe, fast wheels.
  • You only need POSIX syscalls. ctypes is in the stdlib and avoids the build dep.
  • Embedded systems. cffi requires 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).

python
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.

python
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.

python
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.

yaml
# .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:

python
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 cdata object 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:

python
# 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:

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

python
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