LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Zig: The Modern C Replacement

zigsystems-programmingccomptimememory-managementcross-compilation
Contents

C has been the language of systems programming for over fifty years. It is also a language with undefined behavior lurking around nearly every corner: signed integer overflow is UB, reading uninitialized memory is UB, a pointer cast that violates strict aliasing is UB, a union member access outside the active member is UB. The compiler is allowed to assume none of these things happen, which means when they do, the results are not “wrong output” but rather “the compiler deleted your security check because it proved it was unreachable.” The CVE databases are a monument to this bargain.

Rust offers an answer: memory safety enforced by a borrow checker. That answer is real and valuable, but it comes with a steep learning curve, a complex type system, and a programming model that is fundamentally different from C. There is a large class of systems programmers — people writing kernels, embedded firmware, database internals, game engines, compilers — who want something closer to C in complexity, but without the footguns.

Zig is that language. Created by Andrew Kelley starting in 2015, Zig’s central thesis is: you can have low-level manual control and still eliminate entire classes of undefined behavior, not by adding a runtime or a borrow checker, but by making the language semantics well-defined and by forcing you to be explicit about things C hides from you.

This guide is for engineers who already know C, C++, or Rust. It is technically deep. It covers real code. It does not shy away from the rough edges.


What Zig Is and Its Philosophy

Andrew Kelley designed Zig around a simple manifesto: no hidden control flow, no hidden memory allocations, no preprocessor, no macros. Every one of those constraints exists because C and C++ hide things from you.

In C, a + b can invoke undefined behavior if a and b are signed integers that overflow. In C++, operator overloading means a + b might allocate memory, throw an exception, or call a destructor — you can’t tell by reading the call site. In C, #include and macros create a preprocessor language that the compiler never sees, making it impossible to build tooling that understands your code. In C++, templates generate code through a metalanguage (template instantiation) that is separate from C++ itself.

Zig eliminates all of this:

  • No hidden control flow: operator overloading does not exist. An integer addition is an integer addition.
  • No hidden memory allocations: the standard library functions that allocate memory take an explicit Allocator parameter. Nothing allocates behind your back.
  • No preprocessor: there is no #define, no #ifdef, no macro system. Conditional compilation is done with if statements evaluated at compile time.
  • No macros: Zig’s metaprogramming is done with comptime — regular Zig code that runs at compile time. One language, not two.

Current State: Zig 0.13 and the Road to 1.0

As of early 2026, Zig is at version 0.13.x, with 0.14 in development. The language is pre-1.0 and makes no stability promises between releases. The standard library API changes. Build system APIs change. Async/await is being completely redesigned. This is a real cost that you must weigh before adopting Zig for production.

What does not change: the core language semantics, the memory model, the comptime system, and the C interoperability story. These have been stable in practice for several releases. The toolchain — zig build, zig cc, zig c++ — is production-quality today and used by projects that compile millions of lines of C.

Current: Zig 0.13.0
In development: Zig 0.14.0 (async redesign, incremental compilation)
Target 1.0: Estimated 2025-2026 (the Zig Software Foundation is well-funded)

Zig vs C Philosophically

C gives you control and trust. You control every byte. The language trusts you not to make mistakes, and when you do, the behavior is undefined. The compiler may or may not catch it. Tools like AddressSanitizer, UBSan, and Valgrind help, but they are external and optional.

Zig gives you the same control, but replaces trust with explicitness. You still control every byte. But:

  • Integer overflow is a runtime error in Debug and ReleaseSafe builds (not UB)
  • Array out-of-bounds is a runtime error in Debug and ReleaseSafe builds
  • Null pointer dereferences are eliminated by the optional type system
  • Unreachable code is a compile error or runtime panic, not UB
  • undefined is a value you can assign, and in Debug mode it is set to 0xaa bytes — use after assign-undefined is still detectable
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// In C: signed integer overflow is undefined behavior
// The compiler can delete code that follows it
int add_c(int a, int b) {
    return a + b;  // UB if overflow
}

// In Zig: you choose your overflow semantics explicitly
fn addZig(a: i32, b: i32) i32 {
    return a + b;  // Runtime panic on overflow in Debug/ReleaseSafe
}

fn addWrapping(a: i32, b: i32) i32 {
    return a +% b;  // Explicit wrapping add — always well-defined
}

fn addSaturating(a: i32, b: i32) i32 {
    return a +| b;  // Explicit saturating add — clamps to max/min
}

fn addChecked(a: i32, b: i32) ?i32 {
    return @addWithOverflow(a, b);  // Returns null on overflow (actually returns struct)
}

Zig vs Rust Philosophically

Rust prevents memory safety bugs at compile time by tracking ownership. This is genuinely powerful, but it changes the shape of your code significantly. You cannot have a doubly-linked list without unsafe, Rc, or a pool allocator. You cannot have self-referential structs without Pin. The borrow checker is a constraint you program around.

Zig takes the position that experienced systems programmers should be able to write correct code without a borrow checker, as long as the language makes unsafe operations explicit and visible (not hidden). Zig has no borrow checker. You can do anything C can do. The difference is:

  1. The unsafe operations are more explicit (e.g., pointer casts use @ptrCast which is visible in code review)
  2. Many bugs that are silent in C become runtime panics in Zig’s safe build modes
  3. The allocator system makes every allocation visible and auditable

This is a philosophical difference, not a quality difference. Rust’s approach gives stronger static guarantees. Zig’s approach gives a shallower learning curve and less fighting with the type system. Both are valid depending on your use case.


Memory Management Without Undefined Behavior

Memory management is where Zig’s design philosophy is most visible. There is no garbage collector. There is no borrow checker. There are explicit allocators.

The Allocator Interface

Every function in the Zig standard library that needs to allocate memory takes an std.mem.Allocator as a parameter. This is not just a convention — it is the core of how Zig thinks about memory.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const std = @import("std");
const Allocator = std.mem.Allocator;

// A function that needs to allocate takes an allocator explicitly
// The caller decides what allocator to use — heap, arena, stack, test allocator
fn buildMessage(allocator: Allocator, name: []const u8) ![]u8 {
    return std.fmt.allocPrint(allocator, "Hello, {s}!", .{name});
    // Caller is responsible for freeing the returned slice
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const msg = try buildMessage(allocator, "world");
    defer allocator.free(msg);

    std.debug.print("{s}\n", .{msg});
}

The Allocator is an interface — a struct containing function pointers. The interface is defined in the standard library and any allocator that implements it can be passed to any function that accepts Allocator. This means you can swap the general-purpose allocator for an arena allocator, a fixed-buffer allocator, or a custom allocator, without changing the functions that use memory.

std.heap.GeneralPurposeAllocator

The general-purpose allocator is the default choice for most programs. It wraps the system allocator and adds safety features in Debug mode: it detects double-frees, detects memory leaks on deinit(), and can optionally detect use-after-free.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const std = @import("std");

pub fn main() !void {
    // GeneralPurposeAllocator takes a configuration struct at comptime
    var gpa = std.heap.GeneralPurposeAllocator(.{
        .safety = true,          // Enable safety checks (default in Debug)
        .never_unmap = false,    // Actually return memory to OS
        .retain_metadata = true, // Keep metadata for better error messages
    }){};
    // deinit() returns .leak if there are memory leaks, .ok otherwise
    defer {
        const result = gpa.deinit();
        if (result == .leak) @panic("Memory leak detected!");
    }

    const allocator = gpa.allocator();

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

    // Allocate and initialize to zero
    const zeroed = try allocator.alloc(u8, 256);
    defer allocator.free(zeroed);
    @memset(zeroed, 0);

    // Resize an existing allocation
    const resized = try allocator.realloc(buffer, 2048);
    defer allocator.free(resized);

    // Create a single struct on the heap
    const MyStruct = struct { x: i32, y: i32 };
    const instance = try allocator.create(MyStruct);
    defer allocator.destroy(instance);
    instance.* = MyStruct{ .x = 10, .y = 20 };

    std.debug.print("Point: ({}, {})\n", .{ instance.x, instance.y });
}

std.heap.ArenaAllocator

The arena allocator is one of the most useful patterns in systems programming. You allocate from a backing allocator, and free everything at once. Individual frees are no-ops — the only real free is arena.deinit(). This is perfect for request handling, parsing, and any task that has a clear scope.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const std = @import("std");

// Simulate parsing a config file that produces many small allocations
const Config = struct {
    host: []const u8,
    port: u16,
    tags: [][]const u8,
};

