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:
- The unsafe operations are more explicit (e.g., pointer casts use
@ptrCast which is visible in code review)
- Many bugs that are silent in C become runtime panics in Zig’s safe build modes
- 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
}
|
@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.
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:
- The performance requirements (competitive with or faster than Node.js) require manual memory management
- The cross-compilation story makes building release binaries for Linux, macOS, and Windows straightforward
- C/C++ interop with JavaScriptCore (which is C++) is zero-overhead
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