Skip to main content

Nano-FFI

Nano-FFI

CI PyPI Downloads Python 3.10+ Zig 0.15.2 License: MIT

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

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)

Table of built distributions (wheels) for nano-ffi 1.4.0
File
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

Release history Release notifications | RSS feed

This release

1.4.0 This release

4 release files

1.0.0

4 release files

0.2.0

2 release files

0.1.0

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page