| name | zig-to-rust |
| description | Use when migrating Zig codebases to Rust — covers comptime to proc macros/const generics, allocator to ownership, error sets to Result, build.zig to Cargo, and incremental replacement. Includes canonical code patterns, common mistakes, and reference implementations. |
| updated | 2026-07-30T00:00:00.000Z |
Zig to Rust Migration
Architecture Mapping
Zig and Rust share a core philosophy: no hidden control flow, no implicit allocations, and strong compile-time guarantees. Zig achieves this through comptime, explicit allocator passing, and error sets; Rust achieves the same goals through ownership, generics + trait bounds, and Result<T, E>. Both languages reject exceptions, both offer fine-grained memory control, and both target the same systems-programming niche. The translation is mostly structural: Zig's flat-source-file-with-imports becomes Rust's module tree, Zig's std.mem.Allocator interface becomes Rust's implicit ownership system (with explicit Box/Vec/Arc when heap allocation is needed), and Zig's comptime code generation becomes Rust's declarative macros, proc macros, const generics, and build.rs.
A Zig project:
src/
main.zig
parser.zig
network.zig
build.zig
build.zig.zon
becomes:
src/
main.rs
parser.rs
network.rs
Cargo.toml
build.rs
Zig's emphasis on "no hidden allocations" maps beautifully to Rust: in Zig you see every allocator passed explicitly; in Rust you see every heap allocation via Box, Vec, String, Arc -- no invisible allocations in either language.
Type System Mapping
| Zig Type | Rust Type | Notes |
|---|
u8 / i8 | u8 / i8 | Direct mapping |
u16 / i16 | u16 / i16 | Direct mapping |
u32 / i32 | u32 / i32 | Direct mapping |
u64 / i64 | u64 / i64 | Direct mapping |
usize / isize | usize / isize | Direct mapping |
f32 / f64 | f32 / f64 | Direct mapping |
bool | bool | Direct mapping |
noreturn | ! (never type) | Diverging function return type |
void | () (unit type) | () is a zero-size value |
anytype | Generics T or impl Trait | Zig's compile-time duck typing becomes trait-bounded generics |
?T (optional) | Option<T> | null becomes None; unwrap with .? becomes .unwrap() |
!T (error union) | Result<T, E> | Error set becomes an enum; try becomes ? |
[*]T (many-pointer) | *const T or |
Memory & Ownership Model
Zig makes allocation explicit by requiring an Allocator parameter for all heap operations. Rust makes ownership explicit through the type system. This is a philosophical alignment: both languages reject hidden allocations. The key translation is mapping Zig's allocator parameter to Rust's ownership rules.
| Zig Pattern | Rust Pattern | Semantic Notes |
|---|
allocator.alloc(T, n) | vec![T::default(); n] or (0..n).map(...).collect::<Vec<T>>() | Owned buffer; Rust Vec manages its own allocator |
allocator.free(slice) | Automatic at end of scope | Drop is implicit; no manual free call |
allocator.create(T) | Box::new(T::new()) | Single heap-allocated value |
allocator.destroy(ptr) | Automatic (Box::drop) | Drop on box deallocation |
allocator.dupe(u8, slice) | slice.to_vec() or slice.to_owned() | Copy a slice to owned allocation |
allocator.realloc(slice, n) | vec.resize(n, default) | Vec handles resize internally |
std.heap.page_allocator | #[global_allocator] or default system allocator | Default global allocator |
std.heap.GeneralPurposeAllocator | mimalloc / jemallocator crates | Custom global allocator |
std.heap.ArenaAllocator | bumpalo crate / typed_arena crate | Arena/bump allocation for short-lived objects |
std.heap.FixedBufferAllocator | arrayvec::ArrayVec / smallvec::SmallVec | Stack-allocated buffer fallback |
Defer-based cleanup defer allocator.free(buf) | Drop trait impl | RAII: Drop::drop runs at scope exit |
errdefer (defer on error) | Drop + Result -- Drop always runs; conditionals inside drop | Drop runs regardless; check state in drop body if cleanup varies |
Concurrency / Async Translation
Zig's concurrency model is still evolving (no stable async/await). Rust has a mature async ecosystem built on tokio, plus std::thread and rayon for CPU parallelism.
| Zig Pattern | Rust Pattern | Notes |
|---|
std.Thread.spawn | std::thread::spawn | Same: closure as entry point, JoinHandle to wait |
std.Thread.Mutex | std::sync::Mutex<T> | Rust Mutex guards data |
std.Thread.Condition | std::sync::Condvar | Condition variable |
std.Thread.ResetEvent / AutoResetEvent (Windows) | tokio::sync::Notify or std::sync::mpsc | Notification primitive |
std.atomic.* | std::sync::atomic::* | Same CAS, load/store; identical Ordering enum |
std.Thread.Semaphore | tokio::sync::Semaphore | Async semaphore |
std.ChildProcess | std::process::Command | Spawn and manage subprocess |
std.event.Loop | tokio runtime | Event loop |
async fn (Zig, unstable) | async fn (stable, tokio) | Stackless coroutine; .await is identical syntax |
suspend / resume (Zig) | Future::poll (low-level) -- prefer .await | Manual poll is rare; use async/await |
@fieldParentPtr for async frames | N/A -- compiler handles async frame layout | Rust compiler manages the state machine |
std.Thread.Pool | rayon::ThreadPool | Work-stealing thread pool for CPU-bound work |
std.Thread.spawn + manual join | rayon::scope or tokio::task::JoinSet | Structured concurrency |
Build System & Dependencies
| Zig Tool / Concept | Rust Equivalent | Notes |
|---|
build.zig | Cargo.toml | Declarative build manifest |
build.zig.zon | Cargo.toml ([dependencies] section) | Package manifest; Cargo.toml combines both roles |
const exe = b.addExecutable(...) | [[bin]] section or src/main.rs | Binary target |
const lib = b.addStaticLibrary(...) | [lib] section or src/lib.rs | Library target |
exe.addModule("foo", foo_module) | foo = { path = "../foo" } in [dependencies] | Local path dependency |
exe.linkSystemLibrary("ssl") | build.rs with cargo:rustc-link-lib=ssl | Link system native libraries |
exe.addIncludePath("include/") | cc::Build in build.rs | Compile and link C code |
exe.addCSourceFile("vendor/foo.c") | cc::Build::new().file("vendor/foo.c").compile("foo") in build.rs | Mixed-language build |
b.option(T, "name", "description") | cfg(feature = "name") + [features] in Cargo.toml | Feature flags |
target.cpu.arch / target.os.tag | #[cfg(target_arch = "x86_64")] / #[cfg(target_os = "linux")] | Conditional compilation |
@import("foo") | use foo; (module from crate or path) | Module import |
@import("std") | use std::...; | Standard library import |
Standard Library & Ecosystem Mapping
| Zig Standard Library | Rust Equivalent | Notes |
|---|
std.debug.print | println! / eprintln! | Format string uses {} not {s}; compile-time type checked |
std.fmt.allocPrint | format! macro | Returns String; allocates automatically |
std.fmt.parseInt | s.parse::<T>() | Returns Result; from_str_radix for custom bases |
std.fmt.parseFloat | s.parse::<f64>() | Same semantics |
std.fmt.bufPrint | write!(buf, "...") | Write formatted to buffer |
std.fs.cwd() | std::env::current_dir() | Current working directory |
std.fs.openFileAbsolute | std::fs::File::open | Open file by absolute path |
std.fs.Dir.openFile | std::fs::File::open (relative to CWD) or Path::join | File relative to directory |
std.fs.Dir.createFile | std::fs::File::create | Create or truncate file |
std.fs.Dir.readFileAlloc | std::fs::read_to_string / std::fs::read | Read entire file to String/Vec |
std.fs.Dir.writeFile | std::fs::write | Write entire file at once |
std.fs.Dir.deleteFile | std::fs::remove_file | Delete a file |
std.fs.Dir.makeDir | std::fs::create_dir / create_dir_all | Create directories |
Canonical Patterns
Pattern 1: Comptime → Const Generics / Proc Macros
Zig:
// comptime generics: generate code at compile time based on type
fn Matrix(comptime T: type, comptime rows: comptime_int, comptime cols: comptime_int) type {
return struct {
data: [rows * cols]T,
pub fn at(self: *const @This(), r: usize, c: usize) T {
return self.data[r * cols + c];
}
};
}
const Mat3x4 = Matrix(f32, 3, 4);
Rust:
// using const generics for compile-time dimension parameterization
#[derive(Debug, Clone)]
pub struct Matrix<T, const ROWS: usize, const COLS: usize> {
data: [T; ROWS * COLS],
}
impl<T: Copy + Default, const ROWS: usize, const COLS: usize> Matrix<T, ROWS, COLS> {
pub fn new() -> Self {
Matrix { data: [T::default(); ROWS * COLS] }
}
pub fn at(&self, r: usize, c: usize) -> T {
self.data[r * COLS + c]
}
}
type Mat3x4 = Matrix<f32, 3, 4>;
Pattern 2: Error Sets → Enum-Based Error Types
Zig:
// Zig error sets: implicit enum, auto-merged
const ParserError = error{
UnexpectedEof,
InvalidToken,
StackOverflow,
};
fn parse(input: []const u8) ParserError!Ast {
if (input.len == 0) return error.UnexpectedEof;
// ...
return Ast{};
}
// error propagation:
const ast = try parse(data);
Rust:
// define structured error enum with thiserror
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ParserError {
#[error("unexpected end of input")]
UnexpectedEof,
#[error("invalid token at position {pos}: '{found}'")]
InvalidToken { pos: usize, found: char },
#[error("stack overflow at depth {depth}")]
StackOverflow { depth: usize },
}
fn parse(input: &str) -> Result<Ast, ParserError> {
if input.is_empty() {
return Err(ParserError::UnexpectedEof);
}
// ...
Ok(Ast {})
}
// error propagation: ? operator is equivalent to try
let ast = parse(data)?;
Pattern 3: Defer → Drop
Zig:
// defer guarantees cleanup code runs on scope exit
fn processFile(path: []const u8) !void {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
var buf: [4096]u8 = undefined;
const n = try file.read(&buf);
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
// use file and arena ...
// on exit file.close() and arena.deinit() run automatically
}
Rust:
// Drop trait implements RAII: auto-cleanup at scope exit
use std::fs::File;
use std::io::Read;
fn process_file(path: &str) -> std::io::Result<()> {
let mut file = File::open(path)?; // auto-closes on Drop
let mut buf = [0u8; 4096];
let n = file.read(&mut buf)?;
let arena = bumpalo::Bump::new(); // auto-frees all memory on Drop
// use file and arena ...
// arena and file Drop run automatically at scope exit
Ok(())
}
Pattern 4: Optional Unwrapping
Zig:
// handling optional types and error unions
fn getConfig(key: []const u8) ?[]const u8 {
// return null to indicate absence
}
fn lookupOrDefault(key: []const u8, default: []const u8) []const u8 {
return getConfig(key) orelse default;
}
fn requireConfig(key: []const u8) ![]const u8 {
return getConfig(key) orelse error.ConfigMissing;
}
Rust:
// Option and Result combinator chaining
fn get_config(key: &str) -> Option<&str> {
// returns None to indicate absence
}
fn lookup_or_default(key: &str, default: &str) -> &str {
get_config(key).unwrap_or(default)
}
fn require_config(key: &str) -> Result<&str, ConfigError> {
get_config(key).ok_or(ConfigError::Missing { key: key.to_string() })
}
Pattern 5: Allocator Interface → Ownership
Zig:
// Zig: explicit allocator passing
fn buildTree(allocator: std.mem.Allocator, depth: u32) !*Node {
const node = try allocator.create(Node);
node.value = depth;
if (depth > 0) {
node.left = try buildTree(allocator, depth - 1);
node.right = try buildTree(allocator, depth - 1);
}
return node;
}
fn freeTree(allocator: std.mem.Allocator, node: *Node) void {
if (node.left) |left| freeTree(allocator, left);
if (node.right) |right| freeTree(allocator, right);
allocator.destroy(node);
}
Rust:
// Rust: ownership system auto-manages allocation and deallocation
fn build_tree(depth: u32) -> Box<Node> {
let mut node = Box::new(Node { value: depth, left: None, right: None });
if depth > 0 {
node.left = Some(build_tree(depth - 1));
node.right = Some(build_tree(depth - 1));
}
node
}
// entire tree auto-freed recursively when Box<Node> leaves scope
// for explicit iterative free (prevent stack overflow):
impl Drop for Node {
fn drop(&mut self) {
let mut stack = vec![self.left.take(), self.right.take()];
while let Some(Some(mut node)) = stack.pop() {
stack.push(node.left.take());
stack.push(node.right.take());
// node is freed here
}
}
}
Pattern 6: Switch / Pattern Matching
Zig:
// Zig switch: exhaustive match, compile-time completeness check
const Event = union(enum) {
click: struct { x: i32, y: i32 },
keypress: struct { code: u8, modifiers: u8 },
quit,
};
fn handle(ev: Event) void {
switch (ev) {
.click => |c| std.debug.print("click at {}, {}\n", .{c.x, c.y}),
.keypress => |k| std.debug.print("key {} mod {}\n", .{k.code, k.modifiers}),
.quit => std.debug.print("quitting\n", .{}),
}
}
Rust:
// Rust match: exhaustive pattern matching, compiler guarantees completeness
pub enum Event {
Click { x: i32, y: i32 },
KeyPress { code: u8, modifiers: u8 },
Quit,
}
fn handle(ev: Event) {
match ev {
Event::Click { x, y } => println!("click at {x}, {y}"),
Event::KeyPress { code, modifiers } => println!("key {code} mod {modifiers}"),
Event::Quit => println!("quitting"),
}
// missing a variant triggers compiler error -- same exhaustive check as Zig
}
Pattern 7: Packed Struct / C ABI Layout
Zig:
// packed struct: bit-exact layout, suitable for protocol headers and hardware registers
const TCPHeader = packed struct {
src_port: u16,
dst_port: u16,
seq_num: u32,
ack_num: u32,
data_offset: u4,
reserved: u3,
flags: u9,
window: u16,
checksum: u16,
urgent_ptr: u16,
};
Rust:
// use bitflags or manual bit ops; #[repr(C)] ensures C-compatible layout
// Option A: use deku crate for bit-level serialization
use deku::prelude::*;
#[derive(Debug, PartialEq, DekuRead, DekuWrite)]
#[deku(endian = "big")]
pub struct TcpHeader {
pub src_port: u16,
pub dst_port: u16,
pub seq_num: u32,
pub ack_num: u32,
#[deku(bits = "4")] pub data_offset: u8,
#[deku(bits = "3")] pub reserved: u8,
#[deku(bits = "9")] pub flags: u16,
pub window: u16,
pub checksum: u16,
pub urgent_ptr: u16,
}
// Option B: use bitfield crate or manual bit operations
// for simple cases, read bytes and extract bitfields with >> and &
FFI & Incremental Migration
Strategy: Leaf-to-Root Porting
| Stage | Zig | Rust | Bridge |
|---|
| 1 - Baseline | Full application | None | N/A |
| 2 - Library extraction | Core algorithms | Utility crates called via C ABI | extern "C" from Zig to Rust |
| 3 - Mid port | I/O, allocator plumbing | Business logic | Bidirectional C ABI |
| 4 - Top port | Main entry point, CLI arg parsing | All modules | Zig main.zig calls Rust; eventually Rust becomes main |
| 5 - Complete | None | Entire application | Vendored C deps via build.rs |
Exposing Rust to Zig (via C ABI)
// rust_engine.rs -- Rust side exporting C ABI
use std::ffi::{c_char, CStr, CString};
#[derive(Default)]
pub struct Engine {
state: i32,
}
#[no_mangle]
pub extern "C" fn engine_create() -> *mut Engine {
Box::into_raw(Box::new(Engine::default()))
}
#[no_mangle]
pub extern "C" fn engine_process(
engine: *mut Engine,
input: *const c_char,
) -> *mut c_char {
let eng = unsafe { &mut *engine };
let input = unsafe { CStr::from_ptr(input) }
.to_str().unwrap_or("");
let result = format!("processed: {input} (state={})", eng.state);
eng.state += 1;
CString::new(result).unwrap().into_raw()
}
#[no_mangle]
pub extern "C" fn engine_destroy(engine: *mut Engine) {
if !engine.is_null() {
unsafe { drop(Box::from_raw(engine)); }
}
}
#[no_mangle]
pub extern "C" fn engine_free_string(s: *mut c_char) {
if !s.is_null() {
unsafe { drop(CString::from_raw(s)); }
}
}
// Zig side calling Rust-exported C ABI functions
const c = @cImport({
@cInclude("rust_engine.h");
});
pub fn main() !void {
const engine = c.engine_create();
defer c.engine_destroy(engine);
const input = "hello from zig";
const output = c.engine_process(engine, input);
defer c.engine_free_string(output);
// use output ...
}
Calling Zig from Rust
// build.rs
fn main() {
// prerequisite: need zig build-exe or zig build-lib to produce .a / .so
println!("cargo:rustc-link-lib=zig_lib");
println!("cargo:rustc-link-search=native=/path/to/zig/build");
}
// ffi.rs -- Rust side bindings
extern "C" {
fn zig_parse_protocol(data: *const u8, len: usize) -> i32;
fn zig_serialize(data: *const u8, len: usize, out: *mut *mut u8, out_len: *mut usize) -> i32;
}
Common Mistakes
Mistake 1: Keeping defer Patterns via Manual Cleanup
Wrong:
// WRONG: manually calling cleanup functions to emulate Zig's defer in Rust
fn process() -> Result<(), Error> {
let file = File::open("data.bin")?;
// ... use file ...
// WRONG: every return point needs to remember cleanup
// easy to miss on early return
}
Right:
// CORRECT: rely on Drop for auto-cleanup; use scopes to control lifetime precisely
fn process() -> Result<(), Error> {
let file = File::open("data.bin")?;
// Drop auto-closes file handle regardless of how function exits
// ...
// need early release: use scope
{
let temp = File::create("temp.bin")?;
// temp is closed at end of this scope
}
// temp is already freed
Ok(())
}
Mistake 2: Translating anytype as Box<dyn Any>
Wrong:
// WRONG: translating every anytype to type erasure
fn process_any(input: Box<dyn std::any::Any>) {
// type information lost, downcast_ref needed everywhere
}
Right:
// CORRECT: use generics + trait bounds for compile-time polymorphism
fn process<T: Processable>(input: T) {
input.process();
}
trait Processable {
fn process(&self);
}
// or: if truly an open set, use enum instead of Any
enum Input {
Text(String),
Binary(Vec<u8>),
Json(serde_json::Value),
}
Mistake 3: Direct @intFromPtr → as Pointer Casts
Wrong:
// WRONG: directly translating Zig's @intFromPtr/@ptrFromInt to as casts
let addr = 0x1000usize;
let ptr = addr as *const u8;
let bytes = unsafe { std::slice::from_raw_parts(ptr, 16) };
// only valid in very specific scenarios (memory-mapped IO)
Right:
// CORRECT: use dedicated crates for memory-mapped IO
// Option A: if mmap, use memmap2 crate
use memmap2::MmapOptions;
let file = File::open("data.bin")?;
let mmap = unsafe { MmapOptions::new().map(&file)? };
let bytes: &[u8] = &mmap;
// Option B: if truly hardware addresses (embedded/legacy), isolate in unsafe module
#[cfg(target_os = "none")]
mod hardware {
const PERIPHERAL_BASE: usize = 0x4000_0000;
pub fn read_register(offset: usize) -> u32 {
unsafe {
let ptr = (PERIPHERAL_BASE + offset) as *const u32;
ptr.read_volatile()
}
}
}
Mistake 4: Using unsafe Everywhere as Zig-idiomatic "I Know What I'm Doing"
Wrong:
// WRONG: Zig programmers habit of using unsafe for 'I know this is safe' code
unsafe fn fast_copy(src: &[u8], dst: &mut [u8]) {
let len = src.len();
std::ptr::copy_nonoverlapping(src.as_ptr(), dst.as_mut_ptr(), len);
}
// unsafe everywhere, loses Rust safety checking value
Right:
// CORRECT: encapsulate unsafe in minimal safe abstractions
// copy_from_slice is already safe, compiler optimizes to memcpy
fn fast_copy(src: &[u8], dst: &mut [u8]) {
dst[..src.len()].copy_from_slice(src);
}
// if unsafe is truly needed (custom SIMD etc.), isolate behind safe interface:
mod simd_impl {
#[cfg(target_arch = "x86_64")]
pub fn copy_aligned(src: &[u8], dst: &mut [u8]) {
// SAFETY: caller guarantees alignment
unsafe { /* SSE/AVX 实现 */ }
}
#[cfg(not(target_arch = "x86_64"))]
pub fn copy_aligned(src: &[u8], dst: &mut [u8]) {
dst[..src.len()].copy_from_slice(src);
}
}
Mistake 5: Build-System Over-Engineering (Replicating build.zig Complexity in build.rs)
Wrong:
// WRONG: replicating build.zig's full flexibility in build.rs
// build.rs: implementing custom cross-compilation logic, target detection, codegen
fn main() {
let target = std::env::var("TARGET").unwrap();
if target.contains("x86_64") {
// manually setting dozens of compiler options...
}
// manually calling cc, manually linking...
}
Right:
// CORRECT: leverage Cargo's built-in features; build.rs only handles what Cargo can't
// build.rs
fn main() {
// only compile C/ASM dependencies that Cargo cannot manage
cc::Build::new()
.file("vendor/legacy_parser.c")
.compile("legacy_parser");
println!("cargo:rerun-if-changed=vendor/legacy_parser.c");
}
// Cargo.toml uses cfg and features to replace most of build.zig's functionality:
// [target.'cfg(target_os = "linux")'.dependencies]
// mio = { version = "1", features = ["os-poll"] }
Reference Implementations
| Project | Description | Relevant Patterns |
|---|
| tigerbeetle | Financial accounting DB -- Zig native; comparable Rust implementations exist for protocol handling | Custom allocator patterns, SIMD, IO_uring |
| bun | JS runtime in Zig; comparable to Deno (Rust) | Build system complexity, comptime codegen, allocator strategies |
| ghostty | GPU-accelerated terminal emulator (Zig) | Similar to Alacritty (Rust): SIMD, GPU rendering, cross-platform |
| river | Wayland compositor in Zig | Similar compositors in Rust (smithay-based): IPC protocols, layout engine |
| ziglings | Zig exercises; comparable to Rustlings | Language idioms, test-driven learning |
| ncbi-rs | Bio-informatics (Zig to Rust pattern) | Error union → Result mapping, allocator → ownership |
| zap | HTTP server in Zig (wraps C facil.io) | Comparable to hyper/actix: C FFI wrapping, async I/O |
| capy | GUI framework in Zig | Comparable to egui/iced: immediate mode rendering, cross-platform |
Cross-Reference
c-to-rust: For C codebases -- similar memory model but C lacks comptime and error sets
cpp-to-rust: For C++ codebases with templates and RAII patterns similar in spirit to Zig comptime and defer
- For allocator patterns:
bumpalo crate for arena allocation; typed-arena for typed arenas
- For comptime codegen replacement:
syn + quote + proc-macro2 for proc macros; build.rs with codegen patterns
- For error set patterns:
thiserror for derive macros; anyhow for application-level error type erasure
- For packed struct / protocol parsing:
deku, binrw, nom crates