Tech Guides
01

Quick Reference

The essential commands you will reach for daily. Zig ships as a single binary with a built-in build system, test runner, and C/C++ compiler.

Compile & Run

zig build
Build the project using build.zig. Defaults to the install step.
zig run src/main.zig
Compile and immediately execute a single file.
zig build-exe src/main.zig
Produce an executable binary without running it.
zig build-lib src/lib.zig
Compile a static or shared library.
zig build-obj src/file.zig
Compile to a relocatable object file (.o).
zig test src/main.zig
Run all test blocks in the given file.

Common Flags

FlagPurpose
-OReleaseFastOptimize for speed, disable safety checks
-OReleaseSafeOptimize for speed, keep safety checks (recommended)
-OReleaseSmallOptimize for binary size
-ODebugDefault. No optimizations, full safety checks and debug info
-target x86_64-linux-gnuCross-compile to a specific target triple
-lcLink against libc
--pkg-begin / --pkg-endDefine a module dependency inline (legacy; prefer build.zig)

Cross-Compilation

Zig can cross-compile to over 40 targets out of the box, including every major OS and architecture. No extra toolchains needed.

# List all supported targets
zig targets | jq '.arch'

# Build for ARM Linux from any host
zig build-exe src/main.zig -target aarch64-linux-gnu

# Build for Windows from Linux/macOS
zig build-exe src/main.zig -target x86_64-windows-msvc

# Build for macOS from Linux
zig build-exe src/main.zig -target x86_64-macos-none

# Use Zig as a drop-in C cross-compiler
zig cc -target aarch64-linux-gnu hello.c -o hello

Package Manager (0.12+)

# Add a dependency to build.zig.zon
zig fetch --save https://github.com/user/repo/archive/main.tar.gz

# build.zig.zon declares dependencies
.{
    .name = "my-project",
    .version = "0.1.0",
    .dependencies = .{
        .@"zig-clap" = .{
            .url = "https://github.com/Hejsil/zig-clap/archive/v0.4.0.tar.gz",
            .hash = "122053...",
        },
    },
}
02

Variables, Types & Optionals

Zig's type system is designed for clarity and zero-cost abstraction. Every type has a known size, no implicit conversions, and no hidden behavior.

Variable Declarations

// `const` bindings are immutable
const x: i32 = 42;

// `var` bindings are mutable
var y: u8 = 0;
y += 1;

// Type inference with `const`
const name = "Zig";   // inferred as *const [3:0]u8

// Unused variables are compile errors (use _ to discard)
_ = some_function();

// Undefined initialization (dangerous but useful)
var buf: [1024]u8 = undefined;

Primitive Types

CategoryTypesNotes
Signed integersi8, i16, i32, i64, i128Arbitrary widths via i{N}
Unsigned integersu8, u16, u32, u64, u128usize for pointer-sized
Floatsf16, f32, f64, f80, f128IEEE 754 compliant
Booleanbooltrue or false
Pointer-sizedusize, isizeMatch native word size
VoidvoidZero-size type for generics
Comptime typescomptime_int, comptime_floatArbitrary precision at compile time

Structs, Enums & Unions

// Structs
const Point = struct {
    x: f64,
    y: f64,

    pub fn distance(self: Point, other: Point) f64 {
        const dx = self.x - other.x;
        const dy = self.y - other.y;
        return @sqrt(dx * dx + dy * dy);
    }
};

// Enums
const Color = enum {
    red,
    green,
    blue,

    pub fn isWarm(self: Color) bool {
        return self == .red;
    }
};

// Tagged unions (like Rust enums)
const Token = union(enum) {
    number: f64,
    string: []const u8,
    eof: void,
};

Optionals

Zig uses ?T for optional types instead of null pointers. This forces explicit handling of missing values.

// Optional type: ?T
var maybe: ?u32 = 42;
maybe = null;

// Unwrap with orelse (provide default)
const val = maybe orelse 0;

