Zig and Rust are often mentioned in the same breath as “modern C replacements”, but they make very different bets. Zig keeps things explicit and simple: no hidden control flow, no hidden allocations, manual memory management with good tooling, and excellent C interop. Rust bets on compile-time guarantees: the borrow checker rules out whole classes of memory and concurrency bugs before the program ever runs.
Both bets have produced serious software. Bun, TigerBeetle and the Ghostty terminal are written in Zig. Rust is in the Linux kernel, Firefox, Android and a large chunk of cloud infrastructure. So migrating from one to the other isn’t an upgrade from a worse language to a better one. It’s a trade, and it’s only worth making for specific reasons.
This post covers how to decide, and if you do decide to move, how to do it gradually instead of as a risky rewrite.
Good reasons to move from Zig to Rust
You’re paying for memory bugs
If a meaningful share of your incidents or security reports are use-after-free, double free, data races or out-of-bounds access, Rust’s safe subset prevents most of these at compile time. Zig’s safety checks (in Debug and ReleaseSafe builds) catch many issues at runtime, which is valuable, but they’re not the same as a compiler that refuses to build the bug.
You need a stable language and ecosystem
Zig is still pre-1.0. The language and standard library change between releases, and upgrading can mean real work. Rust has had strong stability guarantees since 1.0 in 2015, with editions for opt-in changes. For a product with a long maintenance horizon, and a team that doesn’t want to track language changes, that stability counts.
You need libraries Zig doesn’t have yet
crates.io is very large. If you’re writing your own async runtime, TLS integration, database drivers or serialisation layers in Zig, and those are available as mature Rust crates, switching can remove a lot of code you’d otherwise maintain.
Hiring and team growth
It’s easier today to find engineers with Rust production experience than Zig experience. For a growing team, that’s a legitimate factor.
Reasons not to migrate
- Your Zig code works and your team is productive in it. Rewrites are expensive and introduce new bugs. “Rust is safer” isn’t a sufficient reason on its own.
- Your code depends heavily on comptime. Zig’s compile-time execution is unusually powerful and doesn’t map neatly onto Rust generics, traits and macros. Some designs will need rethinking, not translating.
- Your problem is mostly C interop. Zig is exceptionally good at consuming and building C. Rust’s C interop is good but involves more ceremony:
bindgen, build scripts andunsafeblocks. - Build simplicity matters to you. Zig’s toolchain doubles as a cross-compiling C/C++ compiler, which is hard to beat for some embedded and cross-platform builds.
If you read the first list and nodded, keep going. If you nodded at the second list, you may not need to migrate at all.
The approach: strangle, don’t rewrite
A big-bang rewrite, where you stop feature work and rebuild everything in Rust, is the riskiest way to migrate. The safer approach is the “strangler fig” pattern: replace one module at a time, keep the system working throughout, and let the new code gradually take over.
Zig and Rust can both speak the C ABI, which makes this practical. Either side can expose functions with C calling conventions that the other side calls.
A minimal example: calling Zig from Rust
Suppose you have a Zig module with a checksum function you want to keep for now while new code is written in Rust. First, export it with the C ABI:
// checksum.zig
export fn checksum(ptr: [*]const u8, len: usize) u32 {
var sum: u32 = 0;
for (ptr[0..len]) |byte| {
sum = sum *% 31 +% byte;
}
return sum;
}
Build it as a static library:
zig build-lib checksum.zig -O ReleaseSafe
That produces libchecksum.a (or checksum.lib on Windows). In the Rust crate, tell Cargo where to find it with a build script:
// build.rs
fn main() {
println!("cargo:rustc-link-search=native=path/to/zig/output");
println!("cargo:rustc-link-lib=static=checksum");
}
Then declare and call the function. Recent Rust editions require extern blocks to be marked unsafe, and every call is unsafe because the compiler can’t check what’s on the other side:
// src/main.rs
unsafe extern "C" {
fn checksum(ptr: *const u8, len: usize) -> u32;
}
fn safe_checksum(data: &[u8]) -> u32 {
// SAFETY: the slice gives a valid pointer and length for the call's duration.
unsafe { checksum(data.as_ptr(), data.len()) }
}
fn main() {
println!("{}", safe_checksum(b"hello"));
}
The safe_checksum wrapper is the important pattern. Keep unsafe at the boundary, write a safe Rust API around it, and the rest of your Rust code never touches raw pointers.
When the Rust replacement for checksum is ready, you swap the wrapper’s body for the Rust implementation, and nothing else changes. The same works in reverse with #[unsafe(no_mangle)] pub extern "C" fn in Rust, if you want existing Zig code to call new Rust modules.
A migration plan that holds up
- Map the codebase. List modules, their dependencies and how often they change. Note which ones have had the most memory-safety bugs.
- Pick a leaf module to start. Choose something with few dependencies and clear inputs and outputs, such as a parser, an encoder or a checksum routine. Avoid starting with the core event loop.
- Write characterisation tests first. Before rewriting, capture the current module’s behaviour with tests that call it through the C ABI. The same tests then validate the Rust version, which is your proof that nothing changed.
- Define ownership rules at the boundary. Who allocates, who frees, and with which allocator? Memory crossing an FFI boundary must be freed by the side that allocated it. Write this down per function.
- Port, test, benchmark, switch. For performance-sensitive code, compare before and after with realistic workloads rather than assuming either language is faster.
- Repeat, moving inward. As more modules move, the Zig core gets smaller until switching the entry point to Rust is a small final step rather than a leap.
Translating idioms, not syntax
A few mappings save a lot of frustration:
- Allocators: Zig passes allocators explicitly. Rust usually uses the global allocator plus owned types like
Vec,BoxandString. Arena-style patterns can be done with crates likebumpalo. - Error unions: Zig’s
!Twithtrymaps naturally to Rust’sResult<T, E>with?. Define an error enum per module. - Optionals:
?TbecomesOption<T>. - comptime: use generics and traits for type-level work,
const fnfor simple compile-time computation, and macros only when the others don’t fit. - defer: Rust’s
Drop(RAII) handles most cleanup automatically. For one-off cleanup, a small guard type or a crate likescopeguarddoes the job.
Measuring whether it worked
Decide how you’ll judge success before you start, and track it: memory-safety bugs per quarter, time spent on toolchain upgrades, build times, performance on your real benchmarks and how quickly new engineers become productive. If the numbers don’t improve after the first few modules, it’s fine to stop. A codebase that’s partly Zig and partly Rust over a clean C ABI is a perfectly reasonable end state.
The best migrations are boring. No launch day, no rewrite freeze, just a steady series of small swaps, each one tested and reversible.