fn parseConfig(allocator: std.mem.Allocator, input: []const u8) !Config {
    _ = input; // Simplified for demonstration

    // Each of these allocations uses the arena
    const host = try allocator.dupe(u8, "localhost");
    const tags = try allocator.alloc([]const u8, 3);
    tags[0] = try allocator.dupe(u8, "production");
    tags[1] = try allocator.dupe(u8, "us-east-1");
    tags[2] = try allocator.dupe(u8, "web");

    return Config{
        .host = host,
        .port = 8080,
        .tags = tags,
    };
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    // Create an arena backed by the GPA
    var arena = std.heap.ArenaAllocator.init(gpa.allocator());
    defer arena.deinit(); // Frees ALL arena allocations at once

    const config = try parseConfig(arena.allocator(), "host=localhost port=8080");

    std.debug.print("Host: {s}, Port: {d}\n", .{ config.host, config.port });
    for (config.tags) |tag| {
        std.debug.print("  Tag: {s}\n", .{tag});
    }
    // No individual frees needed — arena.deinit() handles everything
}

The arena allocator is also excellent for HTTP request handling. Each request gets its own arena; when the request is done, free the arena. Zero bookkeeping for individual request-scoped allocations.

std.heap.FixedBufferAllocator

When you need to allocate from a fixed-size stack buffer — embedded systems, hot code paths, frame allocators — use FixedBufferAllocator. It never calls the OS, never fails with OOM (it returns error.OutOfMemory when the buffer fills), and is trivially fast.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const std = @import("std");

pub fn main() !void {
    // Allocate from a stack buffer — no syscalls, no heap
    var buffer: [4096]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&buffer);
    const allocator = fba.allocator();

    // These allocations come from the stack buffer
    const numbers = try allocator.alloc(u32, 100);
    defer allocator.free(numbers); // No-op for FBA, but good practice

    for (numbers, 0..) |*n, i| {
        n.* = @intCast(i * i);
    }

    std.debug.print("First: {d}, Last: {d}\n", .{ numbers[0], numbers[99] });
    std.debug.print("Used: {d} bytes\n", .{fba.end_index});

    // If we try to allocate more than the buffer can hold:
    const big = allocator.alloc(u8, 8192) catch |err| {
        std.debug.print("Expected: {}\n", .{err}); // error.OutOfMemory
        return;
    };
    _ = big;
}

Passing Allocators Through Your Call Stack

A key pattern: functions accept allocators, they don’t use globals. This makes every allocation explicit and auditable. You can see in a function signature whether it can allocate.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
const std = @import("std");
const Allocator = std.mem.Allocator;

// This function allocates — you can tell because it takes an Allocator
// and returns ![]T (error union — alloc can fail with OutOfMemory)
fn readLines(allocator: Allocator, path: []const u8) ![][]u8 {
    const file = try std.fs.cwd().openFile(path, .{});
    defer file.close();

    var lines = std.ArrayList([]u8).init(allocator);
    errdefer {
        for (lines.items) |line| allocator.free(line);
        lines.deinit();
    }

    var buf_reader = std.io.bufferedReader(file.reader());
    var in_stream = buf_reader.reader();

    var line_buf: [4096]u8 = undefined;
    while (try in_stream.readUntilDelimiterOrEof(&line_buf, '\n')) |line| {
        const owned = try allocator.dupe(u8, line);
        try lines.append(owned);
    }

    return lines.toOwnedSlice();
}

// This function does NOT allocate — no Allocator parameter, no ! in return type
fn countWords(text: []const u8) usize {
    var count: usize = 0;
    var in_word = false;
    for (text) |c| {
        if (c == ' ' or c == '\t' or c == '\n') {
            in_word = false;
        } else if (!in_word) {
            in_word = true;
            count += 1;
        }
    }
    return count;
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // In a real program, path would be a real file
    // Here we just demonstrate the pattern
    _ = allocator;
    const text = "the quick brown fox jumps over the lazy dog";
    std.debug.print("Words: {d}\n", .{countWords(text)});
}

defer and errdefer: Deterministic Cleanup

Zig’s defer and errdefer are how you guarantee cleanup without C’s goto-based error handling or C++’s RAII destructors.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
const std = @import("std");

pub fn processFile(allocator: std.mem.Allocator, path: []const u8) !void {
    // defer runs when the scope exits, regardless of how (return, error, panic)
    const file = try std.fs.cwd().openFile(path, .{});
    defer file.close(); // Always runs

    // errdefer runs ONLY when the scope exits with an error
    // Perfect for cleanup when initialization partially fails
    const buffer = try allocator.alloc(u8, 8192);
    errdefer allocator.free(buffer); // Only frees if we return an error

    const bytes_read = try file.readAll(buffer);
    const content = buffer[0..bytes_read];

    // Process the content...
    std.debug.print("Read {d} bytes\n", .{bytes_read});

    // If we reach here successfully, the caller gets `buffer` back
    // (in a real function, we'd return something)
    _ = content;
    // Since we're not returning buffer to the caller, free it on success too:
    allocator.free(buffer);
}

// A more realistic pattern: multiple resources that must be cleaned up in order
pub fn initServer(allocator: std.mem.Allocator) !*Server {
    const server = try allocator.create(Server);
    errdefer allocator.destroy(server);

    server.socket = try openSocket();
    errdefer server.socket.close();

    server.thread_pool = try ThreadPool.init(allocator, 8);
    errdefer server.thread_pool.deinit();

    // All three initialized successfully — return without errdefer running
    return server;
}

// Stub types for the example above
const Server = struct { socket: Socket, thread_pool: ThreadPool };
const Socket = struct {
    fn close(_: Socket) void {}
};
const ThreadPool = struct {
    fn init(_: std.mem.Allocator, _: usize) !ThreadPool { return .{}; }
    fn deinit(_: *ThreadPool) void {}
};
fn openSocket() !Socket { return .{}; }

How This Eliminates C Bug Classes

Compare the C approach to the same logic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// C version: manual error handling with goto
// Missing a goto can leak resources — very easy to get wrong
int process_file(const char *path) {
    int ret = -1;
    FILE *f = NULL;
    char *buffer = NULL;

    f = fopen(path, "r");
    if (!f) goto cleanup;

    buffer = malloc(8192);
    if (!buffer) goto cleanup;

    // ... process ...
    ret = 0;

cleanup:
    if (buffer) free(buffer);
    if (f) fclose(f);
    return ret;
}

The Zig version with defer/errdefer is not just cleaner — the cleanup is structurally tied to the acquisition. It is impossible to forget to close the file after opening it because defer file.close() appears on the very next line. The C version requires scanning to the bottom of the function to verify cleanup is correct.


Error Handling

Zig’s error handling is not exceptions and it is not a Result type in the Rust/Haskell sense. It is its own design, built from first principles.

Error Sets

An error set is a set of error values, defined with error:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// A named error set
const FileError = error{
    NotFound,
    PermissionDenied,
    IsDirectory,
    DiskFull,
};

// Error sets can be merged with ||
const NetworkError = error{
    ConnectionRefused,
    Timeout,
    DnsFailure,
};

const AppError = FileError || NetworkError;

// The anyerror type is the union of all error sets in the program
// (useful for dynamic dispatch, but avoid in library code)

Error Union Types: !T

A function that can fail returns an error union: ErrorSet!ReturnType. The shorthand !T infers the error set from what errors the function can actually return.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const std = @import("std");

// Explicit error set
fn parseInt(s: []const u8) error{InvalidChar, Overflow}!i64 {
    return std.fmt.parseInt(i64, s, 10);
}

// Inferred error set (! alone) — the compiler figures out what errors are possible
// This is fine for application code, but library code should be explicit
fn readConfigValue(path: []const u8, key: []const u8) ![]const u8 {
    _ = path;
    _ = key;
    return error.NotFound;
}

try, catch, errdefer

try is syntactic sugar for “return the error if it’s an error, otherwise unwrap the value”:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// try expr is equivalent to:
// expr catch |err| return err

const std = @import("std");

fn doWork() !void {
    // Without try:
    const result1 = openFile("foo.txt") catch |err| return err;
    _ = result1;

    // With try (same semantics, cleaner):
    const result2 = try openFile("foo.txt");
    _ = result2;
}

fn openFile(path: []const u8) !std.fs.File {
    return std.fs.cwd().openFile(path, .{});
}

catch lets you handle the error inline or transform it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const std = @import("std");

