Skip to content

FFI - calling C from everything

Why it matters

Sooner or later every runtime needs to call a C library - CUDA, OpenSSL, SQLite, libcurl, RocksDB, the kernel's syscall surface. The mechanism for doing so (the Foreign Function Interface) is one of the highest-leverage and highest-risk parts of any language. Get it right and you get the C ecosystem for free; get it wrong and you get segfaults, ABI breakage, and memory bugs that crash the host runtime, not just your code.

Every path on this site covers FFI from its language's angle. The contrasts are dramatic - Rust's extern "C" and Java's Panama are 30 years apart in design.


The lens, per path

Rust - extern "C", the most direct

Month 4 - Unsafe, FFI, Macros. extern "C" fn declarations, #[repr(C)] structs, bindgen to auto-generate bindings from C headers, cbindgen for the reverse direction. FFI calls cost roughly one indirect function call - no marshaling, no thread state transition.

The unique thing here: FFI is essentially zero-cost. Rust's calling convention and C's are the same for extern "C" functions; there's no JIT to bail out of, no GC to coordinate with.

The trap

lifetimes don't cross the FFI boundary. A *const T from C has no lifetime - it's the caller's responsibility to ensure the pointer outlives every use. Most FFI bugs in Rust are use-after-free where the C-side allocation outlived the Rust borrow's assumption.

Go - cgo, "convenient until it isn't"

Month 4 - Reflection, Codegen, Plugins. import "C" with a special comment-as-prelude. The cgo compiler generates Go ↔ C glue.

The unique thing here: cgo calls are expensive. Each call transitions the goroutine out of the Go scheduler (locks an OS thread for the duration), runs C, transitions back. Cost: hundreds of nanoseconds per call vs. ~5ns for a Go function call.

The trap

"cgo is not Go." A cgo-heavy Go program loses many of Go's properties - escape analysis can't see across the boundary, GC has to be conservative, cross-compilation gets harder, the runtime's preemption story changes. Production wisdom: batch cgo calls to amortize the transition cost; or pick Rust.

Java - Panama (Foreign Function & Memory API), the modern way

Month 2 - JVM & Bytecode Week 7 (introduction); referenced from Months 4-5. java.lang.foreign.MemorySegment, MemoryLayout, Linker.nativeLinker(). Stable in JDK 22+.

The unique thing here: Panama replaces JNI for ~all new code. JNI required writing C glue code, generated headers (javah/javac -h), manual reference management. Panama is declarative: describe the C function's signature in Java, get a MethodHandle you can call.

The legacy contrast: JNI is still everywhere in the ecosystem. New code should use Panama; old codebases should plan migration. JNI's safety properties are roughly Rust's unsafe block without the type system's help.

Python - ctypes, cffi, and the C API

Month 3 - Runtime & Performance. Three options: - ctypes (stdlib) - load a .so, declare argtypes/restype, call. Pure Python; slowest per call. - cffi - describe the C ABI in Python or by parsing headers, generate stubs. Faster than ctypes; the SQLAlchemy/cryptography ecosystem default. - C API extensions (PyObject*, Py_INCREF, the full CPython API) - fastest, most invasive, used by NumPy, PyTorch, and any perf-critical extension. PEP 703 (free-threading) is changing the rules here.

The unique thing here: Python is the most-used FFI consumer of any language on this list - the entire scientific Python stack is C/C++/Fortran behind a Python facade. Mastering FFI is non-optional for serious Python work.

The trap

the GIL during native calls. C extensions must explicitly Py_BEGIN_ALLOW_THREADS to release the GIL during long-running work, or they serialize everything else.

Other paths


The contrasts that teach

Aspect Rust extern "C" Go cgo Java Panama Java JNI (legacy) Python ctypes/cffi Python C API
Per-call overhead ~0 (ABI match) hundreds of ns tens of ns tens of ns μs (ctypes) / ~hundred ns (cffi) tens of ns
Memory safety unsafe block runtime checks type-checked layouts manual ref mgmt Python-side only full C API discipline
Build complexity bindgen optional compiler + cgo none beyond JDK C toolchain + glue none / cffi compile C toolchain + Python.h
Cross-compile excellent (cargo) painful (cgo) trivial (JVM is platform-portable) painful (per-platform .so) trivial (load at runtime) per-platform builds
Idiomatic for new code? yes sometimes (avoid if hot) yes no (legacy) yes (cffi > ctypes) for perf-critical only

The most clarifying read: Rust extern "C" + Java Panama side-by-side. Two languages with vastly different runtimes both decided "describe the C ABI declaratively in our language and let the compiler/JIT do the rest." It's the modern FFI pattern. Everything else is either lower-level (kernel syscalls, raw C API) or older (cgo, JNI, ctypes).


What to read first

  • You write Python and use NumPy/PyTorch/anything-with-a-C-core → Python Month 3, then go read the source of one extension you use daily.
  • You want fast FFI → Rust Month 4. The model is "FFI is free; safety is your job."
  • You write Java and need to call C → Java Month 2 Week 7 (Panama), then the Panama docs. Do not start with JNI.
  • You write Go and are considering cgo → Go Month 4, then measure. Most cgo usage that ships in production should be a separate Rust microservice.
  • You write any kernel-adjacent code → Linux Month 1. The syscall ABI is the FFI everyone else implements on top of.