Calling Rust from Go without FFI: embedding a compiler as WASM with wazero
The problem
For the last few months I’ve been kind of obsessed with zero knowledge proofs. Most of the tooling in that world is written in Rust, and I don’t dislike Rust, but I prefer writing Go. The only mainstream ZK tooling actually written in Go is gnark — fast prover, great team, but a fairly low-level frontend that makes it hard to express more complex circuits if you’re not that mathematically inclined (me, lol). My favorite higher-level DSL is Noir, from Aztec, but it officially only ships bindings for Rust and JS/TypeScript, plus the nargo CLI.
So if you’re a Go developer who wants to compile Noir circuits, you’re stuck choosing between writing FFI bindings to Rust, or shelling out to the nargo CLI and parsing stdout. Neither is great: FFI bindings are a pain to carry across Mac/Linux/Windows, and shelling out means every machine, VM, or CI runner needs its own nargo install.
What I actually wanted was to go get a library and have it just work, everywhere, no toolchain required. That’s not really a Noir-specific problem though — it’s the general “I want to use this one Rust library from Go, but I don’t want FFI or a build-time dependency on Rust” problem. So this post isn’t really about Noir. It’s about the trick I used to solve that, which you can reuse for basically any Rust library you want to ship to Go users without making them install anything.
The idea
Compile the Rust library to wasm32-wasip1 instead of a native target, embed the resulting .wasm binary into your Go package with //go:embed, and use wazero (pure Go, no CGo) to instantiate and call it at runtime.
That’s the whole idea. The payoff: anyone who imports your Go package gets the compiled Rust library with a plain go get. No Rust toolchain, no CGo, no platform-specific build steps, no separate binary to install or find on $PATH. The Rust code doesn’t even know it’s being called from Go — as far as it’s concerned, it’s just a WASI program with a couple of exported functions.
The catch is that Wasm functions can only pass numbers across the boundary — no strings, no slices, no structs. Everything else you have to build yourself: allocating memory inside the guest, writing bytes into it, and reading them back out. That’s the part worth actually teaching, so let’s go through it properly.
Step 1: give the host something to allocate with
On the Rust side, you export two functions that just wrap Rust’s own allocator:
#[unsafe(no_mangle)]
pub extern "C" fn alloc(size: usize) -> *mut u8 {
let mut buf = Vec::with_capacity(size);
let ptr = buf.as_mut_ptr();
std::mem::forget(buf);
ptr
}
#[unsafe(no_mangle)]
pub extern "C" fn dealloc(ptr: *mut u8, size: usize) {
unsafe {
let _ = Vec::from_raw_parts(ptr, 0, size);
}
}
This is the whole trick that makes the rest possible: Go can’t allocate memory inside the Wasm guest’s linear memory on its own — only the guest can do that. So you export a minimal allocator the host can call before writing anything, and a matching deallocator so the guest doesn’t leak memory across calls. std::mem::forget is doing the important work in alloc: it stops Rust from freeing the buffer when it goes out of scope, since ownership is being handed off to whoever calls alloc — in this case, Go.
Step 2: get data out without returning a pointer
Wasm functions return numbers, not pointers-with-lengths, so you can’t just return *mut u8 and also get a length back in one call. The pattern I used is an output struct that the caller allocates and the guest writes into:
#[repr(C)]
pub struct WasmBuf {
pub ptr: u32,
pub len: u32,
}
pub extern "C" fn compile_wasm(out: *mut WasmBuf, in_ptr: *mut u8, in_len: usize) {
// ... compile, then:
unsafe {
*out = WasmBuf { ptr: result_ptr as u32, len: result_len as u32 };
}
}
The caller allocates 8 bytes (ptr: u32 + len: u32) using the alloc export from step 1, passes a pointer to it, and after the call reads those 8 bytes back to find out where the real result lives and how big it is. It’s a bit of an unusual pattern if you haven’t done low-level FFI-style work before, but it’s the standard way to move a variable-length result across a boundary that only understands fixed-size numbers.
For serializing the actual payload (the input project files going in, the compiled artifact coming out), I used msgpack instead of JSON. Doesn’t have to be msgpack — any binary format works — but it’s compact and cheap to encode/decode, which matters since you’re paying for every byte you copy across the boundary.
Step 3: the host-side memory dance
This is the part that took me the longest to get right, so I’ll walk through it fully. Here’s the whole bridge function on the Go side:
func callWasmCompile(ctx context.Context, mod api.Module, input []byte) ([]byte, error) {
alloc := mod.ExportedFunction("alloc")
dealloc := mod.ExportedFunction("dealloc")
compileFn := mod.ExportedFunction("compile_wasm")
if alloc == nil {
return nil, fmt.Errorf("wasm export %q not found", "alloc")
}
if dealloc == nil {
return nil, fmt.Errorf("wasm export %q not found", "dealloc")
}
if compileFn == nil {
return nil, fmt.Errorf("wasm export %q not found", "compile_wasm")
}
// Allocate and Write Input
inputSize := uint64(len(input))
res, err := alloc.Call(ctx, inputSize)
if err != nil {
return nil, fmt.Errorf("alloc input failed: %w", err)
}
inPtr := uint32(res[0])
// Defer the deallocation
defer dealloc.Call(ctx, uint64(inPtr), inputSize)
if !mod.Memory().Write(inPtr, input) {
return nil, fmt.Errorf("failed to write input to wasm memory")
}
// Allocate space for the Result Pointer (8 bytes for ptr+len)
resPtrAlloc, err := alloc.Call(ctx, 8)
if err != nil {
return nil, fmt.Errorf("alloc res holder failed: %w", err)
}
outStructPtr := uint32(resPtrAlloc[0])
// Defer the deallocation
defer dealloc.Call(ctx, uint64(outStructPtr), 8)
// Execute
_, err = compileFn.Call(ctx, uint64(outStructPtr), uint64(inPtr), inputSize)
if err != nil {
return nil, fmt.Errorf("compile_wasm execution failed: %w", err)
}
// Read the pointer and length from the output struct
buf, ok := mod.Memory().Read(outStructPtr, 8)
if !ok {
return nil, fmt.Errorf("failed to read output header")
}
retPtr := binary.LittleEndian.Uint32(buf[0:4])
retLen := binary.LittleEndian.Uint32(buf[4:8])
if retLen > 0 {
defer dealloc.Call(ctx, uint64(retPtr), uint64(retLen))
}
// Read the actual result bytes
resultBytes, ok := mod.Memory().Read(retPtr, retLen)
if !ok {
return nil, fmt.Errorf("failed to read result data")
}
// Copy data out of WASM memory so it persists after module close
out := make([]byte, len(resultBytes))
copy(out, resultBytes)
return out, nil
}
Step by step:
- Allocate an input buffer inside Wasm memory. Call the exported
alloc, get back a pointer inside the guest’s memory space, and write the serialized input there withmod.Memory().Write. - Allocate 8 bytes for the output struct. This is where Rust will write the
WasmBuffrom step 2 — the pointer and length of the real result. - Call the function. Every argument goes across as a
uint64, including things that are conceptually pointers or sizes — Wasm only speaks numbers, so there’s no type checking helping you here. Get the argument order wrong and you’ll get garbage or a trap, not a compile error. - Read the result pointer and length back.
binary.LittleEndian.Uint32on those 8 bytes tells you where the real result lives in Wasm memory and how big it is. - Copy the result out of Wasm memory before doing anything else with it. This is the step most likely to bite you:
mod.Memory().Readgives you a slice backed by the guest’s memory, not Go’s heap. Once the module closes (or in some runtimes, once memory grows and gets reallocated), that slice is no longer valid. Copy it into a Go-owned[]byteimmediately.
defer dealloc.Call(...) after every alloc.Call(...) is the other detail that matters: it’s easy to forget one of these and slowly leak guest memory across calls, especially once you have more than one thing to allocate per call (here it’s the input buffer, the 8-byte output struct, and the result buffer — three allocations, three matching deallocations).
Step 4: wire it up
The public API on the Go side ends up being tiny — all of the above is an implementation detail behind it:
func Compile(projectPath string) (*result.Compilation, error) {
return CompileWithContext(context.Background(), projectPath)
}
func CompileWithContext(ctx context.Context, projectPath string) (*result.Compilation, error) {
e, err := New()
if err != nil {
return nil, err
}
defer e.CloseWithContext(context.Background())
return e.CompileWithContext(ctx, projectPath)
}
The .wasm.zst binary is embedded with //go:embed, decompressed once on first use, and compiled once by wazero (cached behind a sync.Once, so repeat calls skip straight to instantiation). Instantiating a fresh module per call is cheap once that one-time compile is done — and if the cold start matters for your use case, you can trigger it explicitly on startup (WarmupModules()) instead of paying it on the first real call from a user.
Gotchas, if you’re doing this yourself
A few things that weren’t obvious to me until I hit them:
- Target
wasm32-wasip1, notwasm32-unknown-unknown, if your Rust code touches the standard library at all (file I/O,println!, etc.) —unknown-unknowngives you a bare-metal target with no libc/WASI shims, which most real-world Rust crates aren’t written for. - wazero discards stdout/stderr by default. If you want to see anything your Rust code prints, or capture it for error messages, you have to explicitly wire up
wazero.NewModuleConfig().WithStdout(...)/.WithStderr(...)— otherwise it silently goes nowhere. - A zeroed-out result isn’t an error message. If something fails on the Rust side, resist the urge to just write
WasmBuf{ptr: 0, len: 0}and call it done — you’ll get a completely opaque failure on the Go side with no idea why. It’s worth designing your wire format so error cases carry an actual message back across the boundary the same way success does. - Panics don’t behave like native panics. Wrap anything that can panic in
std::panic::catch_unwind, or an unexpected panic in the guest shows up as an opaque Wasm trap on the host side instead of a message you can act on.
Wrapping up
| This library | nargo CLI |
|
|---|---|---|
| Cold start | ~2.1s (one-time) | none |
| Small circuit | ~112ms | fast |
| 60k constraint circuit | ~1.9s | ~160ms |
| Portability | go get and done |
install nargo everywhere |
| CGo | none | none |
Is it as fast as nargo? No. Is it roughly 10x slower? Yeah, about that. But for a use case where you’re compiling occasionally and don’t want to manage a nargo install on every machine, VM, or CI runner, I think the tradeoff is worth it — and the same tradeoff will make sense for a lot of other “ship a Rust library to Go users” problems, not just this one.
The full library is at github.com/YaniXIV/noir-go.