pub fn example() void {
    // Provide a default value on error
    const value = parseInt("abc") catch 0;
    std.debug.print("Value: {d}\n", .{value});

    // Handle specific errors differently
    const file = std.fs.cwd().openFile("config.toml", .{}) catch |err| switch (err) {
        error.FileNotFound => {
            std.debug.print("Config not found, using defaults\n", .{});
            return;
        },
        error.AccessDenied => {
            std.debug.print("Permission denied reading config\n", .{});
            return;
        },
        else => {
            std.debug.print("Unexpected error: {}\n", .{err});
            return;
        },
    };
    defer file.close();
    std.debug.print("Opened config file\n", .{});
}

fn parseInt(s: []const u8) !i64 {
    return std.fmt.parseInt(i64, s, 10);
}

Error Return Traces

One of Zig’s killer features: in Debug and ReleaseSafe builds, when an error propagates up the call stack via try, Zig records the error return trace. When your program crashes or you print an error, you see exactly where the error originated and how it propagated — without stack unwinding, without exceptions, at near-zero cost.

error: FileNotFound
/home/user/myapp/src/config.zig:42:5: 0x103a2b in readConfig (myapp)
    try file.readAll(buf);
    ^
/home/user/myapp/src/main.zig:18:5: 0x103b1a in main (myapp)
    const config = try readConfig("missing.toml");
    ^

This is more useful than a traditional stack trace for diagnosing errors in production.

Comptime-Known Error Sets vs Rust’s Result

The key philosophical difference from Rust’s Result<T, E>: Zig error sets are comptime-known sets of error values, not arbitrary types. You cannot put arbitrary data in a Zig error. An error is an error tag — a small integer at runtime.

In Rust, Err(e) carries the error value, which can be any type with any data. This is more flexible. In Zig, if you need to attach context to an error (e.g., the line number where parsing failed), you return a struct that contains both the error and the context via a separate out parameter or a custom return type.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Zig: errors are tags, not values with data
fn parse(input: []const u8) !i64 {
    return std.fmt.parseInt(i64, input, 10) catch error.BadInput;
}

// To add context, return a struct
const ParseResult = struct {
    value: i64,
    warnings: []const []const u8,
};

fn parseWithContext(allocator: std.mem.Allocator, input: []const u8) !ParseResult {
    const value = std.fmt.parseInt(i64, input, 10) catch return error.BadInput;
    const warnings = try allocator.alloc([]const u8, 0);
    return ParseResult{ .value = value, .warnings = warnings };
}

const std = @import("std");

Comptime: Compile-Time Computation and Code Generation

comptime is Zig’s answer to C macros and C++ templates. It is the single most distinctive feature of the language, and arguably its most powerful. The key insight: there is no separate metalanguage. Comptime code is Zig code that runs at compile time. You use the same if, for, while, switch — the same function calls, the same type system.

The comptime Keyword

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const std = @import("std");

// A value known at compile time
const PAGE_SIZE = comptime blk: {
    const base = 4096;
    break :blk base;
};

// A function that runs at compile time when called with comptime arguments
fn factorial(comptime n: u64) u64 {
    if (n == 0) return 1;
    return n * factorial(n - 1);
}

// This call runs entirely at compile time — no runtime cost
const FACT_10 = factorial(10);

pub fn main() void {
    std.debug.print("PAGE_SIZE: {d}\n", .{PAGE_SIZE});
    std.debug.print("10! = {d}\n", .{FACT_10});

    // comptime blocks in expressions
    const max_connections = comptime blk: {
        const per_thread = 64;
        const thread_count = 8;
        break :blk per_thread * thread_count;
    };
    std.debug.print("Max connections: {d}\n", .{max_connections});
}

Type Parameters via comptime

Zig has no generics syntax. Instead, types are values at compile time. A generic function takes a type parameter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const std = @import("std");

// T is a comptime type parameter — this is how generics work in Zig
fn max(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}

// anytype infers the type from the argument — less explicit but very flexible
fn printAny(value: anytype) void {
    const T = @TypeOf(value);
    switch (@typeInfo(T)) {
        .int, .comptime_int => std.debug.print("int: {d}\n", .{value}),
        .float, .comptime_float => std.debug.print("float: {d:.2}\n", .{value}),
        .bool => std.debug.print("bool: {}\n", .{value}),
        .pointer => |info| {
            if (info.child == u8 and info.size == .Slice) {
                std.debug.print("string: {s}\n", .{value});
            } else {
                std.debug.print("pointer\n", .{});
            }
        },
        else => std.debug.print("other type: {s}\n", .{@typeName(T)}),
    }
}

pub fn main() void {
    std.debug.print("{d}\n", .{max(i32, 10, 20)});
    std.debug.print("{d}\n", .{max(f64, 3.14, 2.71)});
    std.debug.print("{s}\n", .{max(u8, 'a', 'z')});

    printAny(@as(i32, 42));
    printAny(@as(f32, 3.14));
    printAny(true);
    printAny("hello world");
}

Generic Data Structures

The standard library uses this to build generic containers. Here is a simplified generic stack:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
const std = @import("std");

// Returns a type — this is how generic types work in Zig
fn Stack(comptime T: type) type {
    return struct {
        items: []T,
        len: usize,
        allocator: std.mem.Allocator,

        const Self = @This();

        pub fn init(allocator: std.mem.Allocator) Self {
            return Self{
                .items = &[_]T{},
                .len = 0,
                .allocator = allocator,
            };
        }

        pub fn deinit(self: *Self) void {
            self.allocator.free(self.items);
        }

        pub fn push(self: *Self, item: T) !void {
            if (self.len >= self.items.len) {
                const new_capacity = if (self.items.len == 0) 8 else self.items.len * 2;
                self.items = try self.allocator.realloc(self.items, new_capacity);
            }
            self.items[self.len] = item;
            self.len += 1;
        }

        pub fn pop(self: *Self) ?T {
            if (self.len == 0) return null;
            self.len -= 1;
            return self.items[self.len];
        }

        pub fn peek(self: *const Self) ?T {
            if (self.len == 0) return null;
            return self.items[self.len - 1];
        }
    };
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    var int_stack = Stack(i32).init(gpa.allocator());
    defer int_stack.deinit();

    try int_stack.push(1);
    try int_stack.push(2);
    try int_stack.push(3);

    while (int_stack.pop()) |val| {
        std.debug.print("{d}\n", .{val}); // 3, 2, 1
    }

    // Exact same implementation, different type — no code duplication
    var string_stack = Stack([]const u8).init(gpa.allocator());
    defer string_stack.deinit();

    try string_stack.push("hello");
    try string_stack.push("world");
    std.debug.print("{s}\n", .{string_stack.pop().?}); // world
}

Comptime Reflection: @typeInfo and std.meta

@typeInfo returns a tagged union describing the type’s structure. This is how you write code that operates on arbitrary types — think serialization, debug printing, or ORM-style field mapping.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
const std = @import("std");

// Serialize any struct to a simple key=value format at compile time
// The field names and types are known at comptime
fn serialize(writer: anytype, value: anytype) !void {
    const T = @TypeOf(value);
    const type_info = @typeInfo(T);

    switch (type_info) {
        .@"struct" => |s| {
            try writer.writeAll("{");
            inline for (s.fields, 0..) |field, i| {
                if (i > 0) try writer.writeAll(", ");
                try writer.print("{s}: ", .{field.name});
                // Recursively serialize each field
                const field_value = @field(value, field.name);
                const field_info = @typeInfo(field.type);
                switch (field_info) {
                    .int, .comptime_int => try writer.print("{d}", .{field_value}),
                    .float, .comptime_float => try writer.print("{d:.3}", .{field_value}),
                    .bool => try writer.print("{}", .{field_value}),
                    .pointer => |p| {
                        if (p.child == u8) {
                            try writer.print("\"{s}\"", .{field_value});
                        } else {
                            try writer.print("<ptr>", .{});
                        }
                    },
                    else => try writer.print("<{s}>", .{@typeName(field.type)}),
                }
            }
            try writer.writeAll("}");
        },
        .int, .comptime_int => try writer.print("{d}", .{value}),
        .float, .comptime_float => try writer.print("{d:.3}", .{value}),
        .bool => try writer.print("{}", .{value}),
        else => try writer.print("<{s}>", .{@typeName(T)}),
    }
}

const Person = struct {
    name: []const u8,
    age: u32,
    score: f32,
    active: bool,
};

pub fn main() !void {
    const p = Person{
        .name = "Alice",
        .age = 30,
        .score = 98.5,
        .active = true,
    };

    const stdout = std.io.getStdOut().writer();
    try serialize(stdout, p);
    try stdout.writeByte('\n');
    // Output: {name: "Alice", age: 30, score: 98.500, active: true}
}

