How do you call Zig from python?
5/19/25 update: See my Zig NYC #4 slides
As this previous post demonstrates, I rather enjoy Zig. However, software I write professionally is almost exclusively in python. Now, because CPython is the most common implementation, people commonly write performance critical parts of their python applications in C. There is an official tutorial documenting that process. The amount of boilerplate required stands out rather quickly in that tutorial. People have come up with a number of utilities to mitigate that boilerplate including, but not limited to, ctypes, Cython, CFFI, SWIG, F2PY, or pybind11.
At the same time, Zig advertises its integration with C libraries without FFI/bindings. Moreover, Zig also offers compile-time reflection and compile-time code execution which can surely mitigate boilerplate. It stands to reason that Zig is a good candidate to implement performance-critical sections. The fine folks at spiral realized that opportunity and released Ziggy Pydust in 2023. They focused a great deal of energy on ergonomics as illustrated by their first example:
const py = @import("pydust");
pub fn fibonacci(args: struct { n: u64 }) u64 {
if (args.n < 2) return args.n;
var sum: u64 = 0;
var last: u64 = 0;
var curr: u64 = 1;
for (1..args.n) {
sum = last + curr;
last = curr;
curr = sum;
}
return sum;
}
comptime {
py.rootmodule(@This());
}
Unfortunately for them, Zig has not reached one ver and makes virtually no promise of stability. True to that spirit, Zig 0.12 banned global mutable comptime state which Ziggy Pydust relied on heavily. Ziggy Pydust was stuck on Zig 0.11 and upgrading “isn’t an easy thing to do.” Such was the state of affairs when I came across Ziggy Pydust. And that’s when I remembered Larry Wall’s three virtues.
- Laziness
- There are so many thing I should be doing instead of this, sounds like a great excuse to procrastinate!
- Impatience
- What do you mean “We can try to reach consensus”? I want to be able to write python extensions in Zig now!
- Hubris
- What do you mean “This isn’t an easy thing to do”? Surely I am clever or at the very least stubborn enough!
So I made the rather ill-advised decision to throw my hat in that race. Much to his credit,
Nicholas Gates pointed me straight to the
problem area
and please believe me when I say that I tried
a lot of approaches.
I ended up finding one approach leveraging
comptime memoization.
Unfortunately, this approach also requires passing the top-level (root
) type all the way
down the call stack. Depending on
who you ask,
this is either an anti-pattern called
“tramp data” or, more generously,
a pattern called “Dependency Injection”.
For what it’s worth, I admitted to not being a fan of what I called this
“root pollution” in private emails with
Robert Kruszewski where I was asking (nay begging) him
to review #429. In the end, I reasoned
that passing an extra argument around felt like a fitting substitute for
global state.
Now the good news, bad news of it all. Ziggy Pydust is no longer stuck on zig 0.11. I think we can all agree that’s reasonably good news. This work paved the way for @bridgeQiao to update Ziggy Pydust to zig 0.14! On the flip side, I saw no way but to break the Ziggy Pydust API.
Before #429:
const py = @import("pydust");
pub fn hello() !py.PyString {
return try py.PyString.create("Hello!");
}
comptime {
py.rootmodule(@This());
}
After #429:
const py = @import("pydust");
const root = @This();
pub fn hello() !py.PyString(root) {
return try py.PyString(root).create("Hello!");
}
comptime {
py.rootmodule(root);
}
And for that,