zig.cheatsheet
Zig v0.16.021sections120entries127code samples52tables

The Zig Cheatsheet

A dense, one-page reference for the Zig programming language — syntax, stdlib, allocators, comptime and more, in the spirit of cheats.rs, with examples aligned to ziglings. Press ⌘K to search.

Start reading

Section 1 of 21: Getting Started

6 entries

Install Zig, write your first program, master the CLI.

#001

Install & Verify

One static binary, no package manager, nothing else to configure.

Grab a build for your platform from ziglang.org/download. Zig ships as a single ~45 MB static binary — no runtime, no VM, no dependencies. Unpack it, put it on your PATH, done.

Verify the install
zig version
# 0.16.0
  • Also available via brew install zig / apt install zig — but package-manager versions often lag behind the latest release.
  • Upgrading is just replacing the folder; projects live in your own directories, not in the toolchain.
#002

Hello, World! (0.16)

since 0.16ziglings 001–002

The canonical 0.16 main signature comes with an allocator, I/O handle and CLI args.

hello.zig
const std = @import("std");

pub fn main(init: std.process.Init) !void {
    try std.Io.File.stdout().writeStreamingAll(init.io, "Hello, world!\n");
}

The 0.16 Juicy Main signature: init bundles init.gpa, init.io, init.arena plus CLI args and environment.

  • std.debug.print("Hello!\n", .{}) writes to stderr and works with any main signature — ideal for quick prints.
  • An empty pub fn main() !void is still legal — but then you get no access to CLI args or environment.
  • std.process.Init.Minimal provides only args + environ, skipping the rest of the I/O setup.
Compile and run in one step
zig run hello.zig
#003

Zig CLI

One tool compiles, tests, formats, cross-compiles — and even builds C code.

CommandWhat it does
zig run file.zigCompile and run immediately
zig build-exe / -lib / -objOne-shot compile to executable, library or object file
zig test file.zigBuild and run every test block
zig fmt .Canonical formatter — no config, no debates
zig buildProject build system driven by build.zig
zig initScaffold a new project (build.zig + src/main.zig)
zig fetch <url>Fetch and cache a package dependency
zig cc / zig c++Drop-in C/C++ compiler with free cross-compilation
zig translate-c file.cConvert C source to Zig
zig env / zig targetsShow cache/lib paths / list supported compile targets
FlagEffect
-O Debug|ReleaseSafe|ReleaseFast|ReleaseSmallChoose the build mode (see below)
-target x86_64-linux-muslCross-compile for any arch-os-abi
-mcpu <name>Select target CPU and feature set
Cross-compile a static binary — no extra toolchain
zig build-exe main.zig -O ReleaseFast -target x86_64-linux-musl
./main
#004

Build Modes

Four modes trade compile time, runtime speed, size and safety checks.

ModeCompile timeRuntimeSafety checksNotes
DebugfastestslowestallDefault for zig run, zig test
ReleaseSafesloweroptimizedallPanics on overflow, out-of-bounds, …
ReleaseFastslowerfastestoffViolations become undefined behaviour
ReleaseSmallslowersmall, less optimizedmostly offOptimizes for binary size
ReleaseFast removes the guard rails:In ReleaseFast, integer overflow or an out-of-bounds access is undefined behaviour — it may silently corrupt memory instead of panicking. Ship ReleaseSafe unless you truly need the last few percent of performance.
Typical workflow
zig run app.zig                     # iterate in Debug
zig test app.zig                    # tests in Debug
zig build-exe app.zig -O ReleaseSafe  # ship with safety checks
zig build -Doptimize=ReleaseFast      # via build.zig
#005

Comments & Docs

Line comments only — plus two flavours of doc comments for autodoc.

zig
//! Top-level doc comment: describes the whole FILE.

/// Doc comment for the declaration that follows.
/// Rendered by zig build-docs / autodoc.
pub fn add(a: i32, b: i32) i32 {
    return a + b; // plain line comment
}

// There are no /* block comments */ in Zig.
  • // — the only comment. There are no block comments.
  • /// — doc comment; attaches to the declaration below it and shows up in generated docs.
  • //! — file-level doc comment; must appear at the very top of the file.
#006

File Anatomy & Imports

Every file is a namespace; @import wires them together; pub controls visibility.

  • @import("std") resolves to the standard library; @import("math.zig") loads a file relative to the current file.
  • pub makes a declaration visible to importers — everything else stays private to the file.
  • Declaration order is irrelevant: top-level decls are lazily analyzed, so forward references just work.
  • Every file is implicitly a struct / namespace; @This() names it from inside.
  • The entry point lives in the root source file — main.zig when you zig run main.zig, or the root module declared in build.zig.
main.zig
const std = @import("std");
const math = @import("math.zig");

pub fn main() !void {
    const sum = math.add(2, 3);
    std.debug.print("{d}\n", .{sum});
}
math.zig
const magic = 42; // private to this file

pub fn add(a: i32, b: i32) i32 {
    return a + b;
}

// Order never matters:
pub const two = add(1, 1);

Non-pub decls like magic are invisible to importers — the file boundary is a hard visibility wall.

Section 2 of 21: Language Basics

6 entries

const vs var, statements vs expressions, labeled blocks, undefined and the comptime mindset.

#007

Variables: const & var

ziglings 003, 051

Two keywords, strict usage rules, and compile errors that keep code honest.

const is immutable, var is mutable. Types are usually inferred; write them when it matters: const x: u32 = 1;.

zig
const answer: u32 = 42;      // immutable, explicit type
var counter = answer;        // mutable, type inferred (u32)
counter += 1;                // ok: vars may be reassigned

fn sum(items: []const u32) u32 {
    var total: u32 = 0;
    for (items) |item| total += item;
    return total;
}
FormMeaning
const x = v;Immutable binding
var x = v;Mutable binding
_ = x;Explicitly discard — silences the unused error
_ = &x;Take and discard the address — also counts as a use
Compile errors you will hit on day one:Unused local variables and parameters are errors — discard them with _ = x;. A var that is never mutated is an error. So is shadowing an identifier from an outer scope.
#008

Statements ≠ Expressions

Assignment is a void statement; control flow yields values instead.

Zig separates the two strictly: assignment returns void, so x = y = 0 is impossible. There is no ++ / -- (use x += 1) and no ternary — because if is an expression.

zig
const a = 3;
const b = 7;

// const c = a = b;            // ERROR: assignment yields void
// a++;                        // ERROR: no ++ — use a += 1
// const max = a > b ? a : b;  // ERROR: no ternary operator

const max = if (a > b) a else b;         // 7
const ok = (a < b) and (b != 0);         // and / or / ! — never && || !

const both = blk: {                      // blocks are expressions too
    const squared = a * a;
    break :blk squared + b;              // yields 16
};
#009

Blocks & Scope

Labeled blocks are inline scopes that can yield a value.

Declarations live inside their block's scope. Any block can be given a label; break :label value; exits it with a result — Zig's tool for computed fallback chains and early exits from a scope.

zig
const n: i32 = -4;

const magnitude = abs: {
    if (n >= 0) break :abs n;   // leave early with a value
    break :abs -n;
};                              // magnitude == 4

// Reads like an inline function:
const squares = first_three: {
    var buf: [3]u32 = undefined;
    for (0..3) |i| buf[i] = @intCast((i + 1) * (i + 1));
    break :first_three buf;
};

Labeled blocks replace many nested-if and switch-fallback patterns — and pair naturally with errdefer-style control flow later on.

#010

undefined

ziglings 050

Opt out of initialization — the 0xAA fill and the compiler both have opinions.

var x: u32 = undefined; means no initialization. In Debug and ReleaseSafe the bytes are filled with 0xAA so stale reads are obvious; in the other modes they are garbage. Useful for deferred init and buffers you will fully overwrite.

zig
var buf: [64]u8 = undefined;   // uninit 64 bytes
buf[0] = 'H';
buf[1] = 'i';

var total: u32 = undefined;    // deferred init
total = 10;
total += 5;                    // safe: written before any read
undefined is not a value:Reading a location that was never written is undefined behaviour — the 0xAA fill is a debugging aid, not a check. undefined may only initialize a var; a const needs a real value.
#011

The comptime Keyword

Anything can run at compile time — that is how Zig does generics.

Zig has no macros and no template engine: the language itself is the metaprogramming layer. comptime marks parameters, variables or blocks that must be known at compile time — and types themselves are values of type type.

zig
fn Buffer(comptime T: type, comptime size: usize) type {
    return struct {
        items: [size]T,
        len: usize = 0,
    };
}

const Buf8 = Buffer(u8, 8);     // a brand-new type...
const Buf32 = Buffer(u8, 32);   // ...generated at compile time

const n = 40 + 2;               // comptime_int: arbitrary-precision math
  • comptime_int grows arbitrarily — integer literals have no fixed width until assigned to a typed const / var.
  • Every instantiation compiles fresh code, including whatever parts of std it touches — that is why the whole std library feels like it is compiled per-use.
  • This is Zig's answer to C macros and C++ templates: ordinary code, evaluated at compile time.
#012

Identifiers & Literals

ziglings 079

snake_case, raw identifiers for keywords, and every numeric literal prefix.

zig
const max_value = 1_000_000;     // snake_case — zig fmt enforces the style
const flags = 0b1010;            // binary
const perms = 0o755;             // octal
const mask = 0xFF;               // hex
const big = 1_000.5;             // _ works in floats too

const letter = 'a';              // char literal (comptime_int)
const newline = '\n';
const emoji: u21 = '\u{1F600}'; // unicode code point

const @"if" = 1;                 // raw identifier for keywords...
const @"weird name" = 2;         // ...or any unusual spelling
LiteralKind
123, 1_000_000Decimal integer, _ as separator
0b1010, 0o755, 0xFFBinary / octal / hex integer
1_000.5, 1e10Float
'a', '\n', '\u{1F600}'Character — a comptime_int
@"if"Raw identifier — escapes keywords and spaces

Section 3 of 21: Primitive Types

6 entries

Integers of any width, IEEE floats, bool / void / noreturn — and exactly which values coerce.

#013

Integers

ziglings 059

Any bit width you want, plus the special arbitrary-precision comptime_int.

TypeNotes
u8 i8 u16 i16 u32 i32 u64 i64 u128 i128The fixed-width family
usize / isizePointer-sized; required for indexing
u7, i47, u65535Arbitrary widths — any uN / iN with N ≤ 65535
u0Zero-bit type; its only value is 0
comptime_intArbitrary precision — the default type of integer literals
zig
const a: u8 = 255;             // fits exactly
const b = 300;                 // comptime_int — no width yet
const c: u16 = b;              // ok: 300 fits u16
// const d: u8 = b;            // compile error: 300 does not fit u8
const e: u9 = 511;             // odd widths are fine
const byte = 'A';              // char literal coerces into u8 and friends
#014

Overflow Behavior

Panics, UB or wraps — plus wrapping and saturating operator variants.

Build modeOn overflow
Debug / ReleaseSafePanic (safety-checked)
ReleaseFastUndefined behaviour
ReleaseSmallWraps (well-defined)
ToolMeaning
+% -% *%Wrapping add / sub / mul
+| -| *|Saturating add / sub / mul
<<|Saturating shift left
@addWithOverflow(a, b)Returns a tuple: result + overflow bit
zig
const big: u8 = 250;

const wrapped = big +% 10;          // 4
const saturated = big +| 10;        // 255
const shifted = @as(u8, 1) <<| 9;   // 255 — saturates instead of UB

const pair = @addWithOverflow(big, @as(u8, 10));
// pair[0] == 4, pair[1] == 1 (overflowed)
#015

Floats

ziglings 060

IEEE floats up to 128 bits — conversions stay explicit.

TypeNotes
f16 f32 f64 f80 f128IEEE-754 widths; hardware support varies
comptime_floatThe type of float literals at compile time

Integers and floats never mix implicitly in arithmetic: 1 + x with x: f32 is an error — write @as(f32, 1) + x. Convert explicitly with @floatFromInt, @intFromFloat (out-of-range is undefined behaviour; safety-checked panic in safe modes) and @floatCast. There is no NaN literal — use std.math.nan(f64).

zig
const std = @import("std");

const pi: f64 = 3.14159;
const root = @sqrt(pi);                 // runtime and comptime
const smaller: f32 = @floatCast(pi);    // f64 -> f32
const n: i32 = @intFromFloat(3.99);     // truncates toward zero: 3
const f: f64 = @floatFromInt(7);        // 7.0
const not_a_number = std.math.nan(f64);
0.16: small integers now coerce into floats:Since 0.16, small integer widths coerce into larger float types (e.g. u8 into f64) where every value is exactly representable — no cast needed.
#016

bool, void & noreturn

Three tiny types that shape control flow.

zig
const std = @import("std");

const ready: bool = true;
const check = ready and !ready;   // and / or / ! only

const unit: void = {};            // zero-bit: takes no memory

fn log(msg: []const u8) void {
    std.debug.print("{s}\n", .{msg});
}

fn crash() noreturn {             // never returns
    unreachable;                  // panics — or: while (true) {}
}
  • bool — true / false, combined with the and / or / ! keywords.
  • void — the unit type; its only value is {}. Zero-bit types (void, u0, empty structs) occupy no memory.
  • noreturn — the type of unreachable, panics and infinite loops without break; useful as a return type and in else prongs.
#017

Implicit Coercions

ziglings 061

Widening is free; narrowing must be explicit and runtime-checked.

FromToImplicit?
u8u32 (wider, same sign)yes
u32u8 (narrower)no — @intCast
i32u64 (sign change)no — signs must match
[N]T[]const Tyes
string literal *const [N:0]u8[]const u8 / [*:0]const u8 / *const [N]u8yes
*T?*Tyes (same size)
T?Tyes
TE!Tyes
error setsuperset error setyes
comptime_int / comptime_floatany int / float that fitsyes
Narrowing never happens silently:const small: u8 = big; with big: u32 is a compile error. Make it explicit with @intCast(big) — runtime-checked, panics in safe modes when the value does not fit (undefined behaviour in ReleaseFast).
#018