The inline for is crucial here: it unrolls the loop at compile time, iterating over the struct’s fields as comptime-known values. The result is that the generated code is specific to each struct type — no runtime reflection, no hash maps of field names, just straight-line field accesses.

Comptime Replacing C Macros

C macros are text substitution. They have no type checking, no scope, and no hygiene. Zig replaces them with comptime functions:

1
2
3
4
// C macro — no type safety, can have subtle bugs
#define MIN(a, b) ((a) < (b) ? (a) : (b))
#define ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0]))
#define STRINGIFY(x) #x
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Zig: type-safe, works at comptime and runtime
fn min(comptime T: type, a: T, b: T) T {
    return if (a < b) a else b;
}

// @sizeOf and array length are comptime builtins — no macro needed
fn arraySize(comptime T: type, comptime N: usize) usize {
    _ = T;
    return N;
}

// Stringification via @typeName and comptime string ops
fn typeName(comptime T: type) []const u8 {
    return @typeName(T);
}

Optional Types and Null Safety

C’s null pointer is Tony Hoare’s “billion dollar mistake” — a sentinel value that is indistinguishable from a real pointer until it dereferences into a segfault. Zig replaces null pointers with optional types.

?T Optional Types

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const std = @import("std");

// This function can return null — the type signature says so
fn findUser(id: u64) ?User {
    if (id == 1) {
        return User{ .id = 1, .name = "Alice" };
    }
    return null;
}

const User = struct { id: u64, name: []const u8 };

pub fn main() void {
    // The compiler forces you to handle the null case
    // You cannot pass ?User where User is expected
    const maybe_user: ?User = findUser(1);

    // Method 1: .? unwrap — panics at runtime if null (deliberate, explicit)
    const user1 = findUser(1).?;
    std.debug.print("User: {s}\n", .{user1.name});

    // Method 2: orelse — provide a default or return early
    const user2 = findUser(99) orelse {
        std.debug.print("User not found\n", .{});
        return;
    };
    std.debug.print("User: {s}\n", .{user2.name});

    // Method 3: if with capture — the idiomatic way
    if (findUser(1)) |user| {
        std.debug.print("Found user: {s}\n", .{user.name});
    } else {
        std.debug.print("Not found\n", .{});
    }

    // Method 4: while with capture — iterate until null (like a nullable iterator)
    _ = maybe_user;
}

Optional Pointers

A key point: ?*T is the same size as *T at runtime. Zig uses the null bit pattern for the null optional, just like C. So there is no overhead for optional pointers — you get null safety with zero cost.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const std = @import("std");

const Node = struct {
    value: i32,
    next: ?*Node, // Nullable pointer — explicit, zero-cost
};

fn sumList(head: ?*Node) i32 {
    var sum: i32 = 0;
    var current = head;
    while (current) |node| { // Capture non-null value
        sum += node.value;
        current = node.next;
    }
    return sum;
}

pub fn main() void {
    var n3 = Node{ .value = 3, .next = null };
    var n2 = Node{ .value = 2, .next = &n3 };
    var n1 = Node{ .value = 1, .next = &n2 };
    std.debug.print("Sum: {d}\n", .{sumList(&n1)}); // 6
}

In C, you would write Node *next and hope that the caller always checks for null. In Zig, ?*Node and *Node are different types. Passing a ?*Node where *Node is expected is a compile error. The null check is mandatory, not optional.


Slices, Pointers, and Safety

Zig has a more explicit pointer type system than C. There are four main pointer types and each has different semantics.

The Four Pointer Types

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const std = @import("std");

pub fn pointerTypes() void {
    var array = [_]i32{ 1, 2, 3, 4, 5 };

    // *T — single item pointer. Equivalent to T* in C when pointing to one item.
    // Cannot do pointer arithmetic.
    const single: *i32 = &array[0];
    single.* = 10; // Dereference with .*
    std.debug.print("single: {d}\n", .{single.*});

    // [*]T — many-item pointer. Equivalent to T* in C when used for arrays.
    // Supports pointer arithmetic but has NO length — you must track length yourself.
    // Only use this for C interop or when you know what you're doing.
    const many: [*]i32 = &array;
    many[1] = 20; // Index into it
    const offset_ptr = many + 2; // Pointer arithmetic
    std.debug.print("offset: {d}\n", .{offset_ptr[0]});

    // []T — slice. A fat pointer: pointer + length.
    // This is what you use for almost everything.
    // Bounds-checked in Debug and ReleaseSafe builds.
    const slice: []i32 = &array;
    std.debug.print("len: {d}\n", .{slice.len});
    std.debug.print("slice[2]: {d}\n", .{slice[2]});

    // [*:0]T — sentinel-terminated pointer. Equivalent to null-terminated C strings.
    // Has pointer arithmetic, stops at the sentinel value (0 for C strings).
    const c_str: [*:0]const u8 = "hello world";
    std.debug.print("c_str: {s}\n", .{c_str});
}

Slices: The Safe Array Type

Slices are the correct type for most array work in Zig. They carry a pointer and a length, and in Debug/ReleaseSafe builds, all slice indexing is bounds-checked:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const std = @import("std");

fn sumSlice(numbers: []const i32) i32 {
    var total: i32 = 0;
    for (numbers) |n| total += n;
    return total;
}

fn splitAt(slice: []const u8, index: usize) struct { left: []const u8, right: []const u8 } {
    return .{
        .left = slice[0..index],
        .right = slice[index..],
    };
}

pub fn main() void {
    const data = [_]i32{ 1, 2, 3, 4, 5 };
    std.debug.print("Sum: {d}\n", .{sumSlice(&data)});

    // Slice syntax: array[start..end] produces a slice
    std.debug.print("Sum of [1..4]: {d}\n", .{sumSlice(data[1..4])});

    // String slices
    const s = "Hello, World!";
    const parts = splitAt(s, 7);
    std.debug.print("Left: {s}, Right: {s}\n", .{ parts.left, parts.right });

    // In Debug mode, this panics instead of returning garbage:
    // const oob = data[10]; // panic: index out of bounds
}

Build Modes and Safety

Zig has four build modes, selectable with -ODebug, -OReleaseSafe, -OReleaseFast, -OReleaseSmall:

Mode Safety checks Optimizations Use case
Debug All checks enabled None Development, testing
ReleaseSafe All checks enabled Full Production when safety matters
ReleaseFast No checks Full + assume no UB Performance-critical, trusted inputs
ReleaseSmall No checks Size optimization Embedded, WASM
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const std = @import("std");
const builtin = @import("builtin");

pub fn safeDivide(a: i32, b: i32) i32 {
    // In Debug/ReleaseSafe: this panics with a helpful message if b == 0
    // In ReleaseFast: this is UB (undefined behavior) — you opt in to that
    if (builtin.mode == .Debug or builtin.mode == .ReleaseSafe) {
        if (b == 0) @panic("Division by zero");
    }
    return @divTrunc(a, b);
}

pub fn main() void {
    std.debug.print("Mode: {}\n", .{builtin.mode});
    std.debug.print("10 / 2 = {d}\n", .{safeDivide(10, 2)});
}
1
2
3
4
zig build-exe main.zig -ODebug         # Default, all safety checks
zig build-exe main.zig -OReleaseSafe   # Optimized with safety
zig build-exe main.zig -OReleaseFast   # Maximum performance
zig build-exe main.zig -OReleaseSmall  # Minimum binary size

@ptrCast and Explicit Unsafe

When you need to reinterpret memory (type punning, C interop, hardware registers), you use @ptrCast. Unlike C’s implicit casts, @ptrCast is visible and searchable. A code review can grep for it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const std = @import("std");

// Reinterpret bytes as a struct — requires @ptrCast
// This is the Zig equivalent of C's (struct MyProtocol*)buf
const PacketHeader = packed struct {
    version: u4,
    type: u4,
    length: u16,
    sequence: u32,
};

fn parseHeader(data: []const u8) !*const PacketHeader {
    if (data.len < @sizeOf(PacketHeader)) return error.TooShort;
    // @ptrCast is explicit — you can grep for all pointer casts in a codebase
    return @ptrCast(@alignCast(data.ptr));
}

pub fn main() !void {
    // Simulate receiving a network packet
    const packet = [_]u8{ 0x15, 0x00, 0x08, 0x00, 0x01, 0x00, 0x00, 0x00 };
    const header = try parseHeader(&packet);
    std.debug.print("Version: {d}, Type: {d}, Length: {d}\n", .{
        header.version,
        header.type,
        header.length,
    });
}

Zig as a C/C++ Build System