// Unwrap with if
if (maybe) |value| {
    std.debug.print("Got: {}\n", .{value});
} else {
    std.debug.print("Was null\n", .{});
}

// Unwrap or unreachable (crash if null)
const certain = maybe.?;  // equivalent to `orelse unreachable`

// Optional pointers are pointer-sized (null = 0x0)
var ptr: ?*u32 = null;    // same size as *u32

Error Unions

Error unions combine a return value with a possible error. Written as ErrorSet!ReturnType.

// Error set declaration
const FileError = error {
    FileNotFound,
    PermissionDenied,
    OutOfMemory,
};

// Function returning error union
fn readConfig(path: []const u8) FileError!Config {
    const file = std.fs.cwd().openFile(path, .{}) catch {
        return error.FileNotFound;
    };
    defer file.close();
    // ...
}

// anyerror captures any error type
fn doSomething() anyerror!void { ... }
03

Comptime

Compile-time execution is Zig's most distinctive feature. Instead of macros or template metaprogramming, Zig evaluates real code at compile time.

Compile-Time Evaluation

// comptime forces evaluation at compile time
const len = comptime blk: {
    var n: u32 = 1;
    inline for (0..10) |_| {
        n *= 2;
    }
    break :blk n;  // len = 1024 at compile time
};

// comptime parameters: evaluated at compile time
fn pow(comptime T: type, base: T, exp: T) T {
    var result: T = 1;
    var i: T = 0;
    while (i < exp) : (i += 1) {
        result *= base;
    }
    return result;
}

// Usage — the type is a comptime parameter
const result = pow(u32, 2, 10);  // 1024

Generics via Comptime

Zig has no generics syntax. Instead, pass type as a comptime parameter. This is simpler and more powerful than parametric polymorphism.

// Generic linked list
fn LinkedList(comptime T: type) type {
    return struct {
        const Self = @This();

        const Node = struct {
            data: T,
            next: ?*Node,
        };

        head: ?*Node,
        len: usize,

        pub fn init() Self {
            return .{ .head = null, .len = 0 };
        }

        pub fn push(self: *Self, allocator: std.mem.Allocator, data: T) !void {
            const node = try allocator.create(Node);
            node.* = .{ .data = data, .next = self.head };
            self.head = node;
            self.len += 1;
        }
    };
}

// Usage
var list = LinkedList(i32).init();
try list.push(allocator, 42);

Compile-Time Reflection

// @typeInfo gives you full type metadata at comptime
fn dumpFields(comptime T: type) void {
    const info = @typeInfo(T);
    switch (info) {
        .@"struct" => |s| {
            inline for (s.fields) |field| {
                @compileLog(field.name, field.type);
            }
        },
        else => @compileError("Expected struct"),
    }
}

// @typeName, @sizeOf, @alignOf are all comptime
const name = @typeName(u32);       // "u32"
const size = @sizeOf(Point);       // 16 (two f64s)
const align = @alignOf(Point);     // 8
⚙ Comptime Philosophy
Zig's comptime replaces macros (C), templates (C++), and proc-macros (Rust) with a single, unified mechanism. Any function that only uses comptime-known values can run at compile time. There is no separate macro language to learn.
04

Memory Management

No garbage collector, no RAII, no hidden allocations. Every allocation is explicit, every allocator is injectable, and defer handles cleanup.

Allocators

Zig does not have a default allocator. Functions that need heap memory accept an std.mem.Allocator parameter, making allocation strategy explicit and swappable.

AllocatorUse Case
std.heap.page_allocatorDirect OS page allocation. Simple but wasteful for small objects.
std.heap.GeneralPurposeAllocatorDebug-friendly allocator with leak detection and double-free checks.
std.heap.ArenaAllocatorBulk allocations freed all at once. Great for request-scoped work.
std.heap.FixedBufferAllocatorAllocates from a fixed stack buffer. No syscalls, no fragmentation.
std.heap.c_allocatorWraps C's malloc/free. Useful for C interop.
std.testing.allocatorTest allocator that fails the test on leaks.
// General purpose allocator with leak detection
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
    const check = gpa.deinit();
    if (check == .leak) @panic("Memory leak detected!");
}
const allocator = gpa.allocator();