Type Reflection

Introspect any type at compile time via @typeInfo.

zig
const std = @import("std");

const T = @TypeOf(42);             // comptime_int
const label = @typeName(u32);      // "u32"
const size = @sizeOf(u64);         // 8 — also @bitSizeOf / @alignOf

const info = @typeInfo(struct { id: u32, name: []const u8 });
const fields = info.@"struct".fields;   // keyword tags need @"" quoting

comptime {
    for (fields) |f| std.debug.print("{s}\n", .{f.name});  // id, name
}
  • @typeInfo(T) returns a tagged union; tags are lowercase snake_case: .int, .pointer, .@"struct", …
  • Each struct field exposes .name, .type, .default_value and .alignment.
  • 0.16: @Type was removed — build types with individual builtins instead: @Struct, @Enum, @Pointer, @Array, …

Section 4 of 21: Arrays, Slices & Strings

7 entries

Fixed arrays, runtime slices, UTF-8 byte strings, sentinel terminators and SIMD vectors.

#019

Arrays

ziglings 004–005

Fixed length, comptime-known, value semantics.

zig
const std = @import("std");

const fixed = [3]u8{ 1, 2, 3 };
const inferred = [_]u8{ 4, 5, 6 };     // length from the initializer
const filled = [1]u8{0xAA} ** 16;      // ** repeats: 16 bytes of 0xAA
const grid = [2][3]u8{
    .{ 1, 2, 3 },                      // nested arrays
    .{ 4, 5, 6 },
};

for (inferred) |item| std.debug.print("{d}\n", .{item});
for (fixed, 0..) |item, i| std.debug.print("[{d}]={d}\n", .{ i, item });
  • [N]T — the length is part of the type and comptime-known; fixed.len is a compile-time constant.
  • Arrays are values: assignment copies, and passing to a function copies.
  • for (arr) |item| iterates values; for (arr, 0..) |item, i| adds an index.
#020

Slices

ziglings 052–053

A pointer plus runtime length — the workhorse view type.

zig
var data = [_]u32{ 1, 2, 3, 4, 5 };

const window: []u32 = data[1..4];      // { 2, 3, 4 }
const rest = data[2..];                // open-ended: to the end
const whole: []const u32 = &data;      // [N]T coerces to []const T

fn sum(items: []const u32) u32 {
    var total: u32 = 0;
    for (items) |item| total += item;  // len lives at runtime
    return total;
}
  • []T is a mutable view, []const T read-only. A slice is a **pointer + runtime .len**; slice.ptr hands out the raw pointer.
  • Slicing a slice works: rest[1..2]. Bounds are checked at runtime — out of range panics in safe modes.
  • Multi-object loop: for (as, bs) |a, b| iterates two sequences in lockstep — equal lengths are a runtime-checked requirement.
#021

Strings

ziglings 006–007

No string type — just []const u8 over UTF-8 bytes.

A string literal has type *const [N:0]u8 (null-terminated at compile time). The canonical string type is []const u8 — a byte slice, not null-terminated, holding arbitrary UTF-8. There is no built-in string type and no concat operator: build strings with an allocator.

zig
const std = @import("std");

const literal = "héllo";                       // *const [6:0]u8
const view: []const u8 = literal;              // canonical string type
const same = std.mem.eql(u8, "abc", "abc");    // byte compare: true
const starts = std.mem.startsWith(u8, "hello!", "hell");
const valid = std.unicode.utf8ValidateSlice("héllo");

// Iterate codepoints:
var it = std.unicode.Utf8View.initUnchecked("héllo").iterator();
while (it.nextCodepoint()) |cp| { _ = cp; }
Concatenation needs an allocator
pub fn main(init: std.process.Init) !void {
    const greeting = try std.fmt.allocPrint(init.arena, "hi, {s}!", .{"zig"});
    try std.Io.File.stdout().writeStreamingAll(init.io, greeting);
}
ExpressionType / result
"hi"*const [2:0]u8
"hi" coerced[]const u8, [*:0]const u8, *const [2]u8
'x'Char literal — a comptime_int
std.mem.concat(alloc, u8, .{a, b})New heap buffer from parts
#022

Multiline String Literals

Every line starts with \\ — no escapes, ever.

zig
const css =
    \\body {
    \\  color: red;
    \\};
  • Each line starts with \\; line breaks and spacing are preserved exactly.
  • No escape sequences inside — \\n is a literal backslash followed by n.
  • The final line contributes no trailing newline.
#023

Sentinel-Terminated

ziglings 076–078

Terminators baked into the type — how Zig talks to C.

zig
const std = @import("std");

const c_str: [*:0]const u8 = "hello";   // many-item + terminator

var buf: [16:0]u8 = undefined;          // array with a slot for the 0
buf[0] = 'o';
buf[1] = 'k';
buf[2] = 0;

const s: [:0]u8 = buf[0..2 :0];         // slice that keeps the sentinel
const span: []const u8 = std.mem.span(c_str);   // scan to terminator
  • [N:0]u8 — sentinel-terminated array; [:0]u8 — slice whose memory past the end is the sentinel value.
  • std.mem.span / std.mem.spanZ turn a [*:0]u8 into a length-aware slice — the bridge from C strings.
  • allocator.allocSentinel(u8, n, 0) allocates a sentinel-terminated buffer.
  • Common uses: C interop (char *), in-place formatting buffers.
#024

Vectors (SIMD)

ziglings 112

Fixed-width SIMD lanes with element-wise operators.

zig
const a: @Vector(4, f32) = .{ 1.0, 2.0, 3.0, 4.0 };
const b: @Vector(4, f32) = @splat(4, 2.0);   // 0.14+: length first

const sum = a + b;                 // element-wise: { 3, 4, 5, 6 }
const total = @reduce(.Add, sum);  // horizontal: 18.0
const pick = @select(f32, a > b, a, b);   // per-lane max here
  • @Vector(len, T) — the length must be comptime-known; lanes are bool, integer or float.
  • Operators apply element-wise; @reduce collapses to a scalar; @select picks per lane; std.simd has extra helpers.
  • @splat(len, scalar) broadcasts a scalar (length-first signature since 0.14).
0.16: arrays and vectors no longer coerce in memory:Since 0.16, [4]f32 and @Vector(4, f32) are kept strictly apart — convert between them explicitly with @bitCast.
#025

Compile-Time Data: @embedFile

Bake files into the binary as comptime-known bytes.

zig
const std = @import("std");

const logo = @embedFile("assets/logo.svg");  // *const [N:0]u8
const size = logo.len;                       // comptime-known

pub fn main(init: std.process.Init) !void {
    try std.Io.File.stdout().writeStreamingAll(init.io, logo);
}
  • The path is relative to the source file and must stay inside the module — the file becomes part of your binary.
  • Result type is *const [N:0]u8: a comptime-known, sentinel-terminated byte array.
  • For runtime files, use the file-system APIs (std.fs, Io.Dir) instead.
  • Works naturally with zig build modules — assets can also come from dependency packages.

Section 5 of 21: Pointers

6 entries

Six pointer spellings, manual arithmetic, casts and alignment discipline — non-null unless you ask.

#026

Taking Addresses

ziglings 039–040

& to take, .* to dereference — parameters are always values.

zig
var score: u32 = 100;

const p: *u32 = &score;        // pointer to score
p.* += 10;                     // deref-write: score == 110
const q: *const u32 = &score;  // read-only pointer
const v = q.*;                 // deref-read: 110

fn bump(n: *u32) void {        // to mutate the caller's value,
    n.* += 1;                  // take a pointer parameter
}
bump(&score);                  // score == 111

Everything is passed by value and parameters are immutable — there is no implicit by-reference. To mutate a caller's value (or avoid copying a big struct) pass a *T; pass *const T for read-only access.

#027

Pointer Kinds

ziglings 041–044

Six pointer spellings — size, length and nullability differ.

TypePoints toLengthNullableNotes
*Tone item1noNon-null, naturally aligned
*const Tone item1noPointee is read-only
[*]Tmany itemsunknownnoNo .len — arithmetic allowed
[*:0]Tmany itemssentinel-delimitednoC-string style
[]T / []const Tmany itemsruntime .lennoSlice: pointer + length
[*c]Tmany or oneunknownyesC pointer from translate-c; coerces both ways
?*Tone item1yesSame size as *T — null reuses the zero bits

A non-optional pointer can never be null, and ?*T costs no extra memory — Zig reserves the all-zero-bits pattern as null. Prefer slices over bare pointers whenever a length is meaningful.

#028

Pointer Arithmetic

ziglings 054

Reserved for many-item pointers — slices lend you their .ptr.

zig
var data = [_]u32{ 10, 20, 30, 40 };

const p: [*]u32 = &data;      // many-item pointer
const second = p + 1;         // arithmetic: only on [*]T
second[0] = 99;               // data[1] == 99 — no bounds check

const slice: []u32 = data[0..4];
const base = slice.ptr;       // same [*]u32
const addr: usize = @intFromPtr(base);
const back: [*]u32 = @ptrFromInt(addr);
Manual, unguarded, yours:[*]T has no length, so indexing it skips bounds checks. In most code, slicing (p[0..n]) or keeping a []T is the safer — and equally fast — choice.
#029

Pointer Casts

One builtin per job — and alignment is your responsibility.

zig
var bytes = [_]u8{ 0x37, 0x13, 0x00, 0x00 };

// Reinterpret the pointee type — here with lowered alignment:
const loose: *align(1) const u32 = @ptrCast(&bytes);

// Raise alignment: you must guarantee it is truly aligned:
const strict: *const u32 = @alignCast(loose);

const frozen: []const u8 = &bytes;
const thawed: []u8 = @constCast(frozen);   // remove const
BuiltinJob
@ptrCastChange pointee type / pointer width — may only *lower* alignment
@alignCastRaise the alignment — UB if the real alignment is lower
@constCastRemove const from a pointer
@volatileCastRemove volatile from a pointer
Alignment UB:Casting a pointer to a higher alignment than the data actually has is undefined behaviour — misaligned loads can fault or silently corrupt. @alignCast is a promise, not a check.

0.16: explicitly aligned pointer types like *align(1) u32 are now distinct types from the naturally aligned *u32 — but they still coerce to each other.

#030

volatile & allowzero

Talking to hardware and the zero address.

zig
const status_reg: *volatile u32 = @ptrFromInt(0x4000_0000);

// Reads are never elided or reordered away:
while ((status_reg.* & 1) == 0) {}   // wait for the ready bit

// Writes are never removed:
status_reg.* = 0x1;
  • *volatile T marks accesses as observable — required for memory-mapped I/O and other sneaky memory.
  • *allowzero T permits address 0 as a valid pointer value (rare; needed only for special ABIs).
  • @prefetch(ptr, .{}) hints the cache without dereferencing.
#031

Pointer Metadata & Slice Patterns

Inspect pointer types at comptime; turn buffers into slices early.

zig
var buf: [64]u8 = undefined;
const n = 10;
const view: []u8 = buf[0..n];    // buffer + count -> slice: the idiom

const info = @typeInfo([]const u8).pointer;
// info.size      -> enum: .one / .many / .slice / .c
// info.is_const  -> bool
// info.alignment -> comptime_int
// info.child     -> u8
// info.sentinel  -> ?u8 (for sentinel pointers)

The golden rule: a raw buffer plus a count should become a slice (buf[0..n]) as early as possible — slices carry their length, work with for, and keep bounds checks.

Section 6 of 21: Structs

6 entries

Plain data with namespaces: methods, tuples, destructuring, packed bits and extern layouts.

#032

Declaring & Initializing

ziglings 037–038

Fields, defaults and decl-literal initialization.

zig
const Point = struct {
    x: f64 = 0,               // default value
    y: f64 = 0,

    const origin = Point{ .x = 0, .y = 0 };   // container-level decl
};

const a: Point = .{ .x = 1, .y = 2 };   // decl-literal init
const b = Point{ .y = 5 };              // full form; x takes the default
const c = a;                            // copy — structs are values
  • Fields may have defaults; omitted fields use them.
  • .{ ... } is the anonymous init (a decl literal) — it needs a result type; Point{ ... } is the explicit form.
  • Structs are plain values: assignment and parameter passing copy. No inheritance, no constructors — just data plus functions.
#033

Methods & Namespaces

ziglings 047–048

Functions inside a struct — with an explicit self, no magic.

zig
const Vec = struct {
    x: f64,
    y: f64,

    fn length(self: Vec) f64 {           // by value: read-only use
        return @sqrt(self.x * self.x + self.y * self.y);
    }

    fn scale(self: *Vec, k: f64) void {  // by pointer: mutation
        self.x *= k;
        self.y *= k;
    }
};

var v = Vec{ .x = 3, .y = 4 };
const len = v.length();      // sugar for Vec.length(v)
v.scale(2);                  // sugar for Vec.scale(&v, 2)
  • There is **no implicit this**: the first parameter is the receiver, conventionally named self (Vec, *Vec or *const Vec).
  • Method-call syntax v.scale(2) is sugar — it auto-takes &v when the receiver is a pointer.
  • Non-method fns and consts in the body act as static members; @This() names the struct itself from inside.
#034

Tuples & Anonymous Structs

ziglings 080–083

Struct literals without a name, plus 0.14 destructuring.

zig
const pair = .{ 3, 7 };                 // tuple: fields "0", "1"
const point = .{ .x = 1, .y = 2 };      // anonymous struct
const mixed = .{ 1, "two", true };      // heterogeneous tuple
const first = mixed[0];                 // comptime index

const [lo, hi] = pair;                  // destructuring (0.14+)
const .{ .x = px, .y = py } = point;    // by field name