This is where Zig becomes compelling even for projects that never write a single line of Zig code. The Zig compiler ships with a complete C and C++ compiler (based on LLVM), bundled with cross-compilation targets for Linux, macOS, Windows, FreeBSD, and more — all in a single 60-90MB binary.

zig cc: Drop-in C Compiler

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Compile C code with zig cc — behaves like clang/gcc
zig cc -o hello hello.c

# Cross-compile for Linux ARM64 from any host
zig cc -o hello-arm64 hello.c -target aarch64-linux-musl

# Cross-compile for Windows from Linux
zig cc -o hello.exe hello.c -target x86_64-windows-gnu

# Static binary for Linux (fully self-contained)
zig cc -o hello-static hello.c -target x86_64-linux-musl -static

# Drop-in replacement in existing build systems
CC="zig cc" CXX="zig c++" ./configure
CC="zig cc" make -j8

Cross-Compilation: The Real Story

Cross-compilation in the traditional C world is painful. You need a cross toolchain for each target, headers for the target platform, the right linker, the right libc. Setting this up for even two targets (say, Linux x86_64 and Linux ARM64) typically requires hours of work or Docker images with pre-built toolchains.

With Zig, cross-compilation is a single flag:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Compile a Zig program for multiple targets from one machine
zig build-exe main.zig -target x86_64-linux-musl
zig build-exe main.zig -target aarch64-linux-musl
zig build-exe main.zig -target x86_64-macos
zig build-exe main.zig -target aarch64-macos
zig build-exe main.zig -target x86_64-windows-gnu
zig build-exe main.zig -target wasm32-freestanding

# List all available targets
zig targets | head -50

Zig bundles the C standard library headers for every target. It includes glibc stubs for Linux targets that need glibc ABI compatibility. It handles the linker scripts, CRT startup files, and sysroots internally.

build.zig: The Build Script

build.zig replaces Makefile, CMakeLists.txt, and configure. It is a Zig program that runs at “meta build” time to describe how to build your project. It has full access to the Zig standard library and can do arbitrary computation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// build.zig — the build script for a simple project
const std = @import("std");

pub fn build(b: *std.Build) void {
    // Standard target options — defaults to native, can be overridden with -Dtarget=...
    const target = b.standardTargetOptions(.{});

    // Standard optimization options — defaults to Debug, override with -Doptimize=...
    const optimize = b.standardOptimizeOption(.{});

    // Create an executable
    const exe = b.addExecutable(.{
        .name = "myapp",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

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

    // Create a 'run' step: `zig build run`
    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }
    const run_step = b.step("run", "Run the application");
    run_step.dependOn(&run_cmd.step);

    // Create a 'test' step: `zig build test`
    const unit_tests = b.addTest(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });
    const run_unit_tests = b.addRunArtifact(unit_tests);
    const test_step = b.step("test", "Run unit tests");
    test_step.dependOn(&run_unit_tests.step);
}

build.zig for C/C++ Projects

Here is the compelling use case: using Zig’s build system for an existing C/C++ project, getting cross-compilation and a reproducible build for free:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// build.zig — building a C library + a Zig wrapper
const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // Build a C library (e.g., an existing C project)
    const libfoo = b.addStaticLibrary(.{
        .name = "foo",
        .target = target,
        .optimize = optimize,
    });
    libfoo.addCSourceFiles(.{
        .files = &.{
            "vendor/libfoo/src/foo.c",
            "vendor/libfoo/src/bar.c",
            "vendor/libfoo/src/utils.c",
        },
        .flags = &.{
            "-std=c99",
            "-Wall",
            "-Wextra",
            "-fno-sanitize=undefined", // If the library has UB we can't fix
        },
    });
    libfoo.addIncludePath(b.path("vendor/libfoo/include"));
    libfoo.linkLibC();

    // Build the main Zig executable that uses the C library
    const exe = b.addExecutable(.{
        .name = "myapp",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });
    exe.linkLibrary(libfoo);
    exe.addIncludePath(b.path("vendor/libfoo/include"));
    exe.linkLibC();

    b.installArtifact(exe);
}
1
2
3
4
5
6
7
8
# Build natively
zig build

# Cross-compile the whole thing to ARM64 Linux, including all C dependencies
zig build -Dtarget=aarch64-linux-musl

# Or to Windows
zig build -Dtarget=x86_64-windows-gnu

Using zig cc to Build Large C Projects

Projects like cURL, SQLite, and Redis can be compiled with zig cc as a drop-in replacement. The benefit: you get a reproducible, hermetic build that can target any platform:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Example: cross-compiling SQLite for multiple targets
# Clone SQLite amalgamation
curl -O https://sqlite.org/2024/sqlite-amalgamation-3450000.zip
unzip sqlite-amalgamation-3450000.zip
cd sqlite-amalgamation-3450000

# Build for native
zig cc -O2 -o sqlite3-native shell.c sqlite3.c -lpthread -ldl

# Build for ARM64 Linux (static, no deps)
zig cc -O2 -target aarch64-linux-musl -static \
  -o sqlite3-arm64-linux \
  shell.c sqlite3.c

# Build for Windows
zig cc -O2 -target x86_64-windows-gnu \
  -o sqlite3-windows.exe \
  shell.c sqlite3.c

# All three produced from the same host, no cross toolchain setup required
ls -lh sqlite3-*

This is not a toy demo. The Bun.js project uses zig cc to cross-compile JavaScriptCore (hundreds of thousands of lines of C++) for multiple targets from a single CI machine.


C Interoperability

Zig’s C interop is arguably the best of any systems language. There is no FFI layer, no binding generation step, no overhead. Zig and C share the same ABI.

@cImport and @cInclude

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const std = @import("std");

// @cImport brings a C header into Zig's namespace
// The translation happens at compile time
const c = @cImport({
    @cInclude("stdio.h");
    @cInclude("string.h");
    @cInclude("stdlib.h");
});

pub fn main() void {
    // Call C functions directly — no wrapper, no overhead
    _ = c.printf("Hello from C's printf: %d\n", @as(c_int, 42));

    // C string functions work naturally
    const greeting = "Hello, World!";
    const len = c.strlen(greeting);
    std.debug.print("C strlen: {d}\n", .{len});

    // C's malloc and free
    const buf = c.malloc(256) orelse {
        std.debug.print("malloc failed\n", .{});
        return;
    };
    defer c.free(buf);

    _ = c.sprintf(@ptrCast(buf), "Value is %d", @as(c_int, 99));
    std.debug.print("sprintf result: {s}\n", .{@as([*:0]const u8, @ptrCast(buf))});
}

Calling a Real C Library

Here is a realistic example: using libcurl from Zig:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const std = @import("std");
const c = @cImport({
    @cInclude("curl/curl.h");
});

// Callback function for libcurl — must match C calling convention
fn writeCallback(
    data: [*c]const u8,
    size: c_size_t,
    nmemb: c_size_t,
    user_data: ?*anyopaque,
) callconv(.C) c_size_t {
    const list: *std.ArrayList(u8) = @ptrCast(@alignCast(user_data));
    const slice = data[0 .. size * nmemb];
    list.appendSlice(slice) catch return 0;
    return size * nmemb;
}

pub fn httpGet(allocator: std.mem.Allocator, url: []const u8) ![]u8 {
    var response = std.ArrayList(u8).init(allocator);
    errdefer response.deinit();

    const curl = c.curl_easy_init() orelse return error.CurlInitFailed;
    defer c.curl_easy_cleanup(curl);

    // Null-terminate the URL for C
    const url_z = try allocator.dupeZ(u8, url);
    defer allocator.free(url_z);

    _ = c.curl_easy_setopt(curl, c.CURLOPT_URL, url_z.ptr);
    _ = c.curl_easy_setopt(curl, c.CURLOPT_WRITEFUNCTION, &writeCallback);
    _ = c.curl_easy_setopt(curl, c.CURLOPT_WRITEDATA, &response);
    _ = c.curl_easy_setopt(curl, c.CURLOPT_FOLLOWLOCATION, @as(c_long, 1));

    const res = c.curl_easy_perform(curl);
    if (res != c.CURLE_OK) {
        std.debug.print("curl error: {s}\n", .{c.curl_easy_strerror(res)});
        return error.CurlFailed;
    }

    return response.toOwnedSlice();
}

Exporting Zig Functions to C

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// lib.zig — a Zig library that exports a C API
const std = @import("std");

// export + callconv(.C) makes this callable from C
export fn zig_add(a: c_int, b: c_int) c_int {
    return a + b;
}

