Cloud Parts

Foreign function interfaces

How code in one language calls functions compiled from another: both sides meet at the platform’s C calling convention, and glue code converts values and settles who frees what.

1 technology on this page ↓

How it works

Every compiler decides for itself how functions receive arguments, how structs are laid out and how symbols are named, so two languages can only call each other if they agree on one set of rules. In practice that set is the platform’s C ABI. Its calling convention says where arguments go: on x86-64 Linux and macOS the first six integer or pointer arguments travel in RDI, RSI, RDX, RCX, R8 and R9; on 64-bit Windows the first four go in RCX, RDX, R8 and R9. Its type rules fix how big an int is and where each struct field sits. A library exposes plain C functions (C++ wraps them in extern "C" to turn off name mangling, Rust marks them extern "C" and #[unsafe(no_mangle)]), and the caller loads the shared library and looks the symbols up by name. The compiled library doesn’t record argument types, so the caller has to declare every signature: by hand (ctypes argtypes), from C declarations (cffi, Java’s FunctionDescriptor), or from attributes a generator reads (.NET’s [LibraryImport], PyO3’s #[pyfunction]). Only numbers and pointers cross as they are. Everything else is marshalled: strings are re-encoded and copied, lists become arrays, objects become opaque handles. Every pointer that crosses also raises the question of ownership: memory must go back to the allocator that created it, and a garbage collector must be told while native code still holds one of its objects. Runtimes add their own rules on top. In CPython only a thread holding the global interpreter lock (GIL) may touch Python objects, so long native work should release it, and a Rust panic or C++ exception must not unwind into a frame that isn’t built for it.

Think of it as

A container port. Ships from any country can unload there because the boxes have one standard size and the cranes one standard grip: that is the C ABI. What’s inside still has to be repacked for local trucks, and both sides have to agree on who returns the empty boxes.

Reach for it when

  • Reusing a mature native library (SQLite, a codec, a numeric kernel) instead of rewriting it
  • Moving a hot loop from Python, Java or JavaScript into Rust, C or C++ while the rest of the program stays put
  • Calling operating system APIs that are only published as C functions
  • Shipping one core library to several languages through thin bindings, as Polars does for Python and Node.js

Think twice when

  • Many tiny calls: converting arguments on every call can cost more than the work itself, so batch the work into fewer, larger calls
  • Untrusted or crash-prone native code: a wrong signature or a dangling pointer corrupts the whole process, so run it in another process behind RPC
  • The two sides only exchange data now and then: a file, a pipe or an HTTP call is easier to debug and version
  • You can’t build native binaries for every platform your users run: each OS, CPU and runtime version needs its own build

A real example

A Python pipeline parses log lines in a pure-Python loop. The team rewrites the parser in Rust as parse(line: &str) -> Result<Record, ParseError>, exposes it with PyO3’s #[pyfunction] and builds a wheel with maturin. Python still opens the files and calls parse(). PyO3 converts each str into a &str, returns a Record as a Python object, and turns every Err into a raised ValueError. Inside, the function calls py.detach so the GIL is released while Rust works, and four Python threads can parse at once. Crossing the boundary once per line is the next bottleneck, so the following version takes a list of lines per call.

Technologies

Specific products, and what each does differently.

Two calling conventions on x86-64#

The same C function compiles to different machine code on Windows and on everything else. A foreign function interface has to follow whichever one the platform uses, which is why bindings are built per OS and CPU.

System V AMD64 ABIMicrosoft x64
Used byLinux, macOS, the BSDs, Solaris64-bit Windows
Integer and pointer argumentsFirst six in RDI, RSI, RDX, RCX, R8, R9First four in RCX, RDX, R8, R9
Floating-point argumentsFirst eight in XMM0 to XMM7In XMM0 to XMM3, by position among the first four
The restOn the stackOn the stack, after the “shadow space” the caller always reserves for the four register arguments
Integer return valueRAX (and RDX for up to 128 bits)RAX
  • 32-bit Windows had several conventions at once (cdecl, stdcall, fastcall), which is why Python’s ctypes has both CDLL and WinDLL, and Rust offers extern "system", which picks stdcall on 32-bit x86 Windows and the C convention on x86-64 Windows.

A Rust library that speaks the C ABI#

Built with crate-type = ["cdylib"] in Cargo.toml and cargo build --release, this produces libgreet.so (libgreet.dylib on macOS, greet.dll on Windows) that C, Python, Go or Java can load. greet hands out memory that Rust allocated, so the library also exports the function that frees it.

rust
use std::ffi::{CStr, CString, c_char};

#[unsafe(no_mangle)] // keep the symbol name `add`, as C would
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}

