Learn Zig
comptime, explicit allocators, defer/errdefer, and no hidden control flow or allocations.
Setup and the Zig Toolchain
Install Zig, run your first program, and meet the build system.
Setup and the Zig Toolchain
Zig is a general-purpose systems language created by Andrew Kelley, first released in 2016. It aims to be a better C: a small, explicit language with no hidden control flow, no hidden memory allocations, no preprocessor, and no macros - while shipping a single static binary that is also a drop-in C/C++ compiler. As of mid-2026 Zig is still pre-1.0 (the 0.14.x / 0.15.x series), so the language and standard library do change between releases; always check the docs for your exact version. This lesson gets you from zero to a running program.
Installing Zig
Download a prebuilt tarball from ziglang.org, or use a package manager. The download is just the zig binary plus a lib/ directory - there is no separate runtime to install. Confirm it works:
$ zig version
0.14.0
The zig binary is the entire toolchain: compiler, build system, test runner, formatter, package manager, and even a C/C++ compiler (zig cc). There is nothing else to download.
Your first program
Create a file named hello.zig:
const std = @import("std");
pub fn main() void {
std.debug.print("Hello, Zig!\n", .{});
}
A few things to notice already. @import is a builtin function (everything starting with @ is a compiler builtin), and it returns the standard library as an ordinary value bound to a const. main must be pub (public) so the runtime can find it. std.debug.print takes a format string and a tuple of arguments - here the empty tuple literal .{} because there are no arguments to interpolate.
Run it directly:
$ zig run hello.zig
Hello, Zig!
zig run compiles to a temporary binary and executes it. To produce a binary you ship, use zig build-exe:
$ zig build-exe hello.zig
$ ./hello
Hello, Zig!
Cross-compilation is first-class
Because Zig bundles libc headers and its own backends, cross-compiling needs no extra toolchain - just a -target triple:
$ zig build-exe hello.zig -target aarch64-linux-gnu
$ zig build-exe hello.zig -target x86_64-windows-gnu
This same machinery is why zig cc is a popular C cross-compiler even in non-Zig projects.
The build system: build.zig
Real projects use a build.zig file - a normal Zig program that describes the build graph. zig init scaffolds one:
$ zig init
A minimal build.zig looks like this:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "hello",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
b.installArtifact(exe);
}
Then zig build compiles, zig build run builds and runs, and zig build test runs your tests. The build script is just Zig, so you have a real programming language - not a bespoke DSL - to express your build.
Optimization modes and formatting
Zig has four build modes you choose explicitly. They trade off safety, speed, and size:
| Mode | Runtime safety checks | Optimizations |
|---|---|---|
Debug (default) |
on | none |
ReleaseSafe |
on | yes |
ReleaseFast |
off | yes (max speed) |
ReleaseSmall |
off | yes (min size) |
In Debug and ReleaseSafe, integer overflow, out-of-bounds indexing, and many other mistakes trap at runtime instead of silently corrupting memory. Pick the mode with -O ReleaseFast or via standardOptimizeOption in the build script.
Finally, zig fmt reformats source to the canonical style, just like gofmt:
$ zig fmt src/
Reference
- Official getting-started guide: https://ziglang.org/learn/getting-started/
- Zig language reference: https://ziglang.org/documentation/master/
With the toolchain working you are ready to learn the language, starting with its types and control flow.
Syntax, Variables, and Types
const/var, integers, optionals, arrays, slices, and structs.
Syntax, Variables, and Types
Zig is statically typed with a small, explicit grammar. There is no type inference across function boundaries, no operator overloading, and no hidden conversions. This lesson covers declarations, the numeric types, optionals, arrays and slices, structs, and control flow.
const and var
Bindings are declared with const (immutable) or var (mutable). The type can often be inferred from the initializer, or written explicitly after a colon:
const name = "Ada"; // inferred: []const u8
const count: u32 = 0; // explicit type
var total: i64 = 0; // mutable
total += 10;
Zig is strict: an unused local variable or unused const is a compile error, not a warning. Prefer const and only reach for var when you actually mutate - the compiler will tell you if a var could have been const.
Integers and explicit width
Zig has sized integers (i8/i16/i32/i64/i128, and u8...u128) plus arbitrary-bit-width integers like u7 or i39. usize/isize are pointer-sized. There are no implicit numeric conversions that lose information - you convert with @intCast, @as, @floatFromInt, etc.:
const a: u8 = 200;
const b: u16 = a; // OK: widening is implicit and lossless
const c: u8 = @intCast(b); // narrowing needs an explicit cast
const f: f32 = @floatFromInt(a);
Overflow is not silent. In safe build modes a + b traps if it overflows; if you want wrapping you must say so with the wrapping operators +%, -%, *%:
const x: u8 = 255;
const y = x +% 1; // wraps to 0; plain `x + 1` would trap in Debug/ReleaseSafe
Optionals replace null
There is no implicit null. A value that might be absent has an optional type, written ?T. You unwrap it with if, orelse, or .?:
var maybe: ?i32 = null;
maybe = 42;
if (maybe) |value| {
std.debug.print("got {d}\n", .{value});
}
const n = maybe orelse 0; // default if null
const m = maybe.?; // assert non-null (traps in safe modes if null)
This means a plain *T pointer can never be null - nullability lives in the type system as ?*T.
Arrays and slices
An array has a compile-time length baked into its type; [3]i32 and [4]i32 are different types. A slice is a fat pointer - a pointer plus a runtime length - written []T:
var arr = [_]i32{ 1, 2, 3, 4, 5 }; // [_] infers length 5
const sl: []i32 = arr[1..3]; // slice covering {2, 3}
std.debug.print("len={d}\n", .{sl.len}); // len=2
Slices carry their length, so indexing is bounds-checked in safe modes. Strings are just []const u8 - byte slices of UTF-8 data; there is no dedicated string type.
Structs
Structs aggregate fields and can hold methods:
const Point = struct {
x: i32,
y: i32,
pub fn manhattan(self: Point) i32 {
return @abs(self.x) + @abs(self.y);
}
};
const p = Point{ .x = 3, .y = -4 };
std.debug.print("{d}\n", .{p.manhattan()}); // 7
Field initializers use the .field = value syntax. Structs are value types: assignment copies all fields.
Control flow
if, while, for, and switch cover everything, and most can also be expressions that produce a value:
const grade = if (score >= 90) "A" else if (score >= 80) "B" else "C";
var i: usize = 0;
while (i < 3) : (i += 1) { // the `: (expr)` is the continue expression
std.debug.print("{d}\n", .{i});
}
const nums = [_]i32{ 10, 20, 30 };
for (nums, 0..) |value, index| { // iterate with an optional index range
std.debug.print("{d}: {d}\n", .{ index, value });
}
switch must be exhaustive - every possible value (or an else branch) must be covered, and there is no fall-through. It is also an expression:
const label = switch (n) {
0 => "zero",
1, 2, 3 => "small",
4...10 => "medium", // inclusive range
else => "large",
};
Reference
- Language reference (values, types): https://ziglang.org/documentation/master/#Values
- Zig Guide (community tutorial): https://zig.guide/
Next we look at how Zig reports failures: error unions and the try operator.
Errors, try, defer, and errdefer
Error unions, the try operator, and deterministic cleanup.
Errors, try, defer, and errdefer
Zig has no exceptions and no hidden control flow. Failures are ordinary values carried in an error union, and cleanup is scheduled with defer/errdefer. Because errors are values and defer runs deterministically, you can always see - in the source - exactly when code runs and when memory is released. This pairing is central to Zig's memory model, which the next lesson explores in depth.
Error sets and error unions
An error set is like an enum of error names. An error union E!T is either an error from set E or a value of type T:
const FileError = error{
NotFound,
PermissionDenied,
};
fn open(path: []const u8) FileError!std.fs.File {
if (path.len == 0) return error.NotFound;
// ...
}
You can also let Zig infer the error set by writing !T, and it will union together every error the function can return.
try and catch
To call a fallible function you must handle the error - the compiler will not let you ignore it. The try keyword unwraps the value or returns the error to the caller:
fn loadConfig() !void {
const file = try open("config.zig"); // on error, return it from loadConfig
_ = file;
}
try expr is exactly expr catch |err| return err. When you want to handle the error instead of propagating it, use catch:
const file = open(path) catch |err| switch (err) {
error.NotFound => return, // give up quietly
error.PermissionDenied => unreachable,
};
const size = computeSize() catch 0; // supply a default on any error
Because try and catch are visible keywords, every place a function can fail is marked in the source. There is no invisible stack unwinding.
defer: deterministic cleanup
defer schedules an expression to run when the current block is exited, no matter how it is exited (normal return, try propagation, break). Deferred statements run in last-in, first-out order:
fn process(allocator: std.mem.Allocator) !void {
const buf = try allocator.alloc(u8, 1024);
defer allocator.free(buf); // runs on every exit path from here on
const more = try allocator.alloc(u8, 64);
defer allocator.free(more); // runs first (LIFO), before freeing buf
try doWork(buf, more); // if this fails, both defers still run
}
This is the idiom that makes manual memory management tractable: you free right next to where you alloc, and defer guarantees it happens regardless of the control flow that follows.
errdefer: cleanup only on failure
Sometimes cleanup should happen only if the function fails partway through - because on success you are handing ownership of the resource to the caller. That is what errdefer is for: it runs only when the block is exited via an error.
fn makeWidget(allocator: std.mem.Allocator) !*Widget {
const widget = try allocator.create(Widget);
errdefer allocator.destroy(widget); // freed only if a later step fails
widget.buffer = try allocator.alloc(u8, 256);
errdefer allocator.free(widget.buffer);
try widget.init(); // if this errors, both errdefers fire and we clean up
return widget; // success: errdefers do NOT run; caller now owns it
}
If init() fails, both errdefers run and we return no leaked memory. If everything succeeds, neither runs and the caller becomes responsible for freeing the widget. This precise distinction between "always clean up" (defer) and "clean up only on the failure path" (errdefer) is what lets Zig do safe manual memory management without a garbage collector.
No hidden control flow
Putting it together: a Zig function's failure paths are spelled out with try/catch, and its cleanup is spelled out with defer/errdefer. Nothing runs that you cannot see at the call site. This is a deliberate contrast with exceptions (which unwind invisibly) and destructors (which run implicitly) - in Zig, what you read is what executes.
Reference
- Errors in the language reference: https://ziglang.org/documentation/master/#Errors
- defer and errdefer: https://ziglang.org/documentation/master/#defer
Next: the heart of the language - how Zig manages memory through explicit allocators.
The Memory Model: Explicit Allocators
No hidden allocations - every heap allocation goes through an Allocator you pass in.
The Memory Model: Explicit Allocators
This is the lesson that defines Zig. Zig has no garbage collector and no hidden heap allocations. Nothing in the language secretly allocates memory - not arrays, not slices, not closures (Zig has none). Whenever code needs heap memory, it asks for it through an Allocator value that the caller supplies. This single design choice - threading an explicit allocator through your program - is what makes Zig's memory behavior auditable.
The std.mem.Allocator interface
std.mem.Allocator is an interface (a struct holding a vtable). You never malloc directly; you call methods on an allocator value:
const std = @import("std");
fn build(allocator: std.mem.Allocator) !void {
// alloc N items of a type -> returns a slice []T
const numbers = try allocator.alloc(i32, 100);
defer allocator.free(numbers);
// create a single item -> returns a pointer *T
const node = try allocator.create(Node);
defer allocator.destroy(node);
numbers[0] = 42;
node.value = numbers[0];
}
The four core operations are alloc/free (for slices) and create/destroy (for single items). There is also realloc for resizing. Each returns an error union, because allocation can fail - and in Zig running out of memory is just error.OutOfMemory, an ordinary value you handle with try.
Allocators are passed in, not global
The convention is that any function or data structure that allocates takes an allocator parameter. The standard library follows this everywhere - for example, an ArrayList is given an allocator when you use it:
var list = std.ArrayList(i32).init(allocator);
defer list.deinit();
try list.append(1);
try list.append(2);
try list.append(3);
std.debug.print("{any}\n", .{list.items}); // { 1, 2, 3 }
Because the allocator is explicit, you decide the allocation strategy at the top of your program and it flows down. A library cannot allocate behind your back, and tests can swap in a different allocator.
Choosing an allocator
The standard library ships several allocators, each suited to a different situation:
- GeneralPurposeAllocator (
std.heap.GeneralPurposeAllocator): a safe general allocator that, in Debug, detects leaks, double-frees, and use-after-free. The default choice while developing. - page_allocator (
std.heap.page_allocator): asks the OS for whole pages; simple but coarse. - ArenaAllocator (
std.heap.ArenaAllocator): allocate freely, then free everything at once by deinit-ing the arena. Perfect for request-scoped or phase-scoped work. - FixedBufferAllocator (
std.heap.FixedBufferAllocator): hands out memory from a fixed stack or static buffer you provide - zero heap, useful in embedded code. - c_allocator (
std.heap.c_allocator): wraps libcmalloc/freewhen linking libc.
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit(); // reports leaks at program exit
const allocator = gpa.allocator();
const data = try allocator.alloc(u8, 4096);
defer allocator.free(data);
// ... use data ...
}
The arena pattern
Arenas turn many tiny frees into one big free, which both simplifies code and speeds it up. You allocate as much as you want and never free individual items:
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit(); // frees everything below in one shot
const a = arena.allocator();
const x = try a.alloc(u8, 100);
const y = try a.alloc(u8, 200);
_ = x; _ = y; // no per-item free needed
Ownership is a convention you enforce
Zig does not track ownership for you the way Rust's borrow checker does. Instead, ownership is a discipline expressed through where you put defer allocator.free(...) and errdefer. The rules of thumb:
- Whoever allocates is responsible for freeing - unless they explicitly transfer ownership by returning the memory.
- Pair every
allocwith adefer freein the same scope when the memory is local. - Use
errdeferwhen a function allocates, then might fail, then would otherwise hand the memory to the caller. - The leak-detecting
GeneralPurposeAllocatoris your safety net during development: if you forget a free, it tells you exactly where the leaked allocation came from.
Why this matters
Explicit allocators give you C-level control with far better ergonomics than C's bare malloc. You can reason about where every byte comes from, swap strategies without touching call sites, and catch leaks automatically - all without a garbage collector pausing your program. This is the distinctive promise of Zig's memory model: no hidden allocations, ever.
Reference
- Choosing an Allocator (official docs): https://ziglang.org/documentation/master/#Choosing-an-Allocator
- std.mem.Allocator API: https://ziglang.org/documentation/master/std/#std.mem.Allocator
Next we compare Zig's model against the other six systems languages so the choice is concrete.
comptime: Compile-Time Code Execution
Run Zig at compile time for generics, reflection, and constants - no macros.
comptime: Compile-Time Code Execution
Zig's distinctive feature is comptime: the ability to run ordinary Zig code at compile time, with the same language and the same semantics. There is no separate macro language, no template meta-language, and no preprocessor. Generics, reflection, and compile-time constants are all just comptime Zig. This is how Zig keeps the language tiny while still being expressive.
Values known at compile time
Many things are already comptime-known: types, the lengths of arrays, the fields of a struct. You can force a value to be computed at compile time with the comptime keyword:
comptime {
// this block runs during compilation
const x = 3 + 4;
if (x != 7) @compileError("math is broken");
}
const table = comptime blk: {
var t: [5]u32 = undefined;
for (&t, 0..) |*slot, i| slot.* = @intCast(i * i);
break :blk t; // {0, 1, 4, 9, 16}, baked into the binary
};
The squares table is computed by the compiler and stored as a constant - no runtime cost.
Types are values
The key insight is that a type is itself a value of type type, usable only at compile time. That means a function can take a type as a parameter and return a new type - which is exactly how generics work:
fn Stack(comptime T: type) type {
return struct {
items: []T,
len: usize,
const Self = @This();
pub fn top(self: Self) T {
return self.items[self.len - 1];
}
};
}
// Stack(i32) and Stack([]const u8) are distinct types generated at compile time
const IntStack = Stack(i32);
std.ArrayList(T), std.AutoHashMap(K, V), and most generic containers in the standard library are written exactly this way - they are functions that take types and return types. There is no special generics syntax to learn; it is just comptime parameters.
comptime function parameters
A parameter marked comptime must be known at compile time, which lets the function specialize on it. This generates efficient, monomorphized code:
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
const m = max(i32, 3, 7); // 7, specialized for i32
const d = max(f64, 1.5, 0.5); // 1.5, specialized for f64
Reflection with @typeInfo
Because types are values, you can inspect them at compile time with builtins like @typeInfo, @field, and @hasDecl. This is compile-time reflection - safe and type-checked, unlike textual macros:
fn printFields(comptime T: type, value: T) void {
inline for (std.meta.fields(T)) |field| {
std.debug.print("{s} = {any}\n", .{ field.name, @field(value, field.name) });
}
}
inline for unrolls the loop at compile time so each field's correct type is known in each iteration. The standard library uses this style to implement formatting, JSON parsing, and serialization generically without any runtime type tags.
Why comptime instead of macros
C uses a textual preprocessor; C++ uses templates plus constexpr; many languages bolt on a macro system. Zig replaces all of them with one idea - run the real language at compile time. The benefits:
- One language to learn: comptime code is regular Zig, type-checked and debuggable.
- No textual hazards: there is nothing like C macro hygiene bugs.
- Powerful: generics, reflection, lookup tables, and conditional compilation all fall out of the same mechanism.
Combined with explicit allocators and defer/errdefer, comptime rounds out Zig's philosophy: a small language where the compiler does a lot of work, but never anything you cannot see.
Reference
- comptime in the language reference: https://ziglang.org/documentation/master/#comptime
- Introduction to comptime (zig.guide): https://zig.guide/language-basics/comptime/
Finally, we place Zig's memory model alongside the other six systems languages.
Zig vs the Other Systems Languages
How Zig's explicit-allocator model compares to C, C++, HolyC, Hare, Odin, and Forth.
Zig vs the Other Systems Languages
All seven of these languages are unmanaged - none has a tracing garbage collector running by default. But each makes different choices about how you obtain and release memory. Seeing them side by side sharpens what is distinctive about Zig: every heap allocation flows through an explicit Allocator value, and defer/errdefer make release deterministic and visible.
C: manual malloc / free
C is the baseline. You call malloc to get raw bytes and free to release them, with no help from the language. There is no destructor, no defer (until C23-era cleanup attributes, which are non-portable), so cleanup on every error path is hand-written and easy to forget:
#include <stdlib.h>
int *buf = malloc(100 * sizeof(int));
if (!buf) return -1; // must check for NULL yourself
/* ... use buf ... */
free(buf); // forget this and you leak
Allocation failure is a NULL return you must remember to check; the allocator is the single global malloc.
C++: RAII, smart pointers, and manual
C++ keeps C's new/delete but adds RAII: an object's destructor runs automatically when it goes out of scope, so smart pointers free memory for you. std::unique_ptr owns a single object; std::shared_ptr reference-counts:
#include <memory>
#include <vector>
auto p = std::make_unique<int[]>(100); // freed automatically at scope end
std::vector<int> v; // manages its own buffer via RAII
v.push_back(42);
Cleanup is implicit (destructors run invisibly) - the opposite of Zig, where defer makes cleanup explicit and visible. C++ trades that visibility for convenience.
HolyC: MAlloc / Free (TempleOS)
HolyC, Terry Davis's C dialect for TempleOS, is C-like with its own runtime. Heap memory comes from MAlloc and is released with Free. Allocations are tied to a task's heap by default, so when a task dies its memory is reclaimed - a coarse, task-scoped lifetime on top of manual alloc/free. (We tag these snippets c since HolyC is a C dialect.)
U8 *buf = MAlloc(100); // allocate 100 bytes on the task heap
// ... use buf ...
Free(buf); // release it; or let the task's death free it
Like C, there is no defer and no ownership tracking; unlike C, the per-task heap gives a fallback cleanup when a program exits.
Hare: manual alloc / free, with defer
Hare is a small C-adjacent language. It uses keyword-style alloc and free built into the language, and - like Zig - it has defer for scheduling cleanup on scope exit. But Hare's allocator is the implicit global one; you do not pass an allocator value around the way you do in Zig:
let buf: []int = alloc([0...], 100); // allocate a slice of 100 ints
defer free(buf); // released when the scope exits
buf[0] = 42;
So Hare shares Zig's defer discipline but not Zig's pluggable, explicitly-passed allocators.
Odin: context allocator, with defer
Odin also has defer, but its big idea is the implicit context - an ambient struct carried into every call that holds, among other things, an allocator. By default new/free and make/delete use context.allocator, and you switch strategies by overriding the context for a block:
ptr := new(int) // uses context.allocator implicitly
defer free(ptr)
arena: mem.Arena
context.allocator = mem.arena_allocator(&arena)
data := make([]u8, 1024) // now allocated from the arena
defer delete(data)
Odin's allocator is swappable like Zig's, but it is passed implicitly through context rather than as an explicit parameter. Zig makes the allocator a visible argument; Odin makes it ambient. Both reject hidden GC.
Forth: raw memory with HERE / ALLOT / @ / !
Forth is the most bare-metal of the set. The classic model has a contiguous dictionary with a pointer called HERE. ALLOT advances HERE to reserve space; @ ("fetch") reads a cell and ! ("store") writes one. There is no malloc, no free, and no types - just addresses and cells:
VARIABLE counter \ reserve one cell, name it counter
42 counter ! \ store 42 into it ( ! = store )
counter @ . \ fetch and print -> 42 ( @ = fetch )
HERE 100 ALLOT \ reserve 100 bytes starting at HERE; HERE is the address
Memory is a flat array you manage by hand with raw pointers; releasing usually means winding HERE back. This is the rawest possible model - the very thing Zig's allocators abstract over while still keeping every allocation explicit.
The big picture
| Language | How you allocate | Pluggable allocator? | Deterministic cleanup |
|---|---|---|---|
| C | malloc / free |
no (global) | hand-written |
| C++ | new/delete, smart pointers |
custom allocators possible | RAII (implicit) |
| HolyC | MAlloc / Free |
per-task heap | manual + task death |
| Zig | allocator.alloc/free |
yes, explicit param | defer / errdefer |
| Hare | alloc / free |
no (global) | defer |
| Odin | new/make, free/delete |
yes, via context |
defer |
| Forth | HERE / ALLOT, @ / ! |
no (raw dictionary) | manual (HERE rewind) |
Zig's signature is the combination in that row: allocation is always an explicit value you pass, and cleanup is always a visible defer/errdefer. No GC, no hidden allocations, no invisible destructors - exactly the promise in this track's blurb. With that mental model, plus comptime for zero-cost generics, you can write systems code that is both low-level and auditable.
Reference
- Zig: Choosing an Allocator: https://ziglang.org/documentation/master/#Choosing-an-Allocator
- Andrew Kelley, "The Road to Zig 1.0" talk and ziglang.org for the broader philosophy: https://ziglang.org/
You now know Zig from setup through its defining memory model. Build something small that allocates, and watch the leak detector keep you honest.