export fn zig_process_buffer(
    data: [*]const u8,
    len: usize,
    out: [*]u8,
    out_len: usize,
) c_int {
    if (len > out_len) return -1;
    const src = data[0..len];
    const dst = out[0..len];
    // Transform: uppercase all ASCII letters
    for (src, dst) |byte, *d| {
        d.* = if (byte >= 'a' and byte <= 'z') byte - 32 else byte;
    }
    return @intCast(len);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
/* main.c — using the Zig library from C */
#include <stdio.h>

// Declarations for the Zig functions
extern int zig_add(int a, int b);
extern int zig_process_buffer(const unsigned char *data, size_t len,
                               unsigned char *out, size_t out_len);

int main(void) {
    printf("zig_add(3, 4) = %d\n", zig_add(3, 4));

    const char *input = "hello world";
    char output[32] = {0};
    int n = zig_process_buffer(
        (const unsigned char *)input, 11,
        (unsigned char *)output, sizeof(output)
    );
    printf("Processed %d bytes: %s\n", n, output);
    return 0;
}

zig translate-c

Zig can translate C headers to Zig code, which is useful for understanding how Zig sees a C API:

1
2
3
4
5
# Translate a C header to Zig
zig translate-c /usr/include/sqlite3.h > sqlite3.zig

# Translate with specific targets
zig translate-c -target aarch64-linux-musl /usr/include/string.h

The output is not always pretty, but it shows you exactly what types Zig maps to each C type, which is helpful when writing C interop code.


Async and Concurrency

The Current State of Async in Zig

This section requires honesty about Zig’s async story, because it is currently in flux.

Zig had a stackless async/await system based on async, await, suspend, and resume keywords. This system was clever — async functions compiled to state machines, function frames could be stored on the stack or heap, and there was no mandatory runtime (unlike Go’s goroutine scheduler or Rust’s tokio/async-std). The developer chose the execution model.

However, this system had fundamental design issues that became apparent as the codebase grew. As of Zig 0.12, async/await was removed from the language. It is being completely redesigned for Zig 0.14+. The new design is expected to be simpler, based on stackful coroutines or fibers, with a cleaner integration with the I/O model.

Status as of Zig 0.13:
- async/await keywords: REMOVED (compile error if used)
- std.event loop: REMOVED
- Stackless coroutines: Under redesign
- Expected in: Zig 0.14 or later

What’s Available Now for Concurrency

Without async, the options for concurrency in Zig today are:

Threads with std.Thread:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const std = @import("std");

fn workerThread(id: usize) void {
    std.debug.print("Worker {d} starting\n", .{id});
    // Simulate work
    std.time.sleep(10 * std.time.ns_per_ms);
    std.debug.print("Worker {d} done\n", .{id});
}

pub fn main() !void {
    var threads: [8]std.Thread = undefined;

    for (&threads, 0..) |*t, i| {
        t.* = try std.Thread.spawn(.{}, workerThread, .{i});
    }

    for (&threads) |t| {
        t.join();
    }
}

Thread pool pattern (manual):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const std = @import("std");

// A simple work queue using a Mutex and Condition
const WorkQueue = struct {
    mutex: std.Thread.Mutex,
    cond: std.Thread.Condition,
    items: std.ArrayList(WorkItem),
    done: bool,

    const WorkItem = struct {
        data: []const u8,
    };

    fn init(allocator: std.mem.Allocator) WorkQueue {
        return .{
            .mutex = .{},
            .cond = .{},
            .items = std.ArrayList(WorkItem).init(allocator),
            .done = false,
        };
    }

    fn deinit(self: *WorkQueue) void {
        self.items.deinit();
    }

    fn push(self: *WorkQueue, item: WorkItem) !void {
        self.mutex.lock();
        defer self.mutex.unlock();
        try self.items.append(item);
        self.cond.signal();
    }

    fn pop(self: *WorkQueue) ?WorkItem {
        self.mutex.lock();
        defer self.mutex.unlock();
        while (self.items.items.len == 0 and !self.done) {
            self.cond.wait(&self.mutex);
        }
        if (self.items.items.len == 0) return null;
        return self.items.pop();
    }
};

std.io.poll for I/O multiplexing:

For servers that need to handle many connections, std.io.poll and std.posix provide the building blocks, though the ergonomics are lower-level than async/await. Many Zig networking projects use epoll/kqueue directly through std.posix primitives.

The async redesign is one of the highest-priority items for the Zig team. Until it lands, Zig is less ergonomic for I/O-bound concurrent programs than Go or Rust with tokio. For CPU-bound parallelism and thread-based servers, the current toolset is sufficient.


The Standard Library and Tooling

ArrayList and HashMap

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const std = @import("std");

pub fn stdlibDemo(allocator: std.mem.Allocator) !void {
    // ArrayList — Zig's Vec<T>
    var list = std.ArrayList(i32).init(allocator);
    defer list.deinit();

    try list.append(1);
    try list.append(2);
    try list.append(3);
    try list.appendSlice(&[_]i32{ 4, 5, 6 });

    std.debug.print("List: ", .{});
    for (list.items) |item| {
        std.debug.print("{d} ", .{item});
    }
    std.debug.print("\n", .{});

    // Sort in place
    std.mem.sort(i32, list.items, {}, std.sort.asc(i32));

    // HashMap — Zig's HashMap<K, V>
    var map = std.AutoHashMap([]const u8, u32).init(allocator);
    defer map.deinit();

    try map.put("alice", 30);
    try map.put("bob", 25);
    try map.put("charlie", 35);

    if (map.get("alice")) |age| {
        std.debug.print("Alice is {d}\n", .{age});
    }

    // Iterate
    var iter = map.iterator();
    while (iter.next()) |entry| {
        std.debug.print("{s}: {d}\n", .{ entry.key_ptr.*, entry.value_ptr.* });
    }

    // StringHashMap is AutoHashMap with []const u8 keys and proper hashing
    var string_map = std.StringHashMap(bool).init(allocator);
    defer string_map.deinit();
    try string_map.put("enabled", true);
    try string_map.put("debug", false);
}

std.json

Zig’s JSON parser is in the standard library and handles both parsing and serialization:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
const std = @import("std");

const ServerConfig = struct {
    host: []const u8,
    port: u16,
    max_connections: u32,
    tls: bool,
    tags: []const []const u8,
};

pub fn jsonDemo(allocator: std.mem.Allocator) !void {
    const json_input =
        \\{
        \\  "host": "0.0.0.0",
        \\  "port": 8443,
        \\  "max_connections": 1000,
        \\  "tls": true,
        \\  "tags": ["production", "web", "v2"]
        \\}
    ;

    // Parse JSON into a typed struct
    const parsed = try std.json.parseFromSlice(
        ServerConfig,
        allocator,
        json_input,
        .{},
    );
    defer parsed.deinit();

    const config = parsed.value;
    std.debug.print("Host: {s}:{d}\n", .{ config.host, config.port });
    std.debug.print("TLS: {}\n", .{config.tls});
    for (config.tags) |tag| {
        std.debug.print("Tag: {s}\n", .{tag});
    }

    // Serialize to JSON
    var output = std.ArrayList(u8).init(allocator);
    defer output.deinit();

    try std.json.stringify(config, .{ .whitespace = .indent_2 }, output.writer());
    std.debug.print("\nSerialized:\n{s}\n", .{output.items});
}

std.fs and std.net

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const std = @import("std");

pub fn fsDemo(allocator: std.mem.Allocator) !void {
    // Write a file
    const file = try std.fs.cwd().createFile("output.txt", .{ .read = true });
    defer file.close();

    try file.writeAll("Hello from Zig!\n");
    try file.writer().print("Value: {d}\n", .{42});

    // Read it back
    try file.seekTo(0);
    const content = try file.readToEndAlloc(allocator, 1024 * 1024);
    defer allocator.free(content);
    std.debug.print("Read: {s}", .{content});

    // Directory operations
    try std.fs.cwd().makeDir("test_dir");
    defer std.fs.cwd().deleteDir("test_dir") catch {};

    // Walk a directory
    var dir = try std.fs.cwd().openDir(".", .{ .iterate = true });
    defer dir.close();

    var it = dir.iterate();
    while (try it.next()) |entry| {
        std.debug.print("{s}: {s}\n", .{
            @tagName(entry.kind),
            entry.name,
        });
    }
}

Testing with std.testing

Zig has built-in testing. Tests live in the same file as the code, using test blocks:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
const std = @import("std");
const testing = std.testing;

fn fibonacci(n: u32) u64 {
    if (n <= 1) return n;
    var a: u64 = 0;
    var b: u64 = 1;
    var i: u32 = 2;
    while (i <= n) : (i += 1) {
        const tmp = a + b;
        a = b;
        b = tmp;
    }
    return b;
}

test "fibonacci basics" {
    try testing.expectEqual(@as(u64, 0), fibonacci(0));
    try testing.expectEqual(@as(u64, 1), fibonacci(1));
    try testing.expectEqual(@as(u64, 1), fibonacci(2));
    try testing.expectEqual(@as(u64, 8), fibonacci(6));
    try testing.expectEqual(@as(u64, 55), fibonacci(10));
}

fn parsePort(s: []const u8) !u16 {
    const n = try std.fmt.parseInt(u32, s, 10);
    if (n > 65535) return error.PortOutOfRange;
    return @intCast(n);
}

test "parsePort valid" {
    try testing.expectEqual(@as(u16, 8080), try parsePort("8080"));
    try testing.expectEqual(@as(u16, 443), try parsePort("443"));
    try testing.expectEqual(@as(u16, 65535), try parsePort("65535"));
}

test "parsePort invalid" {
    try testing.expectError(error.InvalidCharacter, parsePort("abc"));
    try testing.expectError(error.PortOutOfRange, parsePort("65536"));
    try testing.expectError(error.InvalidCharacter, parsePort(""));
}

// Test with an allocator
test "ArrayList operations" {
    const allocator = testing.allocator; // Test allocator — detects leaks!
    var list = std.ArrayList(i32).init(allocator);
    defer list.deinit();

    try list.append(1);
    try list.append(2);
    try list.append(3);

    try testing.expectEqual(@as(usize, 3), list.items.len);
    try testing.expectEqual(@as(i32, 2), list.items[1]);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Run tests
zig test src/main.zig

# Run tests with specific filters
zig test src/main.zig --test-filter "fibonacci"

# Run tests in a build.zig project
zig build test

# Run tests with verbose output
zig build test -- --verbose

testing.allocator is a special allocator that reports leaks at test completion. Every test that allocates and forgets to free will fail with a leak report. This makes it trivially easy to catch memory leaks in library code.

ZLS: Zig Language Server

ZLS (Zig Language Server) provides editor integration via the Language Server Protocol. It supports VSCode, Neovim, Emacs, Sublime Text, and any LSP-capable editor.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Install ZLS (must match your zig version exactly)
# Via package manager:
brew install zls                    # macOS
sudo snap install zls               # Ubuntu (via snap)

# Or build from source (always matches your zig version):
git clone https://github.com/zigtools/zls
cd zls
zig build -Doptimize=ReleaseSafe
# Binary is at ./zig-out/bin/zls

# VSCode: install "Zig Language" extension
# It will auto-download ZLS or use your PATH

ZLS provides:

  • Go to definition (including jumping into standard library code)
  • Hover documentation
  • Code completion
  • Error diagnostics inline
  • Semantic highlighting
  • inlay hints for type inference

Ziglings

Ziglings is the canonical Zig learning resource — small broken programs you fix to learn the language. Think Rustlings, but for Zig.

1
2
3
4
git clone https://codeberg.org/ziglings/exercises
cd exercises
zig build
# Follow the prompts

It covers optionals, error handling, comptime, slices, pointers, and more through 100+ exercises.


Real-World Use Cases

Bun.js

Bun is a JavaScript runtime written in Zig. It bundles a JavaScript engine (JavaScriptCore, the engine from Safari), a package manager, a bundler, and a test runner — all in one binary, all written in Zig.

Bun uses Zig because:

  1. The performance requirements (competitive with or faster than Node.js) require manual memory management
  2. The cross-compilation story makes building release binaries for Linux, macOS, and Windows straightforward
  3. C/C++ interop with JavaScriptCore (which is C++) is zero-overhead
  4. comptime enables efficient metaprogramming for things like HTTP routing tables

Bun reaches over 1 million GitHub stars and ships a production product used by tens of thousands of developers. It is the highest-profile demonstration that Zig is production-ready for complex, performance-critical software.

TigerBeetle

TigerBeetle is a financial transactions database written entirely in Zig. It is designed for strictly serializable distributed transactions at massive throughput. It has become one of the flagship examples of Zig in production.

TigerBeetle exploits several Zig features:

  • Arena allocators for request-scoped memory with deterministic cleanup
  • comptime for generating optimized storage engine code
  • Explicit allocators ensuring zero unexpected allocations in the hot path
  • Direct I/O with io_uring via Zig’s POSIX interface for maximum disk throughput

TigerBeetle’s choice of Zig was deliberate: they needed C-level performance and control, without C’s undefined behavior risks in financial-critical code.

Mach Game Engine

Mach is a game engine and framework written in Zig. It targets native platforms (Linux, macOS, Windows) and WebAssembly, with a focus on modular components. Mach uses Zig’s comptime system extensively for its Entity Component System (ECS).

Using zig cc for Existing C Projects

Several teams have adopted zig cc as their C compiler without writing any Zig code:

  • Uber wrote a blog post about using zig cc to cross-compile Go projects’ CGo dependencies
  • Many embedded and systems teams use zig cc for its hermetic, reproducible builds in CI
  • The Rust community has used zig cc as a cross-linker for targets that lack a Rust-supported cross toolchain

An Honest Assessment

The Pre-1.0 Stability Tax

Zig has not reached 1.0. This is not a theoretical concern. Between Zig 0.11 and 0.12, the build system API changed significantly. Between 0.12 and 0.13, more changes. Async/await was removed entirely. If you write a non-trivial Zig project today, plan on spending time on each Zig upgrade to fix breakages.

For library code, this is mitigated by pinning to a specific Zig version and upgrading deliberately. For applications, it is manageable. For production systems without dedicated Zig expertise, it is a real risk.

The counterargument: Zig’s changes are purposeful, tracked publicly, and the community provides migration guides. The language is clearly converging — the core semantics have been stable for multiple releases. The instability is in the stdlib and tooling APIs, not the fundamentals.

Ecosystem: Smaller Than Rust or C, Growing Fast

The Zig ecosystem is young. For most domains, you will find:

  • One or two libraries where Rust has a dozen mature ones
  • Libraries that may be abandoned if the author moves on
  • No de facto standard for things like HTTP servers, argument parsing, or database drivers

The package manager (zig fetch + build.zig.zon) is functional but not yet a full Cargo. The package registry (packages.zig.pm and the official index) is small.

Where Zig genuinely does not need a rich ecosystem: C interop. If a C library exists for your task (SQLite, libcurl, openssl, libpq), you can call it from Zig trivially. Zig programs routinely use C libraries directly without a wrapper.

Learning Curve by Background

Coming from C: The learning curve is moderate. Pointers, manual memory management, structs — all familiar. The new concepts are: the allocator pattern, error unions, optional types, and comptime. Plan for 1-2 weeks of disorientation, then rapid progress. Most experienced C programmers find Zig natural within a month.

Coming from C++: Similar to C, but you will miss features: no operator overloading, no RAII (use defer instead), no templates (use comptime). The mental model shift from RAII/destructors to explicit defer is the main adjustment. Many C++ programmers find Zig’s explicitness refreshing after fighting with template error messages.

Coming from Rust: The borrow checker absence feels like freedom and like danger simultaneously. You will write use-after-free bugs. You will write double-frees. The safety nets in Debug mode (panics on out-of-bounds, detectable memory leaks in tests) help, but they are not the borrow checker. Experienced Rustaceans sometimes find Zig’s lack of lifetime tracking uncomfortable; others find it liberating.

Coming from Go, Python, or Java: Expect a significant adjustment. Manual memory management, no garbage collector, pointer types, undefined behavior in ReleaseFast mode — these are genuinely new concepts. Zig is not a good first systems language; Go is a better bridge. But for engineers who have the context, Zig’s explicitness becomes an asset.

Where Zig Wins vs Rust vs C

Zig wins over C when:

  • You want defined behavior (not UB) in integer overflow, null dereferences, and out-of-bounds
  • You want error handling that is auditable and explicit, not return-code-and-hope
  • You want cross-compilation without a custom toolchain
  • You want to replace your Makefile/CMake with a build system that is just code
  • You want comptime metaprogramming without the preprocessor

Zig wins over Rust when:

  • You want a shallower learning curve without fighting the borrow checker
  • You are writing embedded code where Rust’s std is unavailable and the ecosystem is thin
  • You need tight C/C++ interop without FFI friction
  • You want to prototype quickly in a low-level language
  • Your team has strong C/C++ experience but limited Rust experience

C wins over Zig when:

  • You need absolute maximum ecosystem compatibility (every platform, every toolchain, 40 years of libraries)
  • You are targeting an embedded toolchain that only speaks GCC (some obscure MCUs)
  • Your team has zero appetite for pre-1.0 tool risk
  • You have a massive existing C codebase with no new-language budget

Rust wins over Zig when:

  • You need compile-time memory safety guarantees (no use-after-free possible)
  • You need compile-time data race prevention
  • You have access to a rich async ecosystem (tokio, async-std, axum)
  • Your team can invest in the learning curve and reaps the safety dividend long-term
  • You are writing security-critical code where an extra class of runtime panics is unacceptable

A Practical Decision Framework

Does your team have C/C++ expertise?
├── Yes → Zig is approachable. The learning is additive, not a paradigm shift.
└── No  → Consider Rust or Go first; build the systems programming foundation.

Is pre-1.0 stability acceptable?
├── Yes → Zig is viable today for greenfield projects.
└── No  → Wait for 1.0, or use C/Rust for the stability guarantee.

Do you need the richest possible ecosystem?
├── Yes → Rust (crates.io) or C (decades of libraries).
└── No  → Zig's C interop covers most gaps.

Is cross-compilation a primary concern?
├── Yes → Zig's build system is one of the best answers to this problem.
└── No  → Less differentiating.

Do you need compile-time memory safety?
├── Yes → Rust is the answer. The borrow checker is not optional there.
└── No  → Zig gives you runtime safety in debug builds, explicit unsafe ops in release.

Putting It Together: A Complete Example

Let’s build a small but real program: a command-line tool that reads a JSON config file, connects to multiple hosts in parallel to check their health, and reports results. This demonstrates allocators, error handling, threading, JSON parsing, and standard library use together.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
const std = @import("std");
const net = std.net;
const json = std.json;
const ArrayList = std.ArrayList;
const Thread = std.Thread;

// --- Types ---

const HostConfig = struct {
    host: []const u8,
    port: u16,
    timeout_ms: u32 = 2000,
};

const Config = struct {
    targets: []const HostConfig,
};

const HealthResult = struct {
    host: []const u8,
    port: u16,
    reachable: bool,
    latency_ms: ?u64,
    error_msg: ?[]const u8,
};

// --- Worker ---

const CheckArgs = struct {
    target: HostConfig,
    result: *HealthResult,
    arena: std.mem.Allocator,
};

fn checkHost(args: CheckArgs) void {
    const start = std.time.milliTimestamp();

    const address = net.Address.parseIp4(args.target.host, args.target.port) catch |err| {
        args.result.reachable = false;
        args.result.error_msg = std.fmt.allocPrint(
            args.arena,
            "DNS/parse error: {}",
            .{err},
        ) catch "parse error";
        return;
    };

    const stream = net.tcpConnectToAddress(address) catch |err| {
        args.result.reachable = false;
        args.result.error_msg = std.fmt.allocPrint(
            args.arena,
            "Connection failed: {}",
            .{err},
        ) catch "connection error";
        return;
    };
    stream.close();

    const elapsed: u64 = @intCast(std.time.milliTimestamp() - start);
    args.result.reachable = true;
    args.result.latency_ms = elapsed;
}

// --- Main ---

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // Read config file path from args
    const args_list = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args_list);

    if (args_list.len < 2) {
        std.debug.print("Usage: healthcheck <config.json>\n", .{});
        std.process.exit(1);
    }

    // Read and parse config
    const config_path = args_list[1];
    const config_data = try std.fs.cwd().readFileAlloc(allocator, config_path, 1024 * 1024);
    defer allocator.free(config_data);

    const parsed_config = try json.parseFromSlice(Config, allocator, config_data, .{
        .ignore_unknown_fields = true,
    });
    defer parsed_config.deinit();

    const config = parsed_config.value;

    // Prepare results (one per target)
    const results = try allocator.alloc(HealthResult, config.targets.len);
    defer allocator.free(results);

    // Arena for per-check allocations (error strings, etc.)
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();

    // Spawn one thread per target
    const threads = try allocator.alloc(Thread, config.targets.len);
    defer allocator.free(threads);

    for (config.targets, results, threads) |target, *result, *thread| {
        result.* = HealthResult{
            .host = target.host,
            .port = target.port,
            .reachable = false,
            .latency_ms = null,
            .error_msg = null,
        };
        const check_args = CheckArgs{
            .target = target,
            .result = result,
            .arena = arena.allocator(),
        };
        thread.* = try Thread.spawn(.{}, checkHost, .{check_args});
    }

    // Wait for all threads
    for (threads) |t| t.join();

    // Report results
    const stdout = std.io.getStdOut().writer();
    var ok_count: usize = 0;
    for (results) |result| {
        if (result.reachable) {
            ok_count += 1;
            try stdout.print("[OK]   {s}:{d}  {d}ms\n", .{
                result.host, result.port, result.latency_ms.?,
            });
        } else {
            try stdout.print("[FAIL] {s}:{d}  {s}\n", .{
                result.host,
                result.port,
                result.error_msg orelse "unknown error",
            });
        }
    }

    try stdout.print("\n{d}/{d} targets reachable\n", .{ ok_count, results.len });
    if (ok_count < results.len) std.process.exit(1);
}