var a: u32 = 1;
var b: u32 = 2;
[a, b] = .{ b, a };                     // swap — no tmp needed
  • .{ 1, 2 } is a tuple (fields named "0", "1", …); .{ .a = 1 } is an anonymous struct. Both are real struct types.
  • Destructuring since 0.14: const [a, b] = tup;, const .{ .x = x } = point;, var variants, and _ to discard.
  • Tuples power multi-object loops: for (as, bs) |a, b| iterates a tuple of slices.
  • Without destructuring, a swap needs a temp: const tmp = a; a = b; b = tmp;.
#035

Packed Structs

ziglings 114–115

Exact bit layout over a backing integer.

zig
const Flags = packed struct(u16) {
    visible: bool,      // 1 bit
    layer: u8,          // 8 bits
    tag: u7,            // 7 bits
};                      // 1 + 8 + 7 == 16, no padding

const f = Flags{ .visible = true, .layer = 3, .tag = 127 };
const bits: u16 = @bitCast(f);       // to the backing integer
const back: Flags = @bitCast(bits);  // and back
  • packed struct(uN) lays fields out bit-exactly over a backing integer — guaranteed in-memory layout, zero padding.
  • Field widths must add up exactly; convert with @bitCast.
  • 0.16: pointers are not allowed inside packed structs and packed unions.

0.16: packed structs and packed unions can now appear directly as switch prong items.

#036

extern Structs

C-compatible layout for FFI boundaries.

zig
const Header = extern struct {
    magic: u32,          // field order is guaranteed
    width: u32,
    height: u32,
    data: [*]const u8,   // pointers are fine
};
KindLayoutUse for
structCompiler's choice — may reorder and padEverything inside Zig-land
packed struct(uN)Exact bits over a backing integerWire formats, hardware registers
extern structC ABI, field order guaranteedInterop with C, syscalls, file formats
#037

Field Introspection

Access fields by name at runtime; walk them at compile time.

zig
const std = @import("std");

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

const user = User{ .id = 7, .name = "ada" };

comptime {
    std.debug.print("has id: {}\n", .{@hasField(User, "id")});
    std.debug.print("offset of name: {d}\n", .{@offsetOf(User, "name")});

    inline for (std.meta.fields(User)) |f| {
        std.debug.print("{s}\n", .{f.name});   // id, name
    }
}

const picked = @field(user, "name");           // access by runtime name
  • @field(obj, "name") — access a field (or decl) when the name is only known at runtime.
  • @hasField(T, "name"), @offsetOf(T, "name"), and @fieldParentPtr for the rare parent-pointer trick.
  • std.meta.fields(T) (or @typeInfo(T).@"struct".fields) + inline for is the workhorse for compile-time iteration.

Section 7 of 21: Enums & Unions

6 entries

Exhaustive tags, payload unions with compiler-tracked active fields, and packed bit views.

#038

Enums

ziglings 035–036

Tags with an optional integer backing — exhaustive by default.

zig
const Color = enum { red, green, blue };

const Mode = enum(u8) {        // explicit tag type
    off = 0,
    on = 1,
    auto = 255,
};

const n = @intFromEnum(Mode.on);     // -> u8 value
const m: Mode = @enumFromInt(1);     // checked in safe modes

fn describe(c: Color) []const u8 {
    return switch (c) {              // must be exhaustive
        .red => "stop",
        .green => "go",
        .blue => "chill",
    };
}
  • Without a backing type, the compiler picks the smallest integer that fits all tags.
  • Enums can have methods and decls, just like structs.
  • enum(u8) { a, _ } declares a non-exhaustive enum — switching one requires a _ => prong.
  • @enumFromInt with a value that has no tag is a checked panic in safe modes; UB in ReleaseFast.
#039

Enum Literals

.red means the red tag — of whichever type the context expects.

zig
const Color = enum { red, green, blue };
const Team = enum { red, blue };

fn paint(c: Color) void { _ = c; }

paint(.red);                       // .red coerces to Color.red

const t: Team = .red;              // ...and also to Team.red
  • An enum literal .red coerces into any enum in the expected type that has a matching tag.
  • 0.15: generalized to decl literals — .empty, .none and friends resolve to any reachable decl, e.g. var list: std.ArrayList(u8) = .empty;.
  • 0.16: decl literals may be used directly in switch prongs.
#040

Tagged Unions

ziglings 056–057

One payload at a time, with the compiler tracking which.

zig
const Shape = union(enum) {
    circle: f64,
    rect: struct { w: f64, h: f64 },
    none,
};

fn area(s: Shape) f64 {
    return switch (s) {
        .circle => |r| 3.14159 * r * r,     // capture the payload
        .rect => |r| r.w * r.h,
        .none => 0,
    };
}

const s: Shape = .{ .circle = 2.0 };        // init one field
const empty = s == .none;                   // tag check: false
  • union(enum) gives every payload a tag; switch must handle all of them (exhaustive).
  • Capture payloads in prongs: .circle => |r| by value, .rect => |*r| for mutation.
  • Field-less members like none carry no data.
  • std.meta.activeTag(s) reads the active tag explicitly; unions are value types.
#041

Untagged & extern Unions

ziglings 055

Raw field aliasing — you own the active-field bookkeeping.

zig
const Value = union {
    int: i32,
    float: f64,      // same storage — fields alias
};

var v: Value = .{ .int = 42 };
v.float = 3.5;       // overwrites the same bytes
No tag, no mercy:Nothing tracks which field is active. Reading a field you did not last write is undefined behaviour — the compiler assumes the active field and may optimize accordingly. Use a tagged union(enum) unless an ABI forces otherwise.