// Arena allocator — bulk free everything at once
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const arena_alloc = arena.allocator();

// Allocate a slice
const buf = try allocator.alloc(u8, 1024);
defer allocator.free(buf);

defer & errdefer

defer runs cleanup at scope exit. errdefer runs only if the function returns an error. Together they replace RAII destructors and try/finally.

fn createResource(allocator: std.mem.Allocator) !*Resource {
    const ptr = try allocator.create(Resource);
    errdefer allocator.destroy(ptr);  // only runs if we return error

    ptr.* = try Resource.init();      // if this fails, ptr is freed
    return ptr;                        // success: caller owns ptr
}

// Multiple defers execute in reverse order (LIFO)
fn example() void {
    const a = acquire_a();
    defer release_a(a);     // runs third

    const b = acquire_b();
    defer release_b(b);     // runs second

    const c = acquire_c();
    defer release_c(c);     // runs first
}

Stack vs Heap

When to Use Which

  • Stack: Fixed-size data, function-local. Arrays with comptime-known length live on the stack.
  • Heap: Dynamic-size data, data that outlives the current scope, large buffers.
  • Arena: Many small allocations with a shared lifetime (parsing, request handling).
  • Fixed buffer: When you know the max size and want zero syscalls (embedded, real-time).
⚠ No Hidden Allocations
Unlike most languages, Zig's standard library never allocates behind your back. String concatenation, array growth, and formatting all require an explicit allocator. This is by design: it makes memory usage predictable and auditable.
05

Slices, Arrays & Sentinels

Zig distinguishes between arrays (fixed-size, stack), slices (fat pointers with length), and sentinel-terminated sequences (C compatibility).

Arrays

// Fixed-size array (lives on the stack)
const arr: [5]u8 = .{ 1, 2, 3, 4, 5 };

// Comptime-known length via init syntax
const zeros = [_]u8{0} ** 256;  // 256 zero bytes

// Array concatenation (comptime only)
const a = [_]u8{ 1, 2 };
const b = [_]u8{ 3, 4 };
const c = a ++ b;  // [4]u8{ 1, 2, 3, 4 }

// Multi-dimensional arrays
const matrix: [3][3]f32 = .{
    .{ 1, 0, 0 },
    .{ 0, 1, 0 },
    .{ 0, 0, 1 },
};

Slices

A slice is a pointer + length pair. It is the idiomatic way to pass sequences of data around.

// Slice from array
const arr = [_]u8{ 10, 20, 30, 40, 50 };
const slice: []const u8 = arr[1..4];  // { 20, 30, 40 }

// Mutable slice
var data = [_]u8{ 0, 0, 0 };
var mutable_slice: []u8 = &data;
mutable_slice[0] = 42;

// Slice from heap allocation
const heap_buf = try allocator.alloc(u8, 1024);
defer allocator.free(heap_buf);

// String literals are slices
const greeting: []const u8 = "Hello, Zig!";

// Iterate over a slice
for (slice, 0..) |byte, i| {
    std.debug.print("[{}] = {}\n", .{ i, byte });
}

Sentinel-Terminated Types

Sentinel types end with a known value (typically 0). This is how Zig interoperates with C strings and null-terminated arrays.

// Null-terminated string (C compatible)
const c_str: [*:0]const u8 = "hello";

// Sentinel-terminated slice
const msg: [:0]const u8 = "world";

// Convert between types
const slice: []const u8 = std.mem.span(c_str);

// Sentinel-terminated arrays
const arr: [3:0]u8 = .{ 1, 2, 3 };  // followed by 0