With a config file:

1
2
3
4
5
6
7
8
{
  "targets": [
    { "host": "1.1.1.1", "port": 53 },
    { "host": "8.8.8.8", "port": 53 },
    { "host": "192.168.1.1", "port": 80 },
    { "host": "127.0.0.1", "port": 5432 }
  ]
}
1
2
3
4
5
6
zig build-exe healthcheck.zig -OReleaseFast
./healthcheck config.json

# Cross-compile and run on a Raspberry Pi
zig build-exe healthcheck.zig -target aarch64-linux-musl -OReleaseFast
scp healthcheck pi@raspberrypi:/usr/local/bin/

Getting Started

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Install Zig
# Option 1: Official downloads at https://ziglang.org/download/
# Option 2: zigup — Zig version manager
curl https://ziglang.org/download/0.13.0/zig-linux-x86_64-0.13.0.tar.xz | tar xJ
sudo mv zig-linux-x86_64-0.13.0 /opt/zig
export PATH=/opt/zig:$PATH

# Using zigup (recommended for managing multiple versions)
curl -L https://github.com/marler8997/zigup/releases/latest/download/zigup-linux-x86_64 -o zigup
chmod +x zigup && sudo mv zigup /usr/local/bin/
zigup 0.13.0

# Verify
zig version