extern union gives C-compatible overlay layout for FFI (think C's union); plain union is the in-Zig variant. Both expect you to remember the active field.

#042

Packed Unions

Field aliasing at the bit level over a backing integer.

zig
const Reg = packed union(u2) {
    raw: u2,
    parts: packed struct(u2) { low: bool, high: bool },
};

const r: Reg = .{ .raw = 0b10 };

// 0.16+: packed unions can be compared for equality:
const matches = r == .{ .parts = .{ .low = false, .high = true } };
  • packed union(uN) overlays its fields bit-exactly on a backing integer — the classic register-view tool.
  • 0.16: packed unions support == comparisons and can appear as switch prong items.
#043

Choosing: enum vs union vs struct

A ten-second decision table.

If you need...Reach forData
One of N statesenumnone — just the tag
One of N states, each with dataunion(enum)exactly one active payload
Overlapping views of the same bitsunion / packed union / extern unionyou track the active field
All fields at oncestructeverything, always

A tagged union is Zig's safe version of C's tagged-struct idiom and C++'s std::variant: the tag is compiler-managed, and switch refuses to compile until every case is handled.

Section 8 of 21: Optionals

4 entries

Null without the billion-dollar mistake — ?T is a value or null, and every unwrap is compiler-checked.

#044

Optional Basics

ziglings 045–046, 050

?T is a value or null — test it, default it, or capture it; there is no truthiness.

?T holds a value of T or null. Test with o == null, default with orelse, unwrap-assert with .?, or capture the payload with if (o) |v|.

Create, test, unwrap
const maybe: ?i32 = null;
const n: ?i32 = 42;

if (n) |v| {
    std.debug.print("got {d}\n", .{v});  // payload capture
} else {
    std.debug.print("nothing\n", .{});
}

const a = maybe orelse 0;        // 0 — fallback when null
const b = n.?;                   // 42 — panics if null
std.debug.print("{d} {d}\n", .{ a, b });
OperationValue presentnull
o orelse dyields oyields d
o.?yields opanic: attempt to use null value
if (o) |v| a else ba, with v boundb
while (o) |v| { ... }body per valueloop ends
#045

While-Unwrap & Iterators

ziglings 045–046, 050

while (iter.next()) |item| is the idiomatic iterator loop; else fires when the loop ends without break.

An iterator is any type with fn next() ?T — each call yields the next value or null. The while-unwrap loop consumes it: the body runs per value, the loop ends when null arrives.

Custom iterator over ?u32
const Counter = struct {
    remaining: u32 = 3,
    fn next(self: *Counter) ?u32 {
        if (self.remaining == 0) return null;
        self.remaining -= 1;
        return self.remaining;        // yields 2, 1, 0
    }
};
var it = Counter{};
while (it.next()) |item| {            // ?u32 unwrapped per pass
    std.debug.print("{d} ", .{item});
} else {
    std.debug.print("ended, no break\n", .{}); // runs: no break used
}

The else clause — on while and for alike — runs only when the loop ended without break; for error unions it takes the last error: else |e|. for (slice, 0..) |item, i| walks items with their index, and multi-sequence for (as, bs) |a, b| zips sequences pairwise.

#046

Optional Pointers

ziglings 045–046, 050

?*T costs nothing extra — null hides in the pointer's spare bit pattern; ?u32 pays for a tag.

?*T occupies exactly the same bytes as *T: address 0 is never a valid pointer, so it encodes null for free. Payloads without a spare bit pattern (?u32, ?bool) need a hidden tag byte. This is why linked-list next fields, tree children, and find-style APIs returning ?usize all use optional pointers.

zig
const Entry = struct {
    id: u32,
    next: ?*Entry = null,       // linked list: null is free
};

const Config = struct {
    home: ?[]const u8 = null,   // optional slice field
    fallback: ?*const Config = null,
};

fn findIndex(hay: []const u8, needle: u8) ?usize {
    for (hay, 0..) |c, i| {
        if (c == needle) return i;   // find-style API
    }
    return null;
}
TypeSizeWhy
?*T== @sizeOf(*T)0 is reserved — null is free
?*const T== @sizeOf(*const T)same trick
?u32> @sizeOf(u32)extra tag, padded to alignment
?bool> @sizeOf(bool)no spare bit pattern
#047

Patterns & Gotchas

ziglings 045–046, 050

orelse chains, nested optionals, and why catch is not orelse.

zig
// orelse chain: first non-null wins
const home = env_home orelse env_user orelse "/tmp";

// capture payload BY POINTER: mutate in place, no copy
if (map.getPtr(key)) |ptr| ptr.count += 1;

// nested optional ??T: legal but rare — unwrap twice
const deep: ??u32 = n;
if (deep) |inner_opt| {
    if (inner_opt) |v| std.debug.print("{d}\n", .{v});
}
  • null has its own singleton type — @TypeOf(null); optionals are not booleans
  • a orelse b orelse c short-circuits to the first non-null
  • nested ??T is allowed but almost always a design smell — prefer ?T or an error union
  • catch belongs to error unions, orelse to optionals — the two do not interchange
x.? panics with attempt to use null value — and that safety check only exists in Debug and ReleaseSafe. In ReleaseFast it is compiled out: undefined behavior. Prefer orelse or if-capture unless the invariant is locally provable.

Section 9 of 21: Error Handling

6 entries

Errors are ordinary values: E!T return types, try/catch, errdefer — no exceptions, no unwinding.

#048

Error Sets

ziglings 021–025, 033

error{...} defines a set of error values — checked at compile time, returned, never thrown.

An error set — error{FileNotFound, OutOfMemory} — is a distinct set of error values, not a class hierarchy. Values coerce upward into any superset for free; anyerror accepts every error that exists.

zig
const OpenError = error{ FileNotFound, AccessDenied };

// combine sets: merged superset, coercion is automatic
const IoError = OpenError || error{ EndOfStream };

// anyerror: the global set — erases the concrete set
var last: anyerror = error.OutOfMemory;

fn open() OpenError!void {
    return error.FileNotFound;   // must be in the return set
}
FormMeaning
error{A, B}set literal; values are error.A, error.B
E1 || E2merged superset (coercion target)
anyerrorthe global set of all errors
E!Terror union: an E value or a T payload

Error sets are not exceptions. They are return values: an error must be handled at the call site or propagated explicitly, and nothing ever unwinds.

#049

Error Unions & Inferred Sets

ziglings 021–025, 033

E!T is error or value; !T lets the compiler infer the set; try/catch unwrap it.

E!T is a union: an error value or a payload. !T (no set named) means inferred — the compiler collects every error the body can return, and inferred sets compose across call chains. Bind one explicitly as IoError!u32 when you want a stable contract.

zig
fn parse(s: []const u8) !u32 {            // ! = inferred error set
    if (s.len == 0) return error.Empty;   // raise: return an error
    return try std.fmt.parseInt(u32, s, 10);
}

const v = parse("42") catch 0;            // fallback value
const w = parse("42") catch unreachable;  // asserts no error
const x = parse("nope") catch |e| blk: {  // labeled catch
    std.debug.print("failed: {s}\n", .{@errorName(e)});
    break :blk 0;
};
try
const v = try parse(s);
catch |e| return e
const v = parse(s) catch |e| return e;

try f() is exactly this sugar: unwrap, or return the error from the enclosing function.

FormOn errorOn success
try f();returns the error from this fnyields the value
f() catch 0;yields 0yields the value
f() catch unreachable;panicsyields the value
f() catch |e| handler;runs handler, e in scopeyields the value
#050

Handling Payloads

ziglings 021–025, 033

Unwrap error unions with if/while payload capture; switch on the error value itself.

zig
const r: ReadError!u32 = read();

if (r) |v| {                          // success: v is the payload
    std.debug.print("ok {d}\n", .{v});
} else |e| {                          // error: e is the error value
    switch (e) {                      // switch on the error set
        error.FileNotFound => create(),
        else => return e,             // propagate the rest
    }
}

while (tryConnect()) |conn| {         // body runs on each success
    serve(conn);
} else |e| return e;                  // runs on the final error

There is no switch on an error union — unwrap with if (r) |v| ... else |e| ... first, then switch the bare error value. A switch (e) over a set must be exhaustive or end in else.

  • @errorName(e) returns the name as []const u8 — the builtin is @errorName, there is no std.error module
  • if (r) |v| { ... } else |e| { ... } — success and error branches in one statement
  • while (attempt()) |v| { ... } else |e| { ... } — retry loops for free
#051

errdefer

ziglings 021–025, 033

errdefer runs cleanup only when an error return passes through its scope.

errdefer arms when execution passes it and fires only when an error return leaves the scope — never on success. Arming it before the resource's own try is pointless: that try already bailed out of the function.

Resource cleanup ordering (LIFO)
const conn = try connect(gpa);   // acquire
errdefer conn.close();           // undo ONLY if we return an error

const buf = try gpa.alloc(u8, 64);
errdefer gpa.free(buf);          // LIFO: freed before conn closes

errdefer |e| std.log.warn("failed: {s}", .{@errorName(e)});

try conn.handshake();            // any failure here or below:
return conn.read();              // log, free buf, close conn, propagate
FormFires
defer f();every scope exit
errdefer f();scope exit via error return only
errdefer |e| f(e);same, with the error value bound
success return after armingnothing — the errdefer is skipped
#052

Panics & Unreachable

ziglings 021–025, 033

@panic aborts with no unwinding; unreachable is a contract the optimizer relies on.

zig
std.debug.assert(x > 0);           // "assertion failed" in safe modes

if (buf.len > max_len) @panic("buffer overrun by design");

fn step(s: Phase) void {
    switch (s) {
        .start, .running => advance(s),
        .done => unreachable,       // invariant: never called when done
    }
}
  • @panic("msg") prints message + trace and aborts — no unwinding: defers do not run
  • unreachable — reached unreachable code panic in Debug/ReleaseSafe; undefined behavior in ReleaseFast
  • std.debug.assert(cond) — panics in Debug/ReleaseSafe, compiled out in ReleaseFast/ReleaseSmall
  • error return traces print in safe builds — every try the error crossed, like a reverse stack trace
  • gate debug-only code with builtin.mode == .Debug (const builtin = @import("builtin");)
No exceptions, no catch-alls. An error is handled where it occurs or returned explicitly — and error unions cannot cross extern/export boundaries: convert to C-style return codes at the FFI seam.
#053

Error Style Guide

ziglings 021–025, 033

Errors carry no payload — keep sets small, add context where it exists, try liberally.

Error values carry no payload or message. The idiomatic response: small per-module sets, context added at the call site that has it, and errdefer for cleanup.

  • no payloads, no wrapper types — attach context by logging where you have it, plus errdefer
  • keep error sets small and per-module; error{OutOfMemory} is ubiquitous and coerces everywhere
  • try liberally; handle at the call site that actually has context to recover
  • don't use optionals for failures — ?T means *absent*, E!T means *went wrong*
  • returning an error from main prints the error trace and exits nonzero
zig
fn load(gpa: Allocator, path: []const u8) !Config {
    const file = try openConfig(path);   // low-level error, no context
    defer file.close();

    return parseConfig(gpa, file) catch |e| {
        std.log.err("config {s}: {s}", .{ path, @errorName(e) }); // context here
        return e;                        // propagate the original error
    };
}

Section 10 of 21: Control Flow

6 entries

Conditions must be real bool; if, loops, and switch are expressions — plus labeled blocks for structured jumps.

#054

if / else if / else

ziglings 009–017, 030–031, 062–063, 098, 103–104, 111

Conditions must be genuine bool; if is an expression — that's why there is no ternary.

No truthiness: if (ptr) and if (n) are compile errors — compare explicitly. As an expression, every branch must yield the same type, which is exactly what the ternary would do.

zig
const x: i32 = 7;

// statement form: condition must be bool
if (x > 5) std.debug.print("big\n", .{});

// expression form: replaces the ternary
const parity: []const u8 = if (x % 2 == 0) "even" else "odd";

// payload sugar (see Optionals / Error Handling)
if (maybe) |v| use(v);                     // optional
if (result) |v| use(v) else |e| fail(e);   // error union
  • braces are optional for a single statement; zig fmt keeps whatever you wrote
  • when used as an expression, all branches must yield one common type
  • if (o) |v| unwraps an optional; if (r) |v| ... else |e| unwraps an error union
#055

while

ziglings 009–017, 030–031, 062–063, 098, 103–104, 111

while with a continue-expression, labeled break, and an else clause — no do-while.

while (cond) { } repeats until the condition is false. The continue-expression : (i += 1) runs after every pass — including after continue — making it the safest place for increments.

zig
var i: u32 = 0;
while (i < 5) : (i += 1) {    // continue-expr runs after each pass
    if (i == 3) continue;
    if (i == 4) break;
}

// no do-while: use while (true) + break
while (true) {
    if (ready()) break;
}

// else: runs when the condition ends the loop WITHOUT a break
while (poll()) |v| {          // optional / error-union unwrap
    if (v == 0) break;
} else flush();               // poll() ran dry
  • break/continue can target a label: break :outer, continue :outer
  • else after while: fires when the loop ended without break (also else |e| for error unions)
  • no do-while — write while (true) plus a break
#056

for & Ranges

ziglings 009–017, 030–031, 062–063, 098, 103–104, 111

for walks exclusive ranges and slices, zipping sequences with an optional index.

for (0..n) |i| iterates the exclusive range 0..n. Sequences zip by position — for (xs, ys, 0..) |x, y, i| — and all lengths must match (runtime check in safe builds).

zig
for (0..3) |i| {                      // exclusive range: 0, 1, 2
    std.debug.print("{d} ", .{i});
}
const xs = [_]u8{ 10, 20, 30 };
const ys = [_]u8{ 1, 2, 3 };

for (xs, ys, 0..) |x, y, i| {         // zip: item, item, index
    std.debug.print("{d}+{d}@{d} ", .{ x, y, i });
}                                     // equal lengths, runtime-checked

for (xs) |x| {
    if (x == 20) continue;
    if (x == 30) break;
} else std.debug.print("no break\n", .{});
  • _ discards a slot: for (xs, 0..) |_, i| iterates indices only
  • no reverse ranges — index backwards by hand, or std.mem.reverse a copy first
  • mutate in place with for (slice) |*item| item.* += 1
  • else on for runs when the loop finished without break
#057

switch

ziglings 009–017, 030–031, 062–063, 098, 103–104, 111

Exhaustive by default; ranges with ...; prongs capture tagged-union payloads.

switch is exhaustive by default: cover every value or add else. It works as expression or statement, and captures |v| bind payloads — especially for tagged unions.

zig
const label = switch (n) {          // expression: prongs yield a value
    0 => "zero",
    1, 2, 3 => "small",             // value list
    4...8 => "medium",              // inclusive range
    else => "large",
};

fn area(s: Shape) f32 {             // union(enum): capture payloads
    return switch (s) {
        .circle => |r| r * r * 3.14159,
        .rect => |d| d[0] * d[1],
    };                              // exhaustive: no else needed
}
ProngMatches
1 => ...a single value
1, 2 => ...a list of values
1...5 => ...an inclusive range
.circle => |r| ...payload capture on union(enum)
else => ...everything unmatched
  • prongs are comma-separated — trailing comma when they span lines
  • switch works on ints, bools, enums, error sets, and union(enum)s — not strings or floats (use if/else + std.mem.eql)
  • 0.16: prongs accept packed struct/union items (.{ .b = 3 }) and decl literals as values
#058

Labeled Blocks & Loops

ziglings 009–017, 030–031, 062–063, 098, 103–104, 111

Labels turn nested loops and blocks into structured goto — and blocks can yield values.

A label names a loop or block so break :label / continue :label can target it from any nesting depth. Labeled blocks double as multi-step expressions.

zig
outer: for (rows) |row| {
    for (row) |cell| {
        if (cell < 0) break :outer;     // exits BOTH loops
        if (cell == 0) continue :outer; // next outer iteration
    }
}

const idx = blk: {                      // labeled block yields a value
    for (names, 0..) |name, i| {
        if (std.mem.eql(u8, name, want)) break :blk i;
    }
    break :blk null;                    // not found
};
  • labels work on blocks, for, and while alike
  • continue :label restarts the labeled loop's next iteration
  • break :blk value makes a block an expression — replaces goto and early-multi-break acrobatics
#059

unreachable

ziglings 009–017, 030–031, 062–063, 098, 103–104, 111

Proves impossibility to the compiler — or costs you UB in optimized builds.

unreachable asserts a line can never execute. The compiler may optimize based on that promise. Safe modes verify it at runtime: panic reached unreachable code. ReleaseFast believes you: undefined behavior.

zig
fn drive(s: State) void {
    switch (s) {
        .parked, .driving => move(s),
        .scrapped => unreachable,   // invariant: can't happen here
    }
}
Never validate input with unreachable — hostile data hitting it in ReleaseFast is memory corruption, not a crash. Use @panic, try, or else => return error.X for anything reachable.

Section 11 of 21: Functions

6 entries

Required return types, immutable params, no closures — functions are plain, predictable declarations.

#060

Declaring Functions

ziglings 018–020, 032

Return type required, params immutable, no defaults — and pub controls visibility.

The return type is REQUIRED — use void when there is nothing to return. Parameters are immutable bindings, and there are no default or named arguments.

zig
/// Adds two integers.
pub fn add(a: i32, b: i32) i32 {
    return a + b;
}

pub fn logHello(name: []const u8) void {  // void: no return value
    std.debug.print("hello {s}\n", .{name});
    // params are const: assigning to name is a compile error
}
  • pub exposes a decl to @importers of this file — private otherwise
  • parameters are const; to change one, copy into a local first
  • /// doc comments attach to decls and appear in autodoc
  • no overloading — use distinct names or comptime dispatch
#061

Multiple Return Values

ziglings 018–020, 032

No multi-return sugar — return a struct/tuple, an optional, an error union, or use out-params.

Zig has no multi-value return sugar — pick a shape: !T for failure, ?T for absence, a struct or anonymous tuple for several values, *T out-params for mutation.

zig
fn divMod(a: u32, b: u32) struct { q: u32, r: u32 } {
    return .{ .q = a / b, .r = a % b };
}

const .{ .q = q, .r = r } = divMod(17, 5);   // 3 and 2 (0.14+)
std.debug.print("{d} {d}\n", .{ q, r });

// alternatives: ?T (absent), E!T (failure), *T (out-param)
fn indexOf(s: []const u8, c: u8) ?usize {
    for (s, 0..) |ch, i| if (ch == c) return i;
    return null;
}
  • tuple return type: struct { u32, u32 } — anonymous, destructured by position
  • destructuring since 0.14: const .{ .q = q, .r = r } = ...;
  • _ discards a destructured slot you don't need
#062

Passing Arguments

ziglings 018–020, 032

Pass-by-value with compiler-optimized lowering — pointers only for mutation or sharing.

Arguments pass by value; the compiler picks the cheap lowering, so don't contort signatures over sizes. Reach for pointers when you must mutate the caller's data or share it — and remember params are immutable, so *T is the only way to write back.

zig
fn byValue(p: Point) Point {         // copy — fine for small data
    return .{ .x = p.x * 2, .y = p.y * 2 };
}

fn scale(p: *Point, f: f32) void {   // mutate the caller's value
    p.x *= f;
    p.y *= f;
}

fn total(items: []const u32) u32 {   // read-only view, any length
    var sum: u32 = 0;
    for (items) |v| sum += v;
    return sum;
}
SituationSignature
read-only, small datapass T by value
read-only, big data[]const T or *const T
mutate caller's data*T
accept an arrayparam []const T — [N]T coerces
optional argument?T explicitly (no default args)
#063

export, extern & callconv

ziglings 018–020, 032

export publishes C-visible symbols, extern declares them, callconv pins the ABI.

The calling convention decides the ABI. export/extern bridge Zig to C and the linker; inline is a codegen hint that forces call-site expansion.

zig
export fn zig_callback(x: i32) i32 {   // C-visible symbol
    return x * 2;
}

extern "c" fn printf(fmt: [*:0]const u8, ...) c_int; // C variadic

fn cStyle(x: u32) callconv(.c) u32 {   // explicit C ABI (0.14+)
    return x + 1;
}

inline fn square(x: i32) i32 {         // body copied into callsites
    return x * x;
}
  • export fn f() emits the symbol for C/linkers; extern declares one defined elsewhere
  • extern "c" names the library; variadic ... requires the C calling convention
  • callconv(.c) on a normal fn pins the C ABI (lowercase since 0.14)
  • callconv(.naked) for asm-only stubs — no prologue or epilogue
  • inline fn forces inlining at each call site
#064

Function Pointers

ziglings 018–020, 032

*const fn (...) T types, &f spelling, no closures — context goes through a parameter.

A function pointer's type is *const fn (i32, i32) i32. Take a function's address with &f — the safest spelling — then call it like a function. There are no closures: pass captured state through an explicit context parameter.

zig
fn add(a: i32, b: i32) i32 { return a + b; }
fn sub(a: i32, b: i32) i32 { return a - b; }

const Op = *const fn (i32, i32) i32;

fn apply(f: Op, a: i32, b: i32) i32 {
    return f(a, b);
}

const f: Op = &add;                    // &fn is the canonical spelling
std.debug.print("{d} {d}\n", .{ apply(f, 3, 4), apply(&sub, 3, 4) });
  • used for callbacks, interrupt vectors, and hand-rolled vtables (see comptime & Generics for the zero-cost alternative)
  • capture state C-style: fn cb(ctx: *anyopaque, x: u32) void with the context as the first parameter
  • comptime fn parameters specialize per function: fn wrap(comptime f: fn () void)
#065

Recursion

ziglings 018–020, 032

Runtime recursion just works (until the stack); comptime recursion needs an eval-branch budget.

Runtime recursion is plain stack recursion — nothing special, nothing detected. Comptime recursion is bounded by the eval branch quota, raised with @setEvalBranchQuota.

zig
fn fib(n: u32) u64 {                  // runtime: plain stack recursion
    return if (n < 2) n else fib(n - 1) + fib(n - 2);
}

fn Fib(comptime n: u32) u64 {         // comptime: quota-bounded
    return if (n < 2) n else Fib(n - 1) + Fib(n - 2);
}

comptime {
    @setEvalBranchQuota(1_000_000);   // default quota is only 1000
    _ = Fib(25);                      // evaluated during compilation
}
  • infinite runtime recursion dies with a stack overflow — not detected, no tail-call guarantee
  • recursive generic instantiation can explode compile times — memoize or flatten
  • @setEvalBranchQuota raises the comptime step budget for the current evaluation

Section 12 of 21: defer & Cleanup

4 entries

Scope-exit cleanup: defer, errdefer, and the acquire/release discipline that replaces RAII.

#066

defer

ziglings 027–029

Runs at the END OF SCOPE — the block, not the function — always, in LIFO order.

defer schedules a statement for the end of its enclosing block — not the function. Sibling defers run LIFO (last registered, first run) on every exit path: return, error return, break.

Mutex + heap example
fn demo(gpa: Allocator) !void {
    const data = try gpa.alloc(u8, 128);
    defer gpa.free(data);           // paired right at acquisition

    lock.lock();
    defer lock.unlock();            // LIFO: runs BEFORE free

    // ... work — early returns, errors, breaks: all safe
}
Defers do not run on panic or abort — the process dies on the spot. One more reason panics are for bugs and errors are for expectations.
#067

errdefer

ziglings 027–029

Same LIFO machinery, but only on the failure path — combine for commit/rollback.

errdefer fires only when an error return passes through its scope — never on success. The classic combo: errdefer rollback right after acquiring, commit as the last step. Two lines make a transaction.

zig
const conn = try connect(gpa);
errdefer conn.close();          // only on error return
try conn.handshake();           // bail? -> closed for you

try tx.begin();
errdefer tx.rollback();         // failure path
try tx.write(records);
tx.commit();                    // success path — keep it last
Exit pathdefererrdefer
success returnrunsskipped
error return / failed tryrunsruns
leave the scope via breakrunsskipped
panic / abortskippedskipped
#068

Scope Rules & Gotchas

ziglings 027–029

defer is block-scoped: in a loop body it fires every iteration — or holds resources the whole loop.

defer binds to the nearest enclosing block. In a loop body that is one iteration; inside an if block, that block; at function top, the function.

zig
fn scan(gpa: Allocator, paths: []const []const u8) !void {
    for (paths) |path| {
        const f = try open(path);            // acquire per iteration
        defer f.close();                     // released per iteration

        const buf = try gpa.alloc(u8, 1024); // also per iteration
        defer gpa.free(buf);                 // LIFO in this body:
        try process(f, buf);                 // free buf, then close f
    }
}
  • defer reads variables at RUN time, not at registration — defer log(i) sees i as it is when the scope exits
  • want the value as it was at defer-time? snapshot first: const j = i; defer use(j);
  • leaving the scope via break still runs defers — only panic/abort skips them
A defer outside a loop body holds the resource for the whole loop. If each iteration should own the resource, move the acquire/defer pair into the body.
#069

Common Cleanup Table

ziglings 027–029

acquire → defer release, immediately, before any try in between.

The discipline that makes defer work: every acquire is IMMEDIATELY followed by its release deferral — before any try, return, or second acquire can slip between them.

AcquireReleaseNotes
gpa.alloc(u8, n)gpa.free(mem)same allocator, same scope
open a filef.close()whatever opened it closes it
lock.lock()lock.unlock()std.Thread.Mutex
acquire a refcountrelease itbalance every +1 with a -1
tx.begin()tx.commit()errdefer tx.rollback()
suspendresumekeep the pair visually adjacent
zig
const mem = try gpa.alloc(u8, size);
defer gpa.free(mem);             // <- no try between these two lines

const out = try dest.begin();    // next resource
defer out.finish();              // paired before any risk

Section 13 of 21: comptime & Generics

7 entries

Run arbitrary code at compile time — generics are just functions on type, with no macro preprocessor in sight.

#070

comptime Fundamentals

ziglings 064–075, 084

comptime marks code the compiler executes at build time — params, vars, and blocks.

comptime marks code the compiler runs at build time: parameters, variables, whole blocks. All generics, type values, and default values are comptime — this one mechanism replaces C macros, templates, and pre-build codegen.

zig
fn repeat(comptime n: u32, msg: []const u8) void {
    comptime var i: u32 = 0;              // comptime variable
    inline while (i < n) : (i += 1) {     // unrolled at compile time
        std.debug.print("{s} ", .{msg});
    }
}

const big = 1_000_000_000_000;   // comptime_int: unbounded, exact
const small: u8 = 200;           // coerced to u8 at compile time
  • comptime_int / comptime_float: unbounded, exact literal types — they coerce into concrete types on use
  • if (comptime cond) evaluates now and prunes the untaken branch from the binary
  • a comptime parameter creates a distinct instantiation per value
#071

Generic Functions

ziglings 064–075, 084

type is a comptime value — take it as a parameter and the fn gets monomorphized per call.

A type parameter makes the function generic — T is just a comptime value. Every distinct T produces a fresh monomorphized copy of the body; there is no runtime polymorphism involved.

zig
fn max(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}

const a = max(i32, 3, 9);        // explicit type argument
const b = max(u8, 1, 2);         // each T = a fresh instantiation

fn sum(items: anytype) i64 {     // anytype: inferred param type
    var total: i64 = 0;
    for (items) |x| total += x;
    return total;
}
  • anytype: infer the parameter type from the call site
  • @hasDecl(T, "name") / @hasField(T, "name") — duck-type checks on capabilities
  • constraints are implicit — misuse fails where used; add @compileError for clear messages
#072

Generic Types (type-returning fns)

ziglings 064–075, 084

A function returning type is a generic type; @This() names the struct from inside.

Generic types are functions that return type. The struct declared inside is a fresh type for every T; identical arguments always yield the identical type.

Canonical generic container
fn List(comptime T: type) type {
    return struct {
        items: []T,
        len: usize = 0,

        const Self = @This();    // name this struct from inside

        fn push(self: *Self, item: T) void {
            self.items[self.len] = item;
            self.len += 1;
        }
    };
}

const IntList = List(i32);       // fresh type, monomorphized
  • @This() — the innermost type's own name, for Self-style aliases and methods
  • methods close over the generic parameter — no extra plumbing
  • instantiate like any decl: const IntList = List(i32);
#073

inline for / inline while

ziglings 064–075, 084

Unroll loops at comptime — one stamped-out copy per iteration.

inline for / inline while force unrolling: the body is stamped out once per iteration at compile time. Required whenever each iteration must generate distinct code from comptime values.

zig
const Point = struct { x: u32 = 0, y: u32 = 0 };

fn zeroAll(p: *Point) void {
    inline for (@typeInfo(Point).@"struct".fields) |field| {
        @field(p, field.name) = 0;    // x, then y — unrolled
    }
}
  • field.name is a comptime string, and @field(obj, name) needs a comptime-known name — a runtime loop can't supply either
  • plain loops stay runtime loops when nothing per-iteration is comptime — inline loops bloat binaries
  • inline while unrolls the same way
#074

Type-Creating Builtins (0.16)

since 0.16ziglings 064–075, 084

@Type is gone in 0.16 — construct types with focused builtins instead.

@Type was removed in 0.16 — constructing types now goes through focused builtins, one per kind. Reach for them only when the type cannot be spelled directly.

BuiltinBuildsSketch
@Intinteger type@Int(signedness, bits)
@Floatfloat type@Float(bits)
@Pointerpointer typesize / child / alignment args
@Arrayarray type@Array(len, child)
@VectorSIMD vector@Vector(len, child)
@Tupletuple type@Tuple(&.{ u8, u16 })
@Structstruct typefields arg — see docs
@Unionunion typesee docs
@Enumenum typesee docs
@Opaqueopaque typesee docs
@Optionaloptional type@Optional(u32) → ?u32
@ErrorUnionerror unionset + payload args
@ErrorSeterror setnames arg — see docs
@Fnfunction typesee docs
@EnumLiteralenum literal typethe type of .tag
These shipped with @Type's removal in 0.16 and signatures are still settling — check the std docs for your exact version. For most code, spelling types directly (?u32, [4]u8, *T) beats constructing them.
#075

Metaprogramming Toolkit

ziglings 064–075, 084

Reflect, generate, and fail fast — the everyday comptime toolbox.

Reflection and generation in one toolbox — everything here is evaluated during the build, never at runtime.

ToolWhat it does
@typeInfo(T)reflect — tags like .int, .@"struct", .pointer, .error_union
@field(obj, "name")access a field or decl by comptime name
@hasField(T, "name")does the struct have this field?
@typeName(T)human-readable type name
@sizeOf(T) / @offsetOf(T, "f")layout facts
@call(.auto, f, .{args})call with a comptime-built argument tuple
@compileError("msg")fail compilation with your message
@compileLog(...)print values during compilation — remove before shipping
@setEvalBranchQuota(n)raise the comptime step budget (default 1000)
@embedFile(path)file contents as *const [n:0]u8 at comptime
std.meta.fields(T)field list as a comptime slice
usingnamespaceremoved since 0.15 — no replacement; declare and import explicitly
Comptime type validation
fn checkPairs(comptime T: type) void {
    inline for (@typeInfo(T).@"struct".fields) |f| {
        if (@sizeOf(f.type) == 0) {
            @compileError("zero-sized field: " ++ f.name);
        }
    }
}

const Bad = struct { ok: u32, ghost: u0 };
comptime checkPairs(Bad);   // compile error: zero-sized field: ghost
#076

Duck-Typed Interfaces (no vtables)

ziglings 084

Interfaces are comptime checks — or explicit fn-pointer structs when the type is runtime-chosen.

Compile-time interface check (ziglings 084 pattern)
fn describe(comptime T: type, thing: T) void {
    comptime {
        if (!@hasDecl(T, "describe"))
            @compileError(@typeName(T) ++ " needs a describe() decl");
    }
    thing.describe();          // duck typing, resolved at compile time
}

const Cat = struct {
    fn describe(self: Cat) void {
        std.debug.print("meow\n", .{});
    }
};

When the concrete type is chosen at runtime, comptime duck typing is out. Build a vtable by hand — a struct of function pointers plus a context pointer. That is exactly how std.Io.Writer / std.Io.Reader dispatch since 0.15.

Hand-rolled vtable
const Speaker = struct {
    ctx: *anyopaque,
    speakFn: *const fn (*anyopaque) void,

    fn speak(self: Speaker) void {
        self.speakFn(self.ctx);      // runtime dispatch
    }
};
  • comptime interfaces: zero cost, monomorphized — but the type must be known at compile time
  • vtable interfaces: one copy of code, runtime dispatch — works with runtime-chosen types
  • *anyopaque context + @ptrCast back is the standard state-capture idiom (there are no closures)

Section 14 of 21: Memory & Allocators

5 entries

Explicit allocation: no GC, no hidden malloc.

#077

The Allocator Interface

ziglings 099

Every allocation goes through a passed-in std.mem.Allocator — nothing allocates behind your back.

Zig has no hidden allocations. Anything that needs memory takes a std.mem.Allocator — by convention it is the first parameter. The allocator is a small vtable interface, so the caller picks the strategy (arena, debug, fixed buffer, C, ...).

allocatereleasereturns
create(T)destroy(ptr)!*T
alloc(T, n)free(slice)![]T
allocSentinel(T, n, 0)free(slice)![:n]T
dupe(T, slice) / dupeZ(T, slice)free![]T / ![:0]T
realloc(slice, n)free![]T
remap(slice, n)free?[]T — may move
resize(slice, n)—bool — in-place only

All allocating operations return errors (!) — out of memory is an ordinary error value, never a panic. The caller owns the result and must free it (or hand the job to an arena).

Allocator as first parameter
fn readName(gpa: std.mem.Allocator) ![]u8 {
    const buf = try gpa.alloc(u8, 32);
    defer gpa.free(buf);
    buf[0] = 'z';
    return try gpa.dupe(u8, buf[0..1]); // caller frees this copy
}
#078

ArenaAllocator

Allocate freely, free everything at once — the workhorse for parsers, CLIs and request handling.

An arena hands out allocations from big chunks it grabs from a child allocator. Individual free is not needed (or possible for single items) — everything is released at once by deinit().

Scratch memory for one operation
fn handle(gpa: std.mem.Allocator) ![]u8 {
    var arena = std.heap.ArenaAllocator.init(gpa);
    defer arena.deinit(); // frees everything allocated below
    const a = arena.allocator();

    const tmp = try a.alloc(u8, 64);
    const msg = try std.fmt.allocPrint(a, "parsed {d} tokens", .{3});
    // ... use tmp and msg freely - no per-slice frees ...
    return try gpa.dupe(u8, msg); // only the result escapes
}
  • .init(child) — wrap any allocator; .allocator() — get the interface; .deinit() — free all
  • reset(.retain_capacity) — keep the memory, drop the allocations: reuse the same arena between phases (per frame, per request, per iteration)
  • Ideal for CLIs, parsers, request handling: one arena per unit of work
  • Since 0.16 the arena is thread-safe and lock-free
#079

DebugAllocator (ex-GPA)

since 0.16

GeneralPurposeAllocator was renamed std.heap.DebugAllocator in 0.16 — leak and double-free detection for development builds.

The standard development setup
var gpa: std.heap.DebugAllocator(.{}) = .init(.{});
defer std.debug.assert(gpa.deinit() == .ok); // .ok == no leaks
const allocator = gpa.allocator();

const n = try allocator.create(u32);
n.* = 5;
defer allocator.destroy(n);

GeneralPurposeAllocator was renamed std.heap.DebugAllocator since 0.16. In Debug builds it tracks allocations and reports leaks and double-frees with stack traces; gpa.deinit() returns .ok when nothing leaked.

allocatorreach for it when
std.heap.DebugAllocator(.{})default in development: leak / double-free checks
std.heap.smp_allocatorReleaseFast multi-threaded programs (0.16)
std.heap.ArenaAllocatormany small allocations, freed all at once
std.heap.c_allocatoryour program links libc anyway
std.heap.page_allocatorraw OS pages, no bookkeeping, coarse
std.heap.FixedBufferAllocatorno-heap targets, embedded, bounded scratch
#080

Lifetime Rules

No GC: freed memory stays freed. Ownership is a convention you enforce with discipline and tooling.

  • Freed memory stays freed — every alloc needs exactly one free (or an arena deinit)
  • Use-after-free and double-free are UB; DebugAllocator turns them into clear panics in Debug
  • Slices from an allocator are only valid while the allocator lives — keep it alive as long as the data
  • resize returns bool and only succeeds in place; remap may move — always use the returned slice, old pointers can dangle after a grow/shrink
  • Never free stack memory through an allocator; never return a slice that points into a local buffer
UB in one line:Using a slice after free (or after arena deinit / reset) is undefined behavior. In Debug builds std.heap.DebugAllocator catches it; in ReleaseFast it is silent corruption.
remap may move your memory
const p = try gpa.alloc(u8, 8);
const q = gpa.remap(p, 16) orelse return error.OutOfMemory;
// p may be invalid now - use q from here on
@memset(q, 0);
gpa.free(q);
#081

Patterns & Pitfalls

Arena per request, allocator as first parameter, and what to do about deinit() results.

  • Arena per request: one arena per unit of work, reset(.retain_capacity) between iterations
  • Pass the allocator down: functions take gpa: std.mem.Allocator as first parameter — no global heap
  • Store an allocator in a struct only if the object owns its allocations; prefer taking it per call (unmanaged containers do exactly this since 0.15)
  • _ = gpa.deinit(); — discard the leak-report result when you intentionally leak at process exit
  • Test for leaks with std.testing.allocator — it fails the test on any unfreed memory (see Testing)
Arena inside, allocator passed down
const Parser = struct {
    gpa: std.mem.Allocator,

    fn parse(self: *Parser) !void {
        var arena = std.heap.ArenaAllocator.init(self.gpa);
        defer arena.deinit(); // scratch memory freed in one shot
        const tmp = try arena.allocator().alloc(u8, 64);
        _ = tmp;
        // long-lived results allocate from self.gpa instead
    }
};

Section 15 of 21: Standard Library

8 entries

Formatting, containers, hashing, files, processes — and the 0.15/0.16 API shake-up.

#082

Printing & Writers (0.16)

since 0.16ziglings 002

std.debug.print for quick stderr output; stdout goes through the std.Io writer interface.

std.debug.print("...", .{args}) writes formatted text to stderr, cannot fail, and works in every build mode and target. For stdout you go through the std.Io interface.

stderr vs stdout
// stderr, formatted - the everyday printf:
std.debug.print("x={d} name={s}\n", .{ x, name });

// stdout, raw bytes (0.16, from "Juicy main"):
try std.Io.File.stdout().writeStreamingAll(init.io, "hello\n");

// buffered formatted stdout (0.15+ shape):
var buffer: [1024]u8 = undefined;
var w = std.Io.File.stdout().writer(&buffer);
try w.interface.print("count={d}\n", .{ n });
try w.interface.flush();

The 0.15/0.16 writer signatures are still settling — see the std docs for your exact version.

specifiermeaning
{s}string / bytes
{d}int or float, decimal
{c}character
{x} / {X}hexadecimal, lowercase / uppercase
{b}binary
{o}octal
{e}scientific notation
{?}optional of a formattable value
{any}anything (debug formatting)
{f}call the type's own .format method (since 0.15)
{{ / }}literal braces
{d:0>4}width 4, fill 0, align right
{s:^10}width 10, centered
  • Arguments are passed as an anonymous tuple: .{ x, name }
  • std.debug.print returns void (best-effort, no error handling needed)
  • Writer-based printing returns errors — try it and flush() at the end
#083

Building Strings

ziglings 106

allocPrint for one-shots, std.mem.concat for joins, Writer.Allocating for incremental building.

One-shot and concat
const s = try std.fmt.allocPrint(gpa, "a={d} b={s}", .{ a, b });
defer gpa.free(s);

const joined = try std.mem.concat(gpa, u8, &.{ "foo", "bar" });
defer gpa.free(joined);
Incremental: Writer.Allocating (0.16)
var aw: std.Io.Writer.Allocating = .init(gpa);
defer aw.deinit();
try aw.writer.print("n={d}\n", .{ n }); // .writer is a FIELD, not a method
const owned = try aw.toOwnedSlice(); // take over the buffer

aw.written() gives a borrowed view of what was written so far; toOwnedSlice() transfers ownership instead.

Rule of thumb: one formatted string → allocPrint; appending in a loop → Writer.Allocating or an ArrayList(u8) (see ArrayList).

#084

ArrayList

since 0.15ziglings 102

Growable array: unmanaged since 0.15 (allocator per call), .empty initialization since 0.16.

std.ArrayList(T) is a growable array. Since 0.15 only the unmanaged form exists — the allocator is passed to each method. Since 0.16 you initialize with .empty.

0.16 style
var list: std.ArrayList(u32) = .empty;
defer list.deinit(gpa);
try list.append(gpa, 1);
try list.appendSlice(gpa, &.{ 2, 3 });
list.items[0] = 9;
const owned = try list.toOwnedSlice(gpa);
methodnotes
append(gpa, v) / appendSlice(gpa, s)add at the end, !void
insert(gpa, i, v) / insertSlice(gpa, i, s)shift right, !void
pop()returns ?T since 0.15 — null when empty
orderedRemove(i)returns T, keeps order, O(n)
swapRemove(i)returns T, swaps in last element, O(1)
items / items.lenthe underlying slice / element count
clearRetainingCapacity()len = 0, memory kept for reuse
resize(gpa, n) / ensureTotalCapacity(gpa, n)grow (!void)
toOwnedSlice(gpa)hand over the exact-sized buffer
deinit(gpa)free the buffer
Do not hold *T pointers into list.items across append — growth may reallocate and invalidate them (see Pointer Pitfalls).
#085

HashMaps

AutoHashMap / StringHashMap (hashed) and the ArrayHashMap family (insertion order preserved).

std.AutoHashMap(K, V) derives hashing for any hashable K (ints, packed structs, ...); std.StringHashMap(V) hashes []const u8 keys. ArrayHashMap variants additionally keep insertion order.

Managed map (allocator stored)
var map: std.StringHashMap(u32) = .init(gpa);
defer map.deinit();
try map.put("a", 1);
if (map.get("a")) |n| std.debug.print("a={d}\n", .{ n }); // ?u32
if (map.getPtr("a")) |ptr| ptr.* += 1; // mutate in place
var it = map.iterator();
while (it.next()) |e|
    std.debug.print("{s}={d}\n", .{ e.key_ptr.*, e.value_ptr.* });
typenotes
std.AutoHashMap(K, V)auto-hashed keys
std.StringHashMap(V)[]const u8 keys
std.AutoArrayHashMap(K, V)+ insertion order, .keys() / .values()
std.StringArrayHashMap(V)string keys + order
std.array_hash_map.Auto / .String / .Customsince 0.16: ArrayHashMap family moved here (managed removed)
  • *Unmanaged variants take the allocator per call (like all containers since 0.15)
  • get returns ?V; getPtr returns ?*V for in-place mutation; getOrPut inserts-if-absent
  • Iterators yield key_ptr / value_ptr — dereference with .*
  • ArrayHashMap variants preserve insertion order; plain HashMap does not
#086

Sorting & Searching

std.mem.sort (stable block sort) and std.mem.sortUnstable (pdq) with a context-first comparator.

Sort structs by a field, descending
const Score = struct { name: []const u8, score: u32 };

fn byScoreDesc(_: void, a: Score, b: Score) bool {
    return a.score > b.score; // context comes FIRST (0.15+)
}

fn demo(items: []Score) void {
    std.mem.sort(Score, items, {}, byScoreDesc); // stable (block sort)
    std.mem.sortUnstable(Score, items, {}, byScoreDesc); // pdq, faster
}
functionnotes
std.mem.sort(T, items, ctx, lessThan)stable, in-place, O(n log n)
std.mem.sortUnstable(...)pdq sort, not stable, usually faster
std.sort.binarySearch(T, items, ctx, compareFn)requires sorted items — compare-callback shape varies, see std docs for your version

lessThan has signature fn (ctx, lhs, rhs) bool — return lhs < rhs for ascending order. Return > for descending as above.

#087

std.mem Helpers

ziglings 109–110

Compare, trim, split, find — with the 0.16 renames (trimStart, find*, new cut*).

helpernotes
eql / eqlIgnoreCaseslice equality
startsWith / endsWithprefix / suffix test
trim / trimStart / trimEndcut chars from both/start/end (renamed from trimLeft/trimRight since 0.16)
find / findLastindexOf* renamed since 0.16: indexOf → find, lastIndexOf → findLast
cut*split at the first match — new helpers since 0.16
splitScalar / splitSequence / tokenizeScalarlazy iterator over parts
replaceOwned / joinbuild new strings (allocate)
min / maxelement-wise min/max of slices
zeroes(T)a zeroed T value (comptime-known)
Everyday std.mem + std.fmt
const t = std.mem.trim(u8, "  hi \t", " \t"); // "hi"
if (std.mem.startsWith(u8, t, "hi")) { /* ... */ }

const n = try std.fmt.parseInt(u32, "4040", 10); // !u32
const f = try std.fmt.parseFloat(f64, "3.5"); // !f64

splitScalar/tokenizeScalar return iterators — while (it.next()) |part| { ... }. tokenize skips empty parts, split yields them.

#088

Files & Dirs (std.Io)

since 0.16

Since 0.16 the file system lives behind the std.Io interface — every operation takes an io handle.

Since 0.16 the file system is part of the std.Io interface: std.Io.Dir and std.Io.File operations take an io handle (obtained from main(init)).

Read and write a file
// read, bounded to 1 MiB:
const bytes = try std.Io.Dir.cwd().readFileAlloc(
    io, "input.txt", gpa, .limited(1 << 20),
);
defer gpa.free(bytes);

// write:
var f = try std.Io.Dir.cwd().createFile(io, "out.txt", .{});
defer f.close(io);
try f.writeStreamingAll(io, "hi\n");

0.16 API — exact signatures may shift in patch releases; consult the std docs for your version.

  • std.Io.Dir.cwd() — current directory handle; openDir(io, path, .{}) for others
  • Directory iteration and recursive walk (selective walking since 0.16)
  • Preopens (WASI-style, since 0.16): directories are capabilities handed to the program
  • std.Io.File.stdout() / .stderr() — standard streams
#089

Process, Args, Env, Time & Random (0.16)

since 0.16

All process-level services are non-global since 0.16 — they arrive via main(init: std.process.Init).

Since 0.16 args, environ, spawning, time and randomness are non-global: they are capabilities passed to pub fn main(init: std.process.Init) ("Juicy main"). An empty pub fn main() is still legal.

Args and environ from init
pub fn main(init: std.process.Init) !void {
    const args = try init.minimal.args.toSlice(init.arena.allocator());
    const user = init.environ_map.get("USER") orelse "nobody";
    std.debug.print("{s}: {d} args\n", .{ user, args.len });
}
Running a child process
const result = try std.process.run(gpa, io, .{ .argv = &.{ "git", "status" } });
std.debug.print("{s}", .{result.stdout});
service0.16 access
argsinit.minimal.args.toSlice(init.arena.allocator())
environmentinit.environ_map.get("KEY") (.keys() / .values())
childrenstd.process.run(gpa, io, .{ .argv = ... })
timestd.Io.Clock / Timestamp / Duration types — Duration formats with {f}
randomnessmoved to the std.Io interface since 0.16 — see the io docs

For args+environ only, std.process.Init.Minimal is the lightweight variant of the init struct.

Section 16 of 21: Testing

4 entries

Built-in test framework: no dependencies, leak detection by default, fuzzing in the toolchain.

#090

Test Blocks

ziglings 105

Any test "..." { ... } block runs under zig test — assertions come from std.testing.

A test next to the code it tests
const std = @import("std");
const testing = std.testing;

fn add(a: i32, b: i32) i32 {
    return a + b;
}

test "add works" {
    try testing.expect(add(2, 2) == 4);
    try testing.expectEqual(@as(i32, 4), add(2, 2));
    try testing.expectFmt("4", "{d}", .{add(2, 2)});
}

Tests live next to the code — in the same file, or in a separate test file that imports it. zig test math.zig finds and runs every test block. Assertions return !void, so prefix them with try.

helperchecks
expect(cond)condition is true
expectEqual(expected, actual)deep equality (structs, arrays)
expectEqualStrings(a, b)string content + length
expectEqualSlices(T, a, b)element-wise slice equality
expectError(err, expr)expr fails with exactly err
expectApproxEqAbs / Relativefloat comparison with tolerance
expectFmt(expected, fmt, args)value formats to expected text

Note that unreferenced functions are not analyzed — a test that never calls f will not type-check f's body. Use refAllDecls (next entry).

#091

Running & Organizing

zig test for files, zig build test for projects, refAllDecls to force analysis.

A test root that pulls in everything
// src/all.zig
test {
    std.testing.refAllDecls(@This()); // force analysis of all decls
    _ = @import("parser.zig"); // and other files' tests
    _ = @import("json.zig");
}
  • zig test math.zig — run one file's tests
  • zig test src/all.zig — run a project via an aggregating root file
  • zig build test — through a test step in build.zig (see Test Step)
  • zig test --test-filter "json" src/all.zig — only matching test names
  • Name tests descriptively: test "json: parses empty object"

On failure the default runner prints the expression, expected vs actual, and a stack trace. Add --summary all (with zig build) for a per-step breakdown.

#092

Allocator-Aware Tests

ziglings 105

std.testing.allocator detects leaks per test — a leak fails the test with a stack trace.

A leak fails the test
test "leaks fail the test" {
    const a = std.testing.allocator;
    const buf = try a.alloc(u8, 16);
    _ = buf;
    // oops: no "defer a.free(buf);"
    // -> FAILS with a leak report (address + stack trace)
}
Arena for scratch data in tests
test "arena for scratch" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const a = arena.allocator();
    const s = try std.fmt.allocPrint(a, "{d}", .{42});
    try std.testing.expectEqualStrings("42", s);
}

