Nano-FFI
Near-zero-overhead Python-to-Zig FFI. Type-safe call wrappers generated at comptime — no runtime type inspection, no branches in the hot path.
Nano-FFI lets Python call Zig functions at close to native speed. Where ctypes and cffi inspect argument types on every call, Nano-FFI uses Zig's comptime engine to generate a type-safe trampoline for each function at compile time. A scalar call costs about 90 ns — roughly 5× less overhead than ctypes and 2.5× less than cffi — with support for strings, bytes, zero-copy buffers, multi-value returns, and Zig-error-to-Python-exception mapping.
Zig performance. Python brain. Comptime safety.
Why Nano-FFI?
| Nano-FFI | ctypes |
cffi |
PyO3 / Cython | |
|---|---|---|---|---|
| Scalar call overhead | ~90 ns | ~440 ns | ~225 ns | compile-time typed |
| Type check location | compile time | every call | every call | compile time |
| Native language | Zig | any C ABI | any C ABI | Rust / C |
| Build step required | pre-built wheels | none | C compiler | Rust / C toolchain |
| Zero-copy buffers | yes | manual | manual | yes |
| Zig errors → Python exceptions | yes | no | no | n/a |
Overhead figures call the same native add on all three bridges, measured by this repo's benchmark on one machine; treat them as relative, not absolute.
If you write your native code in Zig and want the thinnest, fastest possible call path into it from Python, Nano-FFI is built for exactly that: the per-function trampoline is generated at comptime, so the hot path has no runtime type inspection and no branches to resolve.
Contents
- Why Nano-FFI?
- Install
- Quickstart
- Supported types
- How it works
- Benchmarks
- Build from source
- Architecture
- Roadmap · API reference
- License
Install
From PyPI (pre-built wheels, no Zig required):
pip install nano-ffi
Or build from source for the latest.
Quickstart
import nano_ffi
# Scalars
nano_ffi.call("add", 3, 4) # 7
nano_ffi.call("mul", 2.5, 4.0) # 10.0
# Strings (UTF-8 preserved)
nano_ffi.call("strlen", "hello") # 5
nano_ffi.call("echo", "Ñuñoa") # "Ñuñoa"
# Multiple return values -> tuple
q, r = nano_ffi.call("divmod", 17, 5) # (3, 2)
# Zero-copy: Zig writes straight into a Python buffer
buf = bytearray(4)
nano_ffi.call("fill", buf, 7) # buf -> bytearray(b'\x07\x07\x07\x07')
# Zig errors become Python exceptions
try:
nano_ffi.call("div", 10, 0)
except RuntimeError as e:
print(e) # "DivisionByZero"
# The module is self-describing
nano_ffi.list_functions() # ['add', 'mul', 'echo', ...]
nano_ffi.signature("add") # {'args': [('a','i64'),('b','i64')], 'ret': 'i64'}
Supported types
| Name | Zig type | Python type | Notes |
|---|---|---|---|
i64 i32 |
i64 i32 |
int |
range-checked narrowing |
u64 u32 u8 |
u64 u32 u8 |
int |
unsigned; out-of-range -> ValueError |
f64 f32 |
f64 f32 |
float |
|
bool |
bool |
bool |
|
str |
[]const u8 |
str |
borrowed UTF-8 in, copied out |
bytes |
[]const u8 |
bytes |
borrowed in, copied out |
buffer |
[]u8 |
writable buffer | zero-copy, in-place (argument only) |
Up to 8 arguments and 8 return values per call. Full reference: docs/API.md.
How it works
makeTrampoline runs at compile time. It unpacks each argument with a comptime inline for over the signature, so the compiler emits explicit typed assignments — not a runtime loop with per-element type tests. The dispatch path Python calls into has no branch left to resolve.
flowchart LR
A[Python args] --> B[registry lookup]
B --> C["comptime trampoline<br/>(branch-free unpack)"]
C --> D[Zig function]
D --> E[pack result / error]
E --> F[PyObject]
Only python_ext.zig touches <Python.h>; everything else is pure Zig over C-ABI-compatible types, so the core is unit-tested with no interpreter in the loop.
Benchmarks
Median per-call overhead, ReleaseFast, 100k iterations (CPython 3.14, Windows x64):
| Call kind | Overhead |
|---|---|
scalar (add i64) |
~90 ns |
float (mul f64) |
~105 ns |
string (strlen) |
~90 ns |
multi-return (divmod) |
~124 ns |
zero-copy buffer (fill) |
~120 ns |
Head-to-head, same native function on all three bridges — add, strlen, and fill are compiled once into a shared library and called through each binding, so the delta is pure call overhead:
| Call kind | Nano-FFI | ctypes |
cffi |
|---|---|---|---|
scalar (add i64) |
~90 ns | ~440 ns | ~225 ns |
string (strlen) |
~90 ns | ~300 ns | ~220 ns |
zero-copy buffer (fill) |
~120 ns | ~590 ns | ~240 ns |
A scalar Nano-FFI call carries roughly 5× less overhead than ctypes and 2.5× less than cffi on the reference machine. Reproduce:
python benchmarks/benchmark.py # per-kind overhead
python benchmarks/benchmark_vs_libraries.py # fair head-to-head vs ctypes/cffi
Numbers are machine-dependent; the harness is the source of truth.
Build from source
Requires Zig 0.15.2 and a CPython 3.10+ with development headers.
Windows (helper auto-detects your Python headers/libs):
.\scripts\build_local.ps1
Linux / macOS:
zig build -Doptimize=ReleaseFast \
-Dpython-include="$(python3 -c 'import sysconfig; print(sysconfig.get_path("include"))')"
cp zig-out/lib/nano_ffi.so nano_ffi.so
Run the tests:
zig build test # pure-Zig unit tests
python tests/test_python.py # end-to-end + benchmark
Architecture
src/
├── root.zig # entry point; exports PyInit_nano_ffi
├── comptime_bridge.zig # comptime trampoline generator (the core)
├── registry.zig # name -> (FnPtr, Signature)
├── python_ext.zig # the only file that imports <Python.h>
├── allocator.zig # Python/Zig memory boundary
└── version.zig # single source of the version string
How the speed works: makeTrampoline uses an inline for over the signature at compile time. The compiler sees explicit typed assignments, not a loop — no branches, no runtime type checks in the hot path.
Registering your own Zig function
const bridge = @import("nano_ffi").bridge;
fn add(a: i64, b: i64) i64 { return a + b; }
const AddTrampoline = bridge.makeTrampoline(add, .{
.args = &.{ .{ .name = "a", .typ = .i64 }, .{ .name = "b", .typ = .i64 } },
.ret = .i64,
});
// Register at init time:
try my_registry.register("add", AddTrampoline.asPtr(), AddTrampoline.sig);
Roadmap
Nano-FFI is at 1.0 — the public API (call, version, list_functions, signature), the supported type names, and the exception mapping are frozen and follow semantic versioning. See ROADMAP.md for how it got here and CHANGELOG.md for release notes.
License
MIT — see LICENSE. Built with Zig and the CPython C-API.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file nano_ffi-1.0.0-cp312-cp312-win_amd64.whl.
File metadata
- Download URL: nano_ffi-1.0.0-cp312-cp312-win_amd64.whl
- Upload date:
- Size: 98.5 kB
- Tags: CPython 3.12, Windows x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
75cc9d1205ba70123d89fc75fe5929aadecf96c85b3a976029dda87b5247b5d1
|
|
| MD5 |
6cab6fe277103f5b2b09f408b86fc082
|
|
| BLAKE2b-256 |
8685e27a54a157ec68125357a39f9be98e22de51dd1cedf8787b82552c2094af
|
File details
Details for the file nano_ffi-1.0.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl.
File metadata
- Download URL: nano_ffi-1.0.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
- Upload date:
- Size: 54.9 kB
- Tags: CPython 3.12, manylinux: glibc 2.28+ x86-64, manylinux: glibc 2.5+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cdbda55a5979882c2a40543d556f0d706945fc38c248a170a03ebb84ab2a553f
|
|
| MD5 |
60fba9de5ca1aab3a94bf8663de34010
|
|
| BLAKE2b-256 |
2c130c61dfbee5b7c6bc3b84cab56e53073ed851487f96b95306f29c6de6f5e0
|
File details
Details for the file nano_ffi-1.0.0-cp312-cp312-macosx_13_0_x86_64.whl.
File metadata
- Download URL: nano_ffi-1.0.0-cp312-cp312-macosx_13_0_x86_64.whl
- Upload date:
- Size: 17.6 kB
- Tags: CPython 3.12, macOS 13.0+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
293cf8ccb01773ae6f85bbced67c286ad8c66c47934553b9024c6b6d759e1a6d
|
|
| MD5 |
d2f7db8f7ddedd991bf9bfdaed1636b5
|
|
| BLAKE2b-256 |
cacbfc3759b7218a7aa0b5b438e31aadceb0c4437431b36fd92191ffa0878fad
|
File details
Details for the file nano_ffi-1.0.0-cp312-cp312-macosx_13_0_arm64.whl.
File metadata
- Download URL: nano_ffi-1.0.0-cp312-cp312-macosx_13_0_arm64.whl
- Upload date:
- Size: 19.1 kB
- Tags: CPython 3.12, macOS 13.0+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
515def1d04caff252945b2e7646f17d3d05df7203740a5af4d5a3f3e3f23c5f3
|
|
| MD5 |
3ff022bb761f1e5867feafebb8ccd300
|
|
| BLAKE2b-256 |
ba3b77f7984869a3f495ceccb8c53813a9ecb7266af79c412cabe43042c50309
|