⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.2122+3e15e99e6What's newOn an older Zig?

Zig Build

build.zig is not a configuration file. It is a Zig program that constructs a graph of steps, which zig build then executes.

const std = @import("std");

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

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

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

Note the module indirection

addExecutable takes a root_module, not a source file directly. Older examples pass .root_source_file, .target, and .optimize straight to addExecutable; that form is gone. Build the module first.

Steps and options

zig build              # the default step
zig build run          # a step you declared
zig build --help       # lists your steps and options
zig build -Dtarget=wasm32-wasi -Doptimize=ReleaseSmall

b.option(...) declares your own flags, which then appear in --help.

Why a program and not a config file

The usual objection is that a build script should be declarative. The usual answer is that every declarative build system grows an escape hatch, and the escape hatch is where the difficulty ends up. Make has shell, CMake has its own language, npm has scripts. Zig skips the intermediate step.

What you get for it is that everything you already know applies. A loop is a loop, a function is a function, a switch on the target is a switch. This site’s own build walks a directory, classifies each file by reading its first lines, and creates a compile step and a run step per snippet. That is thirty lines of ordinary Zig, and in a declarative system it would be a plugin.

The cost is that a build script can do anything, including things that are slow or that break caching, which is what the next section is about.

Configure time vs run time

This is the distinction that causes the most confusion. build() runs once to construct the graph; the steps run afterwards. Anything build() observes directly (reading a directory, checking whether a file exists) is invisible to the caching layer.

If you do that, say so:

b.graph.poisonCache();

Without it, the configuration is cached and your build() will not re-run when the thing it observed changes. This guide’s own build.zig discovers snippets by walking a directory, and needs exactly this call: adding a snippet was silently ignored until it was added.

The general rule that avoids the problem: express inputs as files the graph knows about rather than as things build() looks at. b.path("src/main.zig") is tracked, and a step depending on it re-runs when it changes. std.fs.cwd().openFile(...) inside build() is not tracked, and nothing will notice.

Steps are a graph, not a list

dependOn is the only sequencing mechanism, and everything else follows. Independent steps run in parallel across cores automatically, because nothing declared an order between them. A step runs at most once per build no matter how many things depend on it. And zig build test runs exactly the subgraph that step needs, not the whole file.

That is why the answer to “how do I make this run before that” is always an edge, never a position in the file. The order declarations appear in build() has no meaning at all.

Dependencies

build.zig.zon declares them; b.dependency("name", .{}) retrieves one in build.zig. Fetching is content-addressed and hash-verified.

zig fetch --save <url> adds an entry and records the hash, which is the command to use rather than editing the file by hand. Because the hash covers the contents, a dependency that changes underneath you fails the build instead of being fetched, and packages land in a global cache shared between projects.

A dependency’s own build.zig runs as part of yours, so it can expose modules (dep.module("name")) and artifacts (dep.artifact("name")) that you wire into your own targets. There is no separate package manifest format and no install step: fetching, building and linking are all the one graph.