std.testing.allocator is a leak-checking wrapper around DebugAllocator. expectEqual compares deeply for structs, unions and arrays — no per-field assertions needed.

#093

Fuzzing

Fuzz targets are ordinary tests — zig build test --fuzz runs them coverage-guided.

A fuzz target (see std.testing.fuzz for your version)
test "parser never crashes on garbage" {
    const ctx = {};
    std.testing.fuzz(ctx, struct {
        fn f(_: @TypeOf(ctx), input: []const u8) void {
            _ = parse(input) catch {}; // any crash = fuzz failure
        }
    }.f, .{});
}

The std.testing.fuzz API is still evolving — check the std docs for your version.

  • zig build test --fuzz — coverage-guided fuzzing (since 0.14)
  • Fuzz targets are normal test blocks; safe build modes turn memory errors into catchable crashes
  • Run it locally against your parser / decoder / any []const u8 consumer
  • 0.16 fuzzing improvements include:
  • AST smith — grammar-aware structured input generation
  • multiprocess fuzzing
  • infinite mode
  • crash dumps for reproduction

Section 17 of 21: Build System

5 entries

build.zig is Zig: declarative steps, cross-compilation and package management built in.

#094

Minimal build.zig (0.16)

since 0.16

Target + optimize + one executable, installed with zig build, run with zig build run.