Pointer Types at a Glance

SyntaxMeaningExample
*TSingle-item pointer*u32
[*]TMany-item pointer (no length)[*]u8 (C array)
[]TSlice (pointer + length)[]u8
[*:0]TNull-terminated many-pointer[*:0]u8 (C string)
[:0]TSentinel-terminated slice[:0]u8
?*TOptional pointer?*Node
06

Error Handling

Zig's error handling is built into the type system. No exceptions, no panics for recoverable errors. Errors are values you handle or propagate explicitly.

try, catch & Error Propagation

// `try` unwraps or returns the error to the caller
fn readFile(path: []const u8) ![]u8 {
    const file = try std.fs.cwd().openFile(path, .{});
    defer file.close();
    return try file.readToEndAlloc(allocator, 1024 * 1024);
}

// `catch` handles the error inline
const content = readFile("config.txt") catch |err| {
    std.log.err("Failed: {}", .{err});
    return;
};

// `catch` with a default value
const port = parsePort(env_var) catch 8080;

// Switch on specific errors
const result = doWork() catch |err| switch (err) {
    error.OutOfMemory => @panic("OOM"),
    error.Timeout => return error.Timeout,
    else => return err,
};

Error Sets

// Named error sets
const ParseError = error {
    InvalidCharacter,
    Overflow,
    EndOfStream,
};

// Error sets can be merged
const AllErrors = ParseError || FileError;

// Inferred error sets (let the compiler figure it out)
fn process() !void {       // `!` with no explicit set
    try step1();
    try step2();              // compiler merges all possible errors
}

// Error return traces (debug builds)
// Zig automatically tracks where errors originated
// — stack traces for errors, not just panics

Error Handling Patterns

try expr
Unwrap success or return error to caller. Syntactic sugar for expr catch |e| return e.
expr catch default
Unwrap success or use a fallback value on error.
expr catch |e| { ... }
Handle the error explicitly with access to the error value.
expr catch unreachable
Assert the error cannot happen. Panics in debug, UB in release.
if (expr) |val| { } else |err| { }
Full pattern match on error union result.
errdefer cleanup()
Run cleanup only when leaving scope with an error.
⚙ Error Design Tip
Prefer specific error sets over anyerror. Specific sets let the compiler check that you handle all cases, and they document your function's failure modes. Use inferred error sets (!) during prototyping, then name them once the API stabilizes.
07

C Interop

Zig can import and call C code directly, with zero overhead. It ships with a full C/C++ compiler and can link any C library without writing bindings by hand.

@cImport

// Import C headers directly — no bindings generator needed
const c = @cImport({
    @cInclude("stdio.h");
    @cInclude("stdlib.h");
    @cInclude("math.h");
});

pub fn main() void {
    // Call C functions directly
    _ = c.printf("Hello from C! sqrt(2) = %f\n", c.sqrt(2.0));

    // Use C's malloc (but prefer Zig allocators)
    const ptr = c.malloc(100);
    defer c.free(ptr);
}

Linking C Libraries

// In build.zig — link against system libraries
const exe = b.addExecutable(.{
    .name = "my-app",
    .root_source_file = b.path("src/main.zig"),
    .target = target,
    .optimize = optimize,
});

// Link system C library
exe.linkLibC();

// Link a specific system library (e.g., SDL2)
exe.linkSystemLibrary("SDL2");

// Or compile C source files as part of the build
exe.addCSourceFiles(.{
    .files = &.{ "vendor/sqlite3.c" },
    .flags = &.{ "-DSQLITE_THREADSAFE=0" },
});
exe.addIncludePath(b.path("vendor/"));

Zig as a C/C++ Compiler

Zig bundles a C and C++ compiler. You can use zig cc as a drop-in replacement for gcc or clang, with built-in cross-compilation.

# Compile C code with zig
zig cc -o hello hello.c