/// Returns a string Rust allocated; the caller must hand it back to `greet_free`.
#[unsafe(no_mangle)]
pub extern "C" fn greet(name: *const c_char) -> *mut c_char {
    let name = unsafe { CStr::from_ptr(name) }.to_string_lossy();
    CString::new(format!("hello, {name}")).unwrap().into_raw()
}

#[unsafe(no_mangle)]
pub extern "C" fn greet_free(s: *mut c_char) {
    if !s.is_null() {
        drop(unsafe { CString::from_raw(s) }); // Rust frees what Rust allocated
    }
}

Calling it from Python, and giving the memory back#

Run from the crate’s directory after the build. Declaring greet’s result as c_char_p would look simpler, but ctypes would copy the string into bytes and drop the pointer, so Rust’s allocation could never be freed.

python
import ctypes, sys

ext = {"darwin": "dylib", "win32": "dll"}.get(sys.platform, "so")
lib = ctypes.CDLL(f"target/release/{'' if ext == 'dll' else 'lib'}greet.{ext}")

lib.add.argtypes = [ctypes.c_int32, ctypes.c_int32]
lib.add.restype = ctypes.c_int32
print(lib.add(2, 40))  # 42

lib.greet.argtypes = [ctypes.c_char_p]
lib.greet.restype = ctypes.c_void_p  # keep the raw address
lib.greet_free.argtypes = [ctypes.c_void_p]

ptr = lib.greet(b"ffi")
print(ctypes.string_at(ptr).decode())  # hello, ffi
lib.greet_free(ptr)  # without this the string leaks: Python's GC never sees it

Binding tools, from hand-written to generated#

They differ in where the signature comes from and who writes the conversion code.

ToolLanguagesYou describe the other side withWhat it does for you
`ctypes`Python calls Cargtypes and restype set in Python at run timeCalls through libffi, no compiler needed; releases the GIL during each call. A wrong declaration crashes the process.
CFFIPython calls CC declarations pasted as a stringABI mode calls through libffi like ctypes; API mode compiles a C wrapper against the real headers, which its authors call “massively faster” and safer.
PyO3Rust and Python, both ways#[pyfunction], #[pyclass], #[pymodule] attributesGenerates a CPython extension module: argument parsing, conversions, Err to exception, panics to PanicException.
JNIJava and C or C++, both waysnative methods in Java plus a matching C headerNothing automatic: C code reads fields through JNIEnv calls and manages local and global references by hand.
Java FFM APIJava calls CFunctionDescriptor and MemoryLayout objects in JavaLinker turns a native symbol into a MethodHandle; an Arena frees the native memory it allocated when it closes. Final in Java 22.
P/Invoke.NET calls C, with callbacks back[LibraryImport] on a static partial methodSince .NET 7 a source generator writes the marshalling code at compile time; the older [DllImport] builds an IL stub at run time, which ahead-of-time compilation can’t use.
wasm-bindgenRust compiled to WebAssembly and JavaScript, both ways#[wasm_bindgen] attributesExports Rust functions and classes to JavaScript, imports JavaScript APIs into Rust, generates TypeScript types, and copies strings between the JavaScript heap and Wasm linear memory.

Where FFI code breaks#

The compiler on each side checks only its own half, so these bugs show up as crashes or leaks at run time.

A wrong signature

crash or garbage

A shared library carries names, not types. ctypes assumes every function returns a C int unless told otherwise, so a function that returns a pointer or a double hands back nonsense, and passing the wrong argument types can corrupt memory.

Collected too early

use after free

A garbage-collected runtime doesn’t know native code kept a reference. Python’s docs warn that a CFUNCTYPE callback object must be kept alive while C can call it; JNI frees local references when the native method returns, and only global references survive until freed.

Freed by the wrong allocator

heap corruption

Memory goes back to whoever allocated it. A pointer from Rust’s CString::into_raw must return to Rust through CString::from_raw, never to C’s free(), which is why libraries export their own _free functions.

Unwinding across the boundary

abort or undefined behaviour

A Rust panic that reaches an extern "C" frame aborts the process, and a C++ exception that enters Rust is undefined behaviour. Catch panics with catch_unwind at the edge, or use the "C-unwind" ABI when unwinding is meant to cross.

Strings

encoding