build.zig
const std = @import("std");

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

    const exe = b.addExecutable(.{
        .name = "app",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });
    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

Modern style (0.15+) nests .root_source_file, .target and .optimize inside root_module = b.createModule(...); setting them directly on the executable options is the legacy shape.

  • zig build — default step: compile and install to zig-out/bin
  • zig build run — run the run step defined above
  • --release=fast — optimized build without editing anything
#095

Steps, Options & Artifacts

Custom -D options, named steps, and the artifact kinds the build graph understands.

Options and steps
const level = b.option(u32, "level", "compression level 0-9") orelse 6;
const target = b.standardTargetOptions(.{}); // -Dtarget, -Dcpu
const optimize = b.standardOptimizeOption(.{}); // --release=...
const t = b.step("test", "Run tests"); // zig build test
invocationeffect
zig builddefault step: install artifacts
zig build runrun a custom step
zig build test --summary alltest step + full failure summary
--release=safe|fast|smalloptimize mode (Debug if unset)
-Doptimize=ReleaseSafesame, via the standard option
-Dtarget=x86_64-windows-gnucross-compile target triple
-Dlevel=9your own b.option values
--prefix ./outinstall directory instead of zig-out
  • Artifacts: b.addExecutable(...) / b.addLibrary(...) / b.addObject(...)
  • b.installArtifact(artifact) attaches it to the default install step
  • b.addRunArtifact(...) runs executables and tests as a build step
  • b.default_step — what plain zig build runs