# Cross-compile C for ARM
zig cc -target aarch64-linux-gnu hello.c -o hello-arm

# Use as CC in Makefiles or CMake
CC="zig cc" make
CMAKE_C_COMPILER="zig cc" cmake ..

# Compile C++ with zig
zig c++ -o app main.cpp -lstdc++

Type Compatibility

C TypeZig EquivalentNotes
char*[*:0]u8Null-terminated byte pointer
const char*[*:0]const u8Immutable C string
void**anyopaqueType-erased pointer
intc_intPlatform-dependent int
size_tusizePointer-width unsigned
NULLnullFor optional pointers
08

Build System

Zig's build system is a Zig program. Instead of Makefiles, CMakeLists, or cargo.toml, you write build.zig and get cross-compilation, caching, and reproducibility for free.

build.zig Anatomy

const std = @import("std");

pub fn build(b: *std.Build) void {
    // Target and optimization from command line
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // Main executable
    const exe = b.addExecutable(.{
        .name = "my-app",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    // Install artifact to zig-out/bin/
    b.installArtifact(exe);

    // Run step: `zig build run`
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());

    // Forward CLI args: `zig build run -- arg1 arg2`
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }

    const run_step = b.step("run", "Run the application");
    run_step.dependOn(&run_cmd.step);

    // Test step: `zig build test`
    const tests = b.addTest(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    const run_tests = b.addRunArtifact(tests);
    const test_step = b.step("test", "Run unit tests");
    test_step.dependOn(&run_tests.step);
}

build.zig.zon (Dependencies)

// build.zig.zon — declarative package manifest
.{
    .name = "my-project",
    .version = "0.1.0",
    .minimum_zig_version = "0.13.0",
    .dependencies = .{
        .@"zig-clap" = .{
            .url = "https://github.com/Hejsil/zig-clap/archive/v0.4.0.tar.gz",
            .hash = "1220abcdef...",
        },
        .@"zap" = .{
            .url = "https://github.com/zigzap/zap/archive/v0.8.0.tar.gz",
            .hash = "1220fedcba...",
        },
    },
    .paths = .{ "src", "build.zig", "build.zig.zon" },
}

// Then in build.zig, import the dependency:
const clap_dep = b.dependency("zig-clap", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("clap", clap_dep.module("clap"));

Build Commands

zig build
Run the default install step. Output goes to zig-out/.
zig build run
Build and immediately run the executable.
zig build test
Build and run all tests.
zig build -Doptimize=ReleaseSafe
Build with optimizations and safety checks.
zig build -Dtarget=aarch64-linux
Cross-compile via build.zig target option.
zig build --help
Show all custom steps and options defined in build.zig.
09

Testing & Debugging

Test blocks are first-class citizens in Zig. They live alongside production code and run with a dedicated test allocator that catches leaks.

Writing Tests

const std = @import("std");
const expect = std.testing.expect;
const expectEqual = std.testing.expectEqual;

// Tests live alongside production code
fn add(a: i32, b: i32) i32 {
    return a + b;
}

test "basic addition" {
    try expectEqual(@as(i32, 4), add(2, 2));
}

test "negative numbers" {
    try expectEqual(@as(i32, 0), add(-1, 1));
}

test "overflow detection" {
    // In Debug mode, overflow is a detectable illegal behavior
    const result = @addWithOverflow(@as(u8, 250), 10);
    try expect(result[1] == 1);  // overflow flag is set
}

// Test with allocator (catches memory leaks)
test "allocator usage" {
    const allocator = std.testing.allocator;
    const list = try allocator.alloc(u8, 10);
    defer allocator.free(list);  // test fails if this is missing!
    try expect(list.len == 10);
}

Test Assertions

FunctionPurpose
expect(bool)Assert a condition is true
expectEqual(expected, actual)Assert two values are equal
expectEqualSlices(T, expected, actual)Compare slice contents
expectEqualStrings(expected, actual)Compare string slices with clear diffs
expectError(error_val, error_union)Assert a specific error is returned
expectApproxEqAbs(expected, actual, tol)Float comparison with tolerance

Debugging

// Print debug info (removed in release builds)
std.debug.print("x = {}, y = {}\n", .{ x, y });

// Log with severity levels
std.log.info("Starting up...", .{});
std.log.warn("Config missing, using defaults", .{});
std.log.err("Failed to open: {}", .{err});

// Stack traces on panic (debug builds)
@panic("something went wrong");

// Breakpoint for debugger attachment
@breakpoint();

// GDB/LLDB work out of the box with debug builds
// $ gdb ./zig-out/bin/my-app
// (gdb) break src/main.zig:42
// (gdb) run

Running Tests

zig test src/main.zig
Run tests in a single file and its transitive imports.
zig build test
Run tests through the build system (preferred).
zig test --test-filter "addition"
Run only tests whose name contains the filter string.
zig test -OReleaseSafe src/main.zig
Run tests with optimizations to check release behavior.
10

Use Cases

Where Zig shines and why teams are choosing it. From replacing C in legacy codebases to building game engines and embedded systems.

Replacing C

Zig is designed as a practical successor to C. It interoperates seamlessly with C code, compiles C sources, and can be incrementally adopted in existing C projects.

Why Replace C with Zig?

  • No undefined behavior — Zig detects buffer overflows, null pointer dereferences, and integer overflow in debug mode.
  • Better tooling — Built-in test runner, package manager, and cross-compiler.
  • Readable error handling — Error unions instead of errno and goto chains.
  • Zero-cost C interop — Call C functions directly, no FFI layer.
  • Incremental adoption — Replace one .c file at a time with .zig, keep linking the rest.

Game Engines & Graphics

Zig's deterministic performance, manual memory control, and comptime make it attractive for game development.

ProjectDescription
Mach EngineA Zig-native game engine with GPU-accelerated rendering and ECS architecture.
zig-gamedevCollection of game-dev libraries: DirectX 12, Vulkan, physics, audio.
Raylib bindingsZig bindings for the popular Raylib game programming library.
SDL2 + ZigDirect @cImport of SDL2 headers for cross-platform windowing and input.

Embedded & Systems Programming

// Zig can target bare-metal platforms
// No OS, no stdlib, no allocator

// Freestanding target in build.zig
const exe = b.addExecutable(.{
    .name = "firmware",
    .root_source_file = b.path("src/main.zig"),
    .target = b.resolveTargetQuery(.{
        .cpu_arch = .thumb,
        .os_tag = .freestanding,
        .abi = .eabi,
    }),
});

// Specify linker script for memory layout
exe.setLinkerScript(b.path("linker.ld"));

// MMIO register access (comptime-known addresses)
const GPIOA = @as(*volatile u32, @ptrFromInt(0x40020000));
GPIOA.* = 0x01;  // set pin 0 high

Notable Projects Using Zig

Bun
JavaScript runtime and toolkit. Uses Zig for its core HTTP server, bundler, and transpiler for maximum performance.
TigerBeetle
A financial transactions database designed for mission-critical safety. Written entirely in Zig.
Ghostty
A fast, feature-rich terminal emulator written in Zig with GPU-accelerated rendering.
River
A Wayland compositor for Linux, showcasing Zig's strength in systems UI programming.
Uber's bazel-zig-cc
Uber uses Zig as a hermetic C/C++ cross-compiler in their Bazel build system.
Zig stdlib itself
The standard library is a showcase of idiomatic Zig: allocators, IO, networking, crypto, compression.
⚙ When to Choose Zig
Zig is a strong choice when you need C-level performance with modern ergonomics: systems software, game engines, embedded firmware, performance-critical libraries, and any project that needs to interface with existing C code. It is not yet ideal for rapid application development or projects that benefit from a rich package ecosystem — Rust or Go may be better fits there.