C wants NUL-terminated bytes, Rust strings aren’t terminated (hence CString), Java and JavaScript use UTF-16. wasm-bindgen copies each string and replaces unpaired UTF-16 surrogates with U+FFFD, so the Rust string can differ from the JavaScript one.

Threads and the GIL

deadlock or no speedup

Only a thread holding CPython’s GIL may touch Python objects. Native code should release it around long work (Py_BEGIN_ALLOW_THREADS, PyO3’s detach). On the free-threaded build, importing an extension that hasn’t declared it is safe without the GIL turns the GIL back on.

In code · python

import ctypes, ctypes.util

libc = ctypes.CDLL(ctypes.util.find_library("c"))  # the C library, loaded at run time

# Nothing in the library says what strlen takes or returns, so we declare it.
libc.strlen.argtypes = [ctypes.c_char_p]
libc.strlen.restype = ctypes.c_size_t
print(libc.strlen(b"hello, C"))  # 8: bytes are passed as a char* to their data

# qsort sorts a C array in place and calls back into Python to compare elements.
CMP = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int))
def compare(a, b):
    return a[0] - b[0]

nums = (ctypes.c_int * 5)(5, 1, 4, 2, 3)  # a C int[5], not a Python list
libc.qsort.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_size_t, CMP]
libc.qsort.restype = None
libc.qsort(nums, len(nums), ctypes.sizeof(ctypes.c_int), CMP(compare))
print(list(nums))  # [1, 2, 3, 4, 5]

Often confused with

Where it came from1997–2025

Programs have always needed code written in another language: operating system services published as C functions, numeric libraries, drivers. Wikipedia traces the name “foreign function interface” to the Common Lisp standard; Haskell, Rust, Python and LuaJIT use it too, while Java calls its version the Java Native Interface. Most of these interfaces settled on the same meeting point. The Haskell 2010 report, which made its FFI part of the language standard, specifies only the C calling convention, and adds types such as CInt because C doesn’t fix how wide an int is.

Java shows how the design matured. JDK 1.0 shipped a native method interface that let C code read Java fields as members of C structs and relied on a conservative garbage collector that scanned the native stack. Both tied native libraries to one VM’s object layout and collector, so other Java vendors couldn’t adopt it. JNI, part of Java since 1.1 in 1997, replaced direct field access with function calls and opaque local and global references, so the VM can move objects and knows which ones native code still holds. It was portable but laborious: a Java declaration, a C header generated from it, and C glue for every function.

Dynamic languages took a lighter route. Python 2.5 added ctypes in 2006, which calls through libffi and can reach any C function from a signature described at run time. CFFI’s authors later argued that working at that binary level is fragile, because C libraries promise an API, not an exact layout, and let you compile a small wrapper from the real headers instead. The newest tools generate both sides from annotations in a typed language: .NET 7’s P/Invoke source generator in 2022, PyO3 and wasm-bindgen from Rust attributes, and Java 22’s Foreign Function & Memory API in 2024, written to replace what its JEP calls the “brittle machinery” of JNI with plain Java.

Why it’s built this way

Meet at the C ABI
Every language can already produce and call C functions, and operating systems publish their APIs that way. The price is that classes, exceptions and generics have to be flattened into functions, pointers and integers, and only code that follows the platform’s C convention can be called: JNI, for one, can’t reach a function compiled with any other convention.
Opaque handles instead of raw object pointers
JDK 1.0 showed that letting C code read objects as structs breaks when a VM lays objects out differently, and forces the collector to scan native stacks. JNI’s references let the garbage collector move objects while native code holds them, at the cost of a function call per field access.
Describe the C side from headers, not by hand
A signature typed into ctypes is a guess the library can’t check. CFFI’s API mode runs the C compiler over the real declarations, so struct layouts and types come from the headers, and the result calls the function directly instead of through libffi.
Make the danger visible
A foreign call can break every guarantee of the calling language. Rust treats every foreign function as unsafe, so each call sits in an unsafe block, and Java’s FFM API lets programs use restricted native methods but warns about them by default.

Timeline

  1. 1997

    JDK 1.1 ships with the Java Native Interface

  2. 2006

    Python 2.5 adds ctypes, written by Thomas Heller, to the standard library

  3. 2010

    Haskell 2010 makes the foreign function interface part of the language standard

  4. 2022

    .NET 7 generates P/Invoke marshalling code at compile time with [LibraryImport]

  5. 2024

    Java 22 finalises the Foreign Function & Memory API (JEP 454) in March

  6. 2025

    Python 3.14 makes the free-threaded build officially supported; extensions declare whether they still need the GIL

Sources23

Improve this page on GitHub