#096

Modules & Dependencies

Wire multi-file projects with createModule + imports; fetch packages via build.zig.zon.

Two modules, one import
const lib_mod = b.createModule(.{
    .root_source_file = b.path("src/lib.zig"),
    .target = target,
    .optimize = optimize,
});
const exe_mod = b.createModule(.{
    .root_source_file = b.path("src/main.zig"),
    .target = target,
    .optimize = optimize,
    .imports = &.{
        .{ .name = "lib", .module = lib_mod },
    },
});
// in main.zig:  const lib = @import("lib");
Package dependency from build.zig.zon
// build.zig.zon:
//   .dependencies = .{ .lib = .{ .url = "https://...", .hash = "..." } },
const dep = b.dependency("lib", .{});
exe.root_module.addImport("lib", dep.module("lib"));
// add deps with:  zig fetch --save <url-or-tarball>
zon fieldnotes
.nameenum literal since 0.14: .my_project
.versionsemver string, e.g. "0.1.0"
.fingerprintpackage identity hash (0.14+)
.minimum_zig_versionoldest Zig that can build this
.dependenciesmap of { .url, .hash } (or local path) deps
.pathsfiles/dirs included when the package is used
#097

Linking & C Files

linkLibC, system libraries, include paths, .c sources — and translate-c for headers.

