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.
Release files for nano-ffi 1.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| nano_ffi-1.4.0-cp312-cp312-win_amd64.whl | CPython 3.12 | CPython 3.12 | Windows x86-64 | Details |
| nano_ffi-1.4.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl | CPython 3.12 | CPython 3.12 | Linux glibc 2.5+ x86-64, Linux glibc 2.28+ x86-64 | Details |
| nano_ffi-1.4.0-cp312-cp312-macosx_13_0_x86_64.whl | CPython 3.12 | CPython 3.12 | macOS 13.0+ x86-64 | Details |
| nano_ffi-1.4.0-cp312-cp312-macosx_13_0_arm64.whl | CPython 3.12 | CPython 3.12 | macOS 13.0+ ARM64 | Details |
Total release size: 203.8 kB
Release files / nano_ffi-1.4.0-cp312-cp312-win_amd64.whl
| Download URL | nano_ffi-1.4.0-cp312-cp312-win_amd64.whl |
|---|---|
| Size | 97.5 kB |
| Tags | CPython 3.12 Windows x86-64 |
|
SHA-256 checksum How to use checksums |
b072d5bf97763b103e86f5985f5d65fe4826a8ec2667d2c9e54635791abcd4a1
|
|
BLAKE2b-256 checksum How to use checksums |
c541e04fbcbcad17c395355e5e072227f20113236cc55d8b7202976957bc2421
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / nano_ffi-1.4.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
| Download URL | nano_ffi-1.4.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl |
|---|---|
| Size | 64.9 kB |
| Tags | CPython 3.12 Linux glibc 2.28+ x86-64 Linux glibc 2.5+ x86-64 |
|
SHA-256 checksum How to use checksums |
82a3dfa4e1e0b8ddd95acc67c309442e0130d4e799449d5c5b3ce0ded48e9dc8
|
|
BLAKE2b-256 checksum How to use checksums |
a0446d7d7936a95274c1e0f55586a9456085eddb4274ca6b03e52776a61f255f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / nano_ffi-1.4.0-cp312-cp312-macosx_13_0_x86_64.whl
| Download URL | nano_ffi-1.4.0-cp312-cp312-macosx_13_0_x86_64.whl |
|---|---|
| Size | 20.0 kB |
| Tags | CPython 3.12 macOS 13.0+ x86-64 |
|
SHA-256 checksum How to use checksums |
06281e15e935c9055341ffaedc4dbccad18a393c1e919f71ebac6d9da52c8efd
|
|
BLAKE2b-256 checksum How to use checksums |
a55b964a20ab63f5a1a6c39ecb8c9f7e307b0074337bc30ca5d94c43202510cd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / nano_ffi-1.4.0-cp312-cp312-macosx_13_0_arm64.whl
| Download URL | nano_ffi-1.4.0-cp312-cp312-macosx_13_0_arm64.whl |
|---|---|
| Size | 21.3 kB |
| Tags | CPython 3.12 macOS 13.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
85daa07b58ba22a91e3f9a5839d69eae2f35ed1f10628876f59913035f3e95fb
|
|
BLAKE2b-256 checksum How to use checksums |
196bb6fd2c248fef3b851165011bacbbc9c82f03498dc71a7205789f3d02ead8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|