# Create a new project
mkdir myproject && cd myproject
zig init  # Creates build.zig, src/main.zig, src/root.zig

# Build and run
zig build run

# Run tests
zig build test

# Fetch Ziglings exercises
git clone https://codeberg.org/ziglings/exercises ziglings
cd ziglings && zig build

# Install ZLS (language server)
# Match version to your zig version — check https://github.com/zigtools/zls/releases

Key Resources


Conclusion

Zig is not a safe language in the Rust sense. It will not prevent you from writing use-after-free bugs if you try hard enough. What it does is make the unsafe operations visible, make the safe operations the default, and eliminate entire classes of bugs — null dereferences, integer overflow UB, buffer overflows in debug builds — that are silent and catastrophic in C.

The allocator system is not just a design pattern; it is an architectural statement. When every allocation is explicit and caller-controlled, you can reason about your program’s memory behavior without reading every function body. You can inject test allocators that detect leaks. You can swap general-purpose allocators for arenas in hot paths without touching function signatures.

Comptime is not just generics. It is a principled replacement for the C preprocessor, C++ templates, and separate metaprogramming DSLs. When your code generation is written in the same language as your runtime code, with the same debugger and the same semantics, the barrier between “library” and “framework” collapses in useful ways.

The zig cc and zig build story is immediately valuable even if you never write Zig. Cross-compiling a C project to five targets from one CI machine, with no external toolchain setup, with a reproducible hermetic build — that is a concrete operational win available today.

Zig is pre-1.0. The standard library will change. Async is not ready. The ecosystem is small. These are real costs. But the language design decisions are sound, the team is focused, funding is real (the Zig Software Foundation has significant backing), and the projects built on it — Bun, TigerBeetle — are production proof of concept.

For systems programmers who find Rust’s borrow checker more hindrance than help, or who want the transparency of C with better defined semantics, Zig is worth a serious evaluation. The question is not whether Zig is a good language. It is. The question is whether it is ready for your particular project, at your particular risk tolerance, right now. For many teams, the answer is yes.

Comments