Mixing C into a Zig build
exe.linkLibC(); // -lc
exe.linkSystemLibrary("sdl2"); // -lSDL2
exe.addIncludePath(b.path("include")); // -I
exe.addCSourceFiles(.{
    .files = &.{ "vendor/foo.c", "vendor/bar.c" },
    .flags = &.{},
});
translate-c for headers (0.16)
const tc = b.addTranslateC(.{
    .root_source_file = b.path("src/bindings.h"),
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("c", tc.createModule());

@cImport is deprecated since 0.16 — this build-based flow replaces it (see C Interop).

#098

Test Step

The standard zig build test recipe: addTest + addRunArtifact + a named step.

Standard test step
const tests = b.addTest(.{ .root_module = mod });
const run_tests = b.addRunArtifact(tests);
const t = b.step("test", "Run unit tests");
t.dependOn(&run_tests.step);
  • addTest analyzes every test block reachable from the module root
  • Force analysis of leaf decls with std.testing.refAllDecls in your code (see Testing)
  • Run: zig build test; add --summary all for details
  • zig build test --fuzz — fuzz mode (see Fuzzing)
  • Unit-test timeouts are available as an option since 0.16

The test step depends on the same module graph as your exe — tests compile with the target and optimize settings of the module they test.

Section 18 of 21: C Interop

5 entries

Call C, export Zig, translate headers, and cross-compile C itself — no glue layer needed.

#099

C Types & [*c] Pointers

ziglings 096–097

c_int and friends for C-width integers; [*c]T for nullable C pointers of unknown length.

Zig typeC equivalent
c_char, c_short, c_int, c_long, c_longlongchar, short, int, long, long long
c_uint, c_ushort, c_ulong, c_ulonglongunsigned variants
c_longdoublelong double
boolbool (C99 _Bool)
usizesize_t
f32 / f64float / double (fixed width)

[*c]T is the C pointer: nullable, allowzero, coerces in both directions with [*]T, [*:0]T and ?[*]T, and has unknown length (no indexing, no .len). A null check works through optional coercion: if (cptr) |p| ....

C pointers in practice
extern fn strlen(s: [*c]const u8) usize;

fn safeLen(s: [*c]const u8) usize {
    if (s) |p| return strlen(p); // null check via optional coercion
    return 0;
}

// sentinel-terminated C string -> slice:
//   const slice: []const u8 = std.mem.span(cstr);

std.mem.span(cstr) converts a [*:0]const u8 into a []const u8 by counting to the sentinel — the bridge between C strings and Zig slices.

#100

Calling C Functions

ziglings 096–097

Declare extern fns yourself, link libc, done — headers optional when you import a translate-c module.

extern declarations
extern "c" fn puts(s: [*:0]const u8) c_int;
extern fn abs(x: c_int) c_int; // "c" implied when linking libc
extern "c" fn printf(fmt: [*:0]const u8, ...) c_int; // variadic

pub fn main() void {
    _ = puts("hello from zig");
    _ = abs(-42);
    _ = printf("n=%d\n", @as(c_int, 7));
}
  • Library name optional: extern fn ... defaults to the C ABI of the linked libc
  • Variadic: ... in the parameter list, like C
  • Functions passed into C use the C calling convention: fn f() callconv(.c) void
Linking
// build.zig:
exe.linkLibC();
exe.linkSystemLibrary("m"); // libm, SDL2, ... anything pkg-config finds

// command line:
//   zig build-exe main.zig -lc
#101

Exporting Zig for C

export fn emits C-ABI symbols; extern struct/union/enum fix the ABI layout; callbacks via callconv(.c).

Exported functions and symbol control
export fn add(a: c_int, b: c_int) c_int {
    return a + b; // symbol "add" lands in the object file
}

fn mul(a: c_int, b: c_int) callconv(.c) c_int {
    return a * b;
}

comptime {
    @export(&mul, .{ .name = "my_mul" }); // custom symbol name
}

extern struct, extern union and extern enum(c_int) guarantee C ABI field layout — use them for types shared across the boundary. Plain Zig structs may reorder/pad differently.

Passing a Zig callback into C
const Callback = *const fn (ctx: ?*anyopaque, n: c_int) callconv(.c) void;
extern "c" fn register(cb: Callback) void;

fn onEvent(ctx: ?*anyopaque, n: c_int) callconv(.c) void {
    _ = ctx;
    std.debug.print("event {d}\n", .{n});
}

pub fn init() void {
    register(onEvent);
}
  • zig build-lib lib.zig — static library
  • zig build-lib -dynamic lib.zig — shared library
  • The C header for your exports must be written by hand
#102

Headers: translate-c (0.16)

since 0.16

@cImport is deprecated — translate headers in build.zig and import them as a module.

0.15 and earlier — @cImport (deprecated)
const c = @cImport({
    @cInclude("sqlite3.h");
});

const rc = c.sqlite3_open(":memory:", &db);
0.16 — build-based translate-c
// build.zig:
const tc = b.addTranslateC(.{
    .root_source_file = b.path("vendor/sqlite3.h"),
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("c", tc.createModule());

// main.zig:
// const c = @import("c");

Same result — but the 0.16 way is explicit in the build graph and caches per header.

  • One-shot CLI: zig translate-c vendor/sqlite3.h > bindings.zig, then @import("bindings.zig")
  • Include paths and defines can be given on the translate-c step
  • Vendoring the generated bindings.zig avoids a build step entirely
@cImport still compiles in 0.16 but is deprecated — migrate new code to b.addTranslateC now.
#103

zig cc — C/C++ Toolchain

Zig ships a full clang-based C/C++ compiler with cross-compilation and bundled libc targets.

sh
zig cc main.c -o main              # drop-in clang/gcc replacement
zig cc -target aarch64-linux-gnu main.c -o main  # instant cross-compile
zig c++ main.cpp -o main           # C++ too
  • libc headers and libraries are bundled: musl, glibc, mingw-w64 — no system toolchain required
  • Same target-triple syntax as zig build -Dtarget=...
  • Ideal for building C dependencies and Makefile-free builds
  • Flags are clang-compatible: -I, -L, -l, -static, ...
sh
# build a C library once, for two targets, no Docker:
zig cc -c -Iinclude vendor/foo.c -target x86_64-linux-gnu -o foo-linux.o
zig cc -c -Iinclude vendor/foo.c -target aarch64-macos-gnu -o foo-mac.o

Section 19 of 21: Concurrency

5 entries

Threads, locks and atomics — plus the 0.16 std.Io interface that replaced async/await.

#104

Threads

ziglings 107–108

std.Thread.spawn with any function and argument tuple; join or detach; shared state is your job.

Spawn + join with a context struct
const Job = struct { n: u32, out: u32 = 0 };

fn worker(job: *Job) void {
    job.out = job.n * 2;
}

pub fn main() !void {
    var job: Job = .{ .n = 21 };
    const t = try std.Thread.spawn(.{}, worker, .{&job});
    t.join(); // block until done (or: t.detach())
    std.debug.print("{d}\n", .{job.out}); // 42
}
operationnotes
std.Thread.spawn(.{}, f, .{args})returns !Thread — options struct first
t.join()block until the thread finishes
t.detach()let it run free; never join it
std.Thread.current()handle for the calling thread
std.Thread.getCpuCount()!usize — hardware parallelism
Data races are UB. Sharing mutable state across threads requires synchronization (locks) or atomics — the compiler will not stop you, so route anything shared through one of the next two entries.
#105

Mutex, Condition & Friends

The std.Thread synchronization primitives: Mutex, Condition, ResetEvent, Semaphore, RwLock.

primitiveoperations
std.Thread.Mutexlock / unlock / tryLock; shared mode: lockShared / unlockShared
std.Thread.Conditionwait(&mutex) / signal / broadcast / timedWait
std.Thread.ResetEventwait / set / reset / isSet
std.Thread.Semaphorewait / post
std.Thread.RwLocklock / unlock / lockShared / unlockShared
Guard pattern: mutex next to its data
const Counter = struct {
    mu: std.Thread.Mutex = .{},
    n: u32 = 0,

    fn bump(self: *Counter) void {
        self.mu.lock();
        defer self.mu.unlock(); // always unlock, even on early return
        self.n += 1; // protected critical section
    }
};
ResetEvent: one-shot done signal
var done: std.Thread.ResetEvent = .{};

fn work() void {
    defer done.set();
    // ... do the work ...
}

// another thread:
done.wait(); // blocks until set()
#106

Atomics

std.atomic.Value(T) wraps one value with explicit memory ordering; raw @atomic* builtins below it.

Stop flag across threads
var stop: std.atomic.Value(bool) = .init(false);

fn worker() void {
    while (!stop.load(.acquire)) {
        // ... work chunk ...
    }
}

// main thread:
stop.store(true, .release); // signal shutdown
std.atomic.Value(T) opcall
createstd.atomic.Value(T).init(v)
read.load(.acquire)
write.store(v, .release)
exchange.swap(v, .seq_cst)
read-modify-write.rmw(.Add, x, .seq_cst)
raw builtinpurpose
@atomicLoad(T, ptr, order)atomic read
@atomicStore(T, ptr, v, order)atomic write
@atomicRmw(T, ptr, op, v, order)atomic read-modify-write
@cmpxchgStrong / @cmpxchgWeakcompare-and-swap (weak may spuriously fail)
@fence(order)standalone memory barrier
  • Memory orders: .monotonic (relaxed), .acquire, .release, .acq_rel, .seq_cst
  • Default to .seq_cst; relax only with profiling evidence
  • .monotonic for counters, .acquire/.release pairing for flag hand-offs
#107

std.Io: The Async Story (0.16)

since 0.16ziglings 085–095

async/await keywords are gone — concurrency is an interface (std.Io) with pluggable implementations.

History callout:The async / await / suspend / resume keywords were removed since 0.14. Since 0.16, I/O concurrency is expressed through the std.Io interface instead — an io handle flows through your program.
abstractionrole
std.Io.Futureone task started from a function; await its result
std.Io.Groupmany tasks; await or cancel all of them
std.Io.Queue(T)MPMC channel between tasks
std.Io.Batchbatch many operations, submit together
std.Io.Selectwait on several operations at once
implementationnotes
std.Io.Threadeddefault — operations block on a thread pool
std.Io.Eventedexperimental green threads
std.Io.Uring / Kqueue / Dispatchproof-of-concept backends
std.Io.failingalways fails — tests, no-IO builds
Illustrative shape (simplified)
// SIMPLIFIED - the 0.16 std.Io API is evolving:
// consult the std docs for your exact version.
const fut = try io.async(work, .{ arg }); // start a task
const out = try fut.await(io); // join it

// many tasks: std.Io.Group
//   g.async(io, work, .{...}) for each, then g.await(io) / g.cancel(io)
  • -fsingle-threaded — compile assuming one thread (enables optimizations, disables thread APIs)
  • -fno-single-threaded — force the opposite
  • Which backend runs is configured per root module — see the std.Io docs for your version
#108

Choosing a Tool

CPU parallelism → threads; I/O concurrency → std.Io; signaling → events; counters → atomics.

you needreach for
CPU parallelismstd.Thread (+ locks / atomics)
I/O concurrencystd.Io — Future / Group / Queue(T)
simple signalingResetEvent or Condition
counters / stop flagsstd.atomic.Value(T)
per-task scratch memoryone ArenaAllocator per task

Start with std.Thread for CPU-bound work. std.Io.Threaded gives I/O concurrency without changing your threading model; std.Io.Evented scales to many concurrent I/O operations on few threads.

Ziglings exercises 085–095 teach the removed async/await keywords — treat them as historical. Ziglings exercises for the new std.Io interface are still pending.

Section 20 of 21: Gotchas & Tips

7 entries

Where Zig bites: UB, undefined, version churn — and the philosophy behind the rules.

#109

Safety Checks vs UB

Debug and ReleaseSafe panic with a trace; ReleaseFast turns failed checks into UB; ReleaseSmall mostly drops them.

checkDebug / ReleaseSafeReleaseFastReleaseSmall
array/slice boundspanic + traceUBoff
integer overflowpanic + traceUBwrapping (off)
.? on nullpanic + traceUBoff
unreachable reachedpanic + traceUBoff
division by zeropanic + traceUBoff
cast truncation (@intCast)panic + traceUBoff

Rule: develop and test in Debug or ReleaseSafe, ship ReleaseFast only with solid tests and coverage. ReleaseSmall trades most safety checks for binary size (overflow wraps silently).

  • unreachable is a promise to the optimizer — keep it true in every mode
  • @intCast and .? are checked in safe modes, unchecked elsewhere
  • Out-of-range @intFromFloat is UB — range-check before converting
#110

undefined & Uninitialized

undefined means "no value" — reading it is UB in every build mode (0xAA fill makes it visible in Debug).

Declare now, initialize before use
var buf: [64]u8 = undefined; // declared, not initialized
@memset(&buf, 0); // MUST write before reading

const x: u32 = undefined;
_ = x + 1; // UB: reads undefined memory (0xAA fill in Debug)
Reading undefined is UB in every mode. Debug fills with 0xAA so the damage shows up as crashes or absurd values instead of silent luck.
  • Defer-init pattern: var v: T = undefined; then assign all fields before first read
  • Struct literals: initialize every field or give fields defaults — no partial init
  • @memset(ptr, 0) bulk-initializes a buffer
  • Legit uses: buffers a function will fully overwrite, or values the OS/writer fills before you read
#111

Pointer Pitfalls

Dangling after growth, misaligned casts, and the four pointer spellings people mix up.

Invalidation after growth
var list: std.ArrayList(u32) = .empty;
defer list.deinit(gpa);
try list.append(gpa, 1);
const first: *u32 = &list.items[0];
try list.append(gpa, 2); // may reallocate items
// 'first' may now DANGLE - retake &list.items[0]
  • Alignment: casting to a stricter alignment via @ptrCast needs @alignCast — misaligned access is UB
  • Use-after-free: reading after free (see Lifetime Rules)
  • []T (len known) vs [*]T (many, unknown len) vs [*c]T (C) vs *T (exactly one) — coerce deliberately
  • Capture semantics: for (items) |item| gives a copy; |*item| gives *T so writes stick
Copy vs by-reference capture
for (items) |item| {} // item is a copy - writes are lost
for (items) |*item| { // item: *T - mutations persist
    item.* += 1;
}
#112

Language Surprises

Small rules that trip up newcomers: comptime_int locals, bool-only conditions, mandatory else, no shadowing.

  • ! negates a bool; ~ is bitwise-not for integers — there is no ~ for bools
  • No x++ / x--: write x += 1
  • if conditions must be bool — no truthy integers or pointers
  • var x = 1; is a compile error at runtime scope: the literal is comptime_int, so annotate the type
  • Indexing takes usize — cast with @intCast (or @as) first
  • Shadowing is forbidden: an inner x cannot hide an outer x
  • Unused variables, parameters and errors are compile errors — discard with _ = x;
  • Blocks are expressions: an if used as a value requires else
  • switch must be exhaustive (add else => to cover the rest)
comptime_int strikes
var x = 1; // ERROR: '1' is comptime_int, runtime vars need a type
var y: i32 = 1; // OK
Value-context if requires else
const maybe: ?u32 = null;
const v = if (maybe) |n| n else 0; // else is mandatory here
const s = switch (maybe) { .some => 1, .none => 0 }; // exhaustive too
#113

Version Migration Traps (0.15 → 0.16)

since 0.16

The rename-and-remove list that will break older code: containers, mem helpers, IO, @cImport, @Type.

0.15 and earlier0.16
var l = ArrayList(T).init(gpa)var l: ArrayList(T) = .empty + allocator per call
std.heap.GeneralPurposeAllocatorstd.heap.DebugAllocator
std.mem.trimLeft / trimRighttrimStart / trimEnd
std.mem.indexOf*std.mem.find* (indexOf → find, lastIndexOf → findLast)
@cImport({ @cInclude(...) })b.addTranslateC + module import
@Type(...)@Int, @Float, @Array, @Pointer, ... type builders
std.process.argsAlloc()init.minimal.args.toSlice(init.arena.allocator())
std.fs.cwd() / std.fsstd.Io.Dir / std.Io.File with an io handle
aw.writer() method callaw.writer field access
std.Thread.Poolremoved — use std.Io
managed ArrayHashMapstd.array_hash_map.Auto / .String / .Custom
global args / envmain(init) only — init.minimal.args, init.environ_map
Writer.Allocating.writer is a field, not a method — aw.writer() is a compile error; write aw.writer.print(...). Full details: the 0.16.0 release notes.
#114

The Zen of Zig

Six lines that explain most design decisions — and most of the gotchas above.

  • Communicate intent precisely.
  • Edge cases matter.
  • Only one way to do things.
  • No hidden control flow.
  • No hidden allocations.
  • Together, we serve the users.

The design enforces it: allocation is explicit (the Allocator parameter), control flow is visible (errors are values, no exceptions, no operator overloading) — which is exactly why the gotchas in this section exist.

#115

Reading Compiler Errors

Zig errors explain coercions, offer help: hints, and can be produced without emitting a binary.

  • Errors include coercion explanations ("expected type X, found Y") — read top to bottom
  • help: lines usually contain the fix
  • Reference traces show where each mismatched type came from
  • Typecheck only: zig build-exe main.zig -fno-emit-bin
  • Step-level detail for build failures: zig build --summary all
  • error: unused ... → discard with _ = x; (or name it _)
  • "expected type" mismatches → check the casting table (see Quick Reference)
The discard fix
fn f(gpa: std.mem.Allocator) !void {
    const s = try gpa.alloc(u8, 8); // error: unused local variable 's'
    _ = s; // fix: explicit discard
}

Zig's error messages are unusually pedagogical — when the compiler complains, the explanation is usually longer than the fix.

Section 21 of 21: Quick Reference

5 entries

Print-and-pin tables: operators, literals, builtins, casts, and where to read more.

#116

Operators & Precedence

All operators, grouped highest to lowest precedence, with the wrap/saturate variants.

precedenceoperatorsmeaning
1 (highest)x.* x.? x[i] x.f f() @b()postfix: deref, unwrap, index, field, call
2! - ~ &xunary: not, negate, bit-not, address-of
3* / % ** *% *|multiply, divide, remainder, power, wrap-mul, sat-mul
4+ - ++ +| +% -| -%add, subtract, concat, sat-add, wrap-add, sat-sub, wrap-sub
5<< >> <<|shift left/right, saturating shift left
6& ^ |bitwise and, xor, or
7== != < > <= >=comparison
8and orlogical, short-circuit
9 (lowest)= += -= *= /= %= *= **= *%= <<= >>= &= |= ^= +|= +%= -|= -%= <<|=assignment (a statement, not an expression)
  • ++ concatenates arrays and slices (comptime-known result for arrays)
  • +% / -% / *% wrap on overflow; +| / -| / *| saturate; <<| saturates the shift
  • and / or short-circuit and require bool operands
  • No operator overloading anywhere — operators always mean bit/math operations
#117

Literal Syntax

Every literal form: integer bases, chars, floats, strings, enum/struct/error literals, ranges.

kindexamples
integers42 0x2A 0o52 0b101010 1_000_000 (separators)
characters'a' '\n' '\x1b' '\u{263A}'
floats3.14 1e9 0x1.8p3 (hex float)
strings"hi\n" "\xAF" (byte) multiline with \\
enum literal.tag
struct / tuple literal.{ .x = 1 } .{ 1, 2 }
errorserror.OutOfMemory error.Set{ .A, .B }
switch ranges'a'...'z' 1...9 (three dots)
anon list → array.{ 1, 2, 3 } coerces to [3]u8 (anonymous list, since 0.15)
Literals in one glance
const hex: u32 = 0xFF_00;
const nl: u8 = '\n';
const f: f64 = 0x1.8p3; // 12.0
const s: []const u8 = "hi\n" ++ "there";
const multi =
    \\line one
    \\line two
;

.{ ... } is the anonymous literal — it becomes a struct (.{ .x = 1 }), a tuple (.{ 1, 2 }), or an array/tuple depending on the target type.

#118

Builtin Functions Index

The @-functions grouped by purpose — introspection, casts, math, SIMD, compile-time, runtime.

groupbuiltins
Introspection@typeInfo @typeName @TypeOf @sizeOf @alignOf @bitSizeOf @offsetOf @This @field @hasDecl @hasField @extern
Casts@intCast @truncate @floatCast @floatFromInt @intFromFloat @enumFromInt @intFromEnum @bitCast @ptrCast @alignCast @constCast @volatileCast @addrSpaceCast
Math@min @max @abs @sqrt @sin @cos @tan @exp @log @log2 @log10 @floor @ceil @round @trunc @mod @rem @divTrunc @divFloor @divExact @mulAdd @popCount @clz @ctz @byteSwap @bitReverse
Overflow-aware@addWithOverflow @subWithOverflow @mulWithOverflow @shlWithOverflow
SIMD@splat @reduce @select @shuffle @mulAdd
Compile-time@compileError @compileLog @setEvalBranchQuota @setRuntimeSafety @embedFile @import @src @call @export
Runtime@panic @breakpoint @fence @errorName @errorReturnTrace @returnAddress @frameAddress @prefetch
Pointers@ptrFromInt @intFromPtr
Type creators (0.16)@Int @Float @Array @Pointer @Struct @Union @Enum @Tuple @Opaque @Optional @ErrorUnion @ErrorSet @Fn @EnumLiteral

@Type was removed in 0.16 and replaced by the individual type creators in the last row. Full signatures live in the std docs; every builtin is documented in the language reference.

#119

Casting Cheat Table

Which builtin converts what — and where the safe-mode checks kick in.

from → tobuiltinnotes
int → smaller int@intCastpanics on truncation in safe modes
int → bigger intcoercion or @intCastimplicit up-cast just works
int → float@floatFromInt—
float → int@intFromFloattruncates; out-of-range is UB
float → smaller float@floatCastmay lose precision
enum → int@intFromEnum—
int → enum@enumFromIntno validation — can create invalid tags
ptr → ptr@ptrCast (+ @alignCast)keep the alignment legal
bits reinterpret@bitCastsource and target must have equal size
[]T → []const Tcoercionimplicit, free
?T → T.?panics on null in safe modes
E!T → Ttry / catcherror handling, not a cast builtin
The big four
const big: u64 = 300;
const small: i32 = @intCast(big); // panics if it doesn't fit (safe modes)
const approx: f64 = @floatFromInt(big);
const bits: u32 = @bitCast(@as(f32, 1.0)); // reinterpret same-size bits

When two types coerce implicitly, no builtin is needed — the "expected type X, found Y" error usually tells you which cast the compiler wants.

#120

Links & Resources

Official docs, std reference, ziglings, community — and where the 0.16 changes are listed.

resourcewhat it is
ziglang.orgdownloads, official docs, blog
ziglang.org/documentation/master/language reference
ziglang.org/documentation/master/std/standard library docs
zig.guidecommunity beginner guide
codeberg.org/ziglings/exercisesziglings — fix small broken programs
ziggit.devcommunity forum
github.com/ziglang/zigsource, issues, PRs
ziglang.org/download/0.16.0/release-notes.html0.16.0 release notes — everything that changed
cheats.rsthe Rust cheatsheet that inspired this site

This site targets Zig 0.16.0; since badges mark entries whose API arrived in 0.15 or 0.16. Ziglings exercise numbers appear throughout as cross-references — work through them top to bottom for muscle memory.

Learn by fixing broken programs

Every entry links to related ziglings exercises — tiny broken Zig programs you fix to learn the language.

Start ziglings