Overview
Chapter 21 21 showed how build.zig.zon declares package metadata; this chapter reveals how build.zig orchestrates compilation by authoring a directed acyclic graph of build steps using the std.Build API, which the build runner executes to produce artifacts—executables, libraries, tests, and custom transformations—while caching intermediate results and parallelizing independent work (see Build.zig).
Unlike zig run or zig build-exe, which compile a single entry point imperatively, build.zig is executable Zig code that constructs a declarative build graph: nodes represent compilation steps, edges represent dependencies, and the build runner (zig build) traverses the graph optimally. For release details, see v0.15.2.
Learning Goals
- Distinguish
zig build(build graph execution) fromzig run/zig build-exe(direct compilation). - Use
b.standardTargetOptions()andb.standardOptimizeOption()to expose user-configurable target and optimization choices. - Create modules with
b.addModule()andb.createModule(), understanding when to expose modules publicly versus privately (see Module.zig). - Build executables with
b.addExecutable(), libraries withb.addLibrary(), and wire dependencies between artifacts (see Compile.zig). - Integrate tests with
b.addTest()and wire custom top-level steps withb.step(). - Debug build failures using
zig build -vand interpret graph errors from missing modules or incorrect dependencies.
as Executable Zig Code
Every build.zig exports a pub fn build(b: *std.Build) function that the build runner invokes after parsing build.zig.zon and setting up the build graph context; within this function, you use methods on the *std.Build pointer to register steps, artifacts, and dependencies declaratively. 21
Imperative Commands vs. Declarative Graph
When you run zig run main.zig, the compiler immediately compiles main.zig and executes it—a single-shot imperative workflow. When you run zig build, the runner first executes build.zig to construct a graph of steps, then analyzes that graph to determine which steps need to run (based on cache state and dependencies), and finally executes those steps in parallel where possible.
This declarative approach enables:
- Incremental builds: unchanged artifacts are not recompiled
- Parallel execution: independent steps run simultaneously
- Reproducibility: the same graph produces the same outputs
- Extensibility: custom steps integrate seamlessly
Minimal
The simplest build.zig creates one executable and installs it:
const std = @import("std");
// Minimal build.zig: single executable, no options
// Demonstrates the simplest possible build script for the Zig build system.
pub fn build(b: *std.Build) void {
// Create an executable compilation step with minimal configuration.
// This represents the fundamental pattern for producing a binary artifact.
const exe = b.addExecutable(.{
// The output binary name (becomes "hello" or "hello.exe")
.name = "hello",
// Configure the root module with source file and compilation settings
.root_module = b.createModule(.{
// Specify the entry point source file relative to build.zig
.root_source_file = b.path("main.zig"),
// Target the host machine (the system running the build)
.target = b.graph.host,
// Use Debug optimization level (no optimizations, debug symbols included)
.optimize = .Debug,
}),
});
// Register the executable to be installed to the output directory.
// When `zig build` runs, this artifact will be copied to zig-out/bin/
b.installArtifact(exe);
}
// Entry point for a minimal Zig build system example.
// This demonstrates the simplest possible Zig program structure that can be built
// using the Zig build system, showing the basic main function and standard library import.
const std = @import("std");
pub fn main() !void {
std.debug.print("Hello from minimal build!\n", .{});
}
$ zig build
$ ./zig-out/bin/helloHello from minimal build!This example hard-codes b.graph.host (the machine running the build) as the target and .Debug optimization, so users cannot customize it. For real projects, expose these as options.
The build function does not compile anything itself—it only registers steps in the graph. The build runner executes the graph after build() returns.
Standard Options Helpers
Most projects want users to control the target architecture/OS and optimization level; std.Build provides two helpers that expose these as CLI flags and handle defaults gracefully.
: Cross-Compilation Made Easy
b.standardTargetOptions(.{}) returns a std.Build.ResolvedTarget that respects the -Dtarget= flag, allowing users to cross-compile without modifying build.zig:
$ zig build -Dtarget=x86_64-linux # Linux x86_64
$ zig build -Dtarget=aarch64-macos # macOS ARM64
$ zig build -Dtarget=wasm32-wasi # WebAssembly WASIThe empty options struct (.{}) accepts defaults; you can optionally whitelist targets or specify a fallback:
const target = b.standardTargetOptions(.{
.default_target = .{ .cpu_arch = .x86_64, .os_tag = .linux },
});: User-Controlled Optimization
b.standardOptimizeOption(.{}) returns a std.builtin.OptimizeMode that respects the -Doptimize= flag, with values .Debug, .ReleaseSafe, .ReleaseFast, or .ReleaseSmall:
$ zig build # Debug (default)
$ zig build -Doptimize=ReleaseFast # Maximum speed
$ zig build -Doptimize=ReleaseSmall # Minimum sizeThe options struct accepts a .preferred_optimize_mode to suggest a default when the user doesn’t specify one. If you pass no preference, the system infers from the package’s release_mode setting in build.zig.zon. 21
Under the hood, the chosen OptimizeMode feeds into the compiler’s configuration and affects safety checks, debug information, and backend optimization levels:
This is the same OptimizeMode that b.standardOptimizeOption() returns, so the flags you expose in build.zig directly determine which safety checks remain enabled and which optimization pipeline the compiler selects.
Complete Example with Standard Options
const std = @import("std");
// Demonstrating standardTargetOptions and standardOptimizeOption
pub fn build(b: *std.Build) void {
// Allows user to choose target: zig build -Dtarget=x86_64-linux
const target = b.standardTargetOptions(.{});
// Allows user to choose optimization: zig build -Doptimize=ReleaseFast
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "configurable",
.root_module = b.createModule(.{
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
}),
});
b.installArtifact(exe);
// Add run step
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
const run_step = b.step("run", "Run the application");
run_step.dependOn(&run_cmd.step);
}
// This program demonstrates how to access and display Zig's built-in compilation
// information through the `builtin` module. It's used in the zigbook to teach
// readers about build system introspection and standard options.
// Import the standard library for debug printing capabilities
const std = @import("std");
// Import builtin module to access compile-time information about the target
// platform, CPU architecture, and optimization mode
const builtin = @import("builtin");
// Main entry point that prints compilation target information
// Returns an error union to handle potential I/O failures from debug.print
pub fn main() !void {
// Print the target architecture (e.g., x86_64, aarch64) and operating system
// (e.g., linux, windows) by extracting tag names from the builtin constants
std.debug.print("Target: {s}-{s}\n", .{
@tagName(builtin.cpu.arch),
@tagName(builtin.os.tag),
});
// Print the optimization mode (Debug, ReleaseSafe, ReleaseFast, or ReleaseSmall)
// that was specified during compilation
std.debug.print("Optimize: {s}\n", .{@tagName(builtin.mode)});
}
$ zig build -Dtarget=x86_64-linux -Doptimize=ReleaseFast runTarget: x86_64-linux Optimize: ReleaseFast
Always use standardTargetOptions() and standardOptimizeOption() unless you have a very specific reason to hard-code values (e.g., firmware targeting a fixed embedded system).
Modules: Public and Private
Zig 0.15.2 distinguishes public modules (exposed to consumers via b.addModule()) from private modules (internal to the current package, created with b.createModule()). Public modules appear in downstream build.zig files via b.dependency(), while private modules exist only within your build graph.
vs.
b.addModule(name, options)creates a module and registers it in the package’s public module table, making it available to consumers who depend on this package.b.createModule(options)creates a module without exposing it; useful for executable-specific code or internal helpers.
Both functions return a *std.Build.Module, which you wire into compilation steps via the .imports field.
Example: Public Module and Executable
const std = @import("std");
// Demonstrating module creation and imports
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Create a reusable module (public)
const math_mod = b.addModule("math", .{
.root_source_file = b.path("math.zig"),
.target = target,
});
// Create the executable with import of the module
const exe = b.addExecutable(.{
.name = "calculator",
.root_module = b.createModule(.{
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "math", .module = math_mod },
},
}),
});
b.installArtifact(exe);
const run_step = b.step("run", "Run the calculator");
const run_cmd = b.addRunArtifact(exe);
run_step.dependOn(&run_cmd.step);
}
// This module provides basic arithmetic operations for the zigbook build system examples.
// It demonstrates how to create a reusable module that can be imported by other Zig files.
/// Adds two 32-bit signed integers and returns their sum.
/// This function is marked pub to be accessible from other modules that import this file.
pub fn add(a: i32, b: i32) i32 {
return a + b;
}
/// Multiplies two 32-bit signed integers and returns their product.
/// This function is marked pub to be accessible from other modules that import this file.
pub fn multiply(a: i32, b: i32) i32 {
return a * b;
}
// This program demonstrates how to use custom modules in Zig's build system.
// It imports a local "math" module and uses its functions to perform basic arithmetic operations.
// Import the standard library for debug printing capabilities
const std = @import("std");
// Import the custom math module which provides arithmetic operations
const math = @import("math");
// Main entry point demonstrating module usage with basic arithmetic
pub fn main() !void {
// Define two constant operands for demonstration
const a = 10;
const b = 20;
// Print the result of addition using the imported math module
std.debug.print("{d} + {d} = {d}\n", .{ a, b, math.add(a, b) });
// Print the result of multiplication using the imported math module
std.debug.print("{d} * {d} = {d}\n", .{ a, b, math.multiply(a, b) });
}
$ zig build run10 + 20 = 30
10 * 20 = 200Here math is a public module (consumers of this package can @import("math")), while the executable’s root module is private (created with createModule).
The .imports field in Module.CreateOptions is a slice of .{ .name = …, .module = … } pairs, allowing you to map arbitrary import names to module pointers—useful for avoiding name collisions when consuming multiple packages.
Artifacts: Executables, Libraries, Objects
An artifact is a compile step that produces a binary output: an executable, a static or dynamic library, or an object file. The std.Build API provides addExecutable(), addLibrary(), and addObject() functions that return *Step.Compile pointers.
: Building Programs
b.addExecutable(.{ .name = …, .root_module = … }) creates a Step.Compile that links a main function (or _start for freestanding) into an executable:
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);.name: The output filename (e.g.,myapp.exeon Windows,myappon Unix)..root_module: The module containing the entry point.- Optional:
.version,.linkage(for PIE),.max_rss,.use_llvm,.use_lld,.zig_lib_dir.
: Static and Dynamic Libraries
b.addLibrary(.{ .name = …, .root_module = …, . linkage = … }) creates a library:
const lib = b.addLibrary(.{
.name = "utils",
.root_module = b.createModule(.{
.root_source_file = b.path("utils.zig"),
.target = target,
.optimize = optimize,
}),
. linkage = .static, // or .dynamic
.version = .{ .major = 1, .minor = 0, .patch = 0 },
});
b.installArtifact(lib);.linkage = .staticproduces a.a(Unix) or.lib(Windows) archive..linkage = .dynamicproduces a.so(Unix),.dylib(macOS), or.dll(Windows) shared library..version: Semantic version embedded in the library metadata (Unix only).
Linking Libraries to Executables
To link a library into an executable, call exe.linkLibrary(lib) after creating both artifacts:
const std = @import("std");
// Demonstrating library creation
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Create a static library
const lib = b.addLibrary(.{
.name = "utils",
.root_module = b.createModule(.{
.root_source_file = b.path("utils.zig"),
.target = target,
.optimize = optimize,
}),
.linkage = .static,
.version = .{ .major = 1, .minor = 0, .patch = 0 },
});
b.installArtifact(lib);
// Create an executable that links the library
const exe = b.addExecutable(.{
.name = "demo",
.root_module = b.createModule(.{
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
}),
});
exe.linkLibrary(lib);
b.installArtifact(exe);
const run_step = b.step("run", "Run the demo");
const run_cmd = b.addRunArtifact(exe);
run_step.dependOn(&run_cmd.step);
}
//! Utility module demonstrating exported functions and formatted output.
//! This module is part of the build system deep dive chapter, showing how to create
//! library functions that can be exported and used across different build artifacts.
const std = @import("std");
/// Doubles the input integer value.
/// This function is exported and can be called from C or other languages.
/// Uses the `export` keyword to make it available in the compiled library.
export fn util_double(x: i32) i32 {
return x * 2;
}
/// Squares the input integer value.
/// This function is exported and can be called from C or other languages.
/// Uses the `export` keyword to make it available in the compiled library.
export fn util_square(x: i32) i32 {
return x * x;
}
/// Formats a message with an integer value into the provided buffer.
/// This is a public Zig function (not exported) that demonstrates buffer-based formatting.
///
/// Returns a slice of the buffer containing the formatted message, or an error if
/// the buffer is too small to hold the formatted output.
pub fn formatMessage(buf: []u8, value: i32) ![]const u8 {
return std.fmt.bufPrint(buf, "Value: {d}", .{value});
}
// Import the standard library for printing capabilities
const std = @import("std");
// External function declaration: doubles the input integer
// This function is defined in a separate library/object file
extern fn util_double(x: i32) i32;
// External function declaration: squares the input integer
// This function is defined in a separate library/object file
extern fn util_square(x: i32) i32;
// Main entry point demonstrating library linking
// Calls external utility functions to show build system integration
pub fn main() !void {
// Test value for demonstrating the external functions
const x: i32 = 7;
// Print the result of doubling x using the external function
std.debug.print("double({d}) = {d}\n", .{ x, util_double(x) });
// Print the result of squaring x using the external function
std.debug.print("square({d}) = {d}\n", .{ x, util_square(x) });
}
$ zig build rundouble(7) = 14
square(7) = 49When linking a Zig library, symbols must be `export`ed (for C ABI) or you must use module imports—Zig does not have a linker-level "public Zig API" concept distinct from module exports.
Installing Artifacts:
b.installArtifact(exe) adds a dependency on the default install step (zig build with no arguments) that copies the artifact to zig-out/bin/ (executables) or zig-out/lib/ (libraries). You can customize the install directory or skip installation entirely if the artifact is intermediate-only.
Tests and Test Steps
Zig’s test blocks integrate directly into the build system: b.addTest(.{ .root_module = … }) creates a special executable that runs all test blocks in the given module, reporting pass/fail to the build runner. 13
: Compiling Test Executables
const lib_tests = b.addTest(.{
.root_module = lib_mod,
});
const run_lib_tests = b.addRunArtifact(lib_tests);
const test_step = b.step("test", "Run library tests");
test_step.dependOn(&run_lib_tests.step);b.addTest() returns a *Step.Compile just like addExecutable(), but it compiles the module in test mode, linking the test runner and enabling test-only code paths.
Complete Test Integration Example
const std = @import("std");
// Demonstrating test integration
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const lib_mod = b.addModule("mylib", .{
.root_source_file = b.path("lib.zig"),
.target = target,
});
// Create tests for the library module
const lib_tests = b.addTest(.{
.root_module = lib_mod,
});
const run_lib_tests = b.addRunArtifact(lib_tests);
// Create a test step
const test_step = b.step("test", "Run library tests");
test_step.dependOn(&run_lib_tests.step);
// Also create an executable that uses the library
const exe = b.addExecutable(.{
.name = "app",
.root_module = b.createModule(.{
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "mylib", .module = lib_mod },
},
}),
});
b.installArtifact(exe);
}
/// Computes the factorial of a non-negative integer using recursion.
/// The factorial of n (denoted as n!) is the product of all positive integers less than or equal to n.
/// Base case: factorial(0) = factorial(1) = 1
/// Recursive case: factorial(n) = n * factorial(n-1)
pub fn factorial(n: u32) u32 {
// Base case: 0! and 1! both equal 1
if (n <= 1) return 1;
// Recursive case: multiply n by factorial of (n-1)
return n * factorial(n - 1);
}
// Test: Verify that the factorial of 0 returns 1 (base case)
test "factorial of 0 is 1" {
const std = @import("std");
try std.testing.expectEqual(@as(u32, 1), factorial(0));
}
// Test: Verify that the factorial of 5 returns 120 (5! = 5*4*3*2*1 = 120)
test "factorial of 5 is 120" {
const std = @import("std");
try std.testing.expectEqual(@as(u32, 120), factorial(5));
}
// Test: Verify that the factorial of 1 returns 1 (base case)
test "factorial of 1 is 1" {
const std = @import("std");
try std.testing.expectEqual(@as(u32, 1), factorial(1));
}
// Main entry point demonstrating the factorial function from mylib.
// This example shows how to:
// - Import and use custom library modules
// - Call library functions with different input values
// - Display computed results using debug printing
const std = @import("std");
const mylib = @import("mylib");
pub fn main() !void {
std.debug.print("5! = {d}\n", .{mylib.factorial(5)});
std.debug.print("10! = {d}\n", .{mylib.factorial(10)});
}
$ zig build testAll 3 tests passed.
Create separate test steps for each module to isolate failures and enable parallel test execution.
To see how this scales up in a large codebase, the Zig compiler’s own wires many specialized test steps into a single umbrella step:
Your own projects can borrow this pattern: one high-level test step that fans out to format checks, unit tests, integration tests, and cross-target test matrices, all wired together using the same b.step and b.addTest primitives.
Top-Level Steps: Custom Build Commands
A top-level step is a named entry point that users invoke with zig build <step-name>. You create them with b.step(name, description) and wire dependencies using step.dependOn(other_step).
Creating a Step
const run_step = b.step("run", "Run the application");
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
run_step.dependOn(&run_cmd.step);b.step("run", …)creates the top-level step users invoke.b.addRunArtifact(exe)creates a step that executes the compiled binary.run_cmd.step.dependOn(b.getInstallStep())ensures the binary is installed before running it.run_step.dependOn(&run_cmd.step)links the top-level step to the run command.
This pattern appears in almost every zig init-generated build.zig.
In the Zig compiler’s own , the default install and test steps form a larger dependency graph:
Running zig build (with no explicit step) typically executes a default install step like this, while zig build test executes a dedicated test step that depends on the same core compile actions.
To place this chapter in the wider Zig toolchain, the compiler’s own bootstrap process uses CMake to produce an intermediate executable, then invokes on its native script:
In other words, the same APIs you use for application projects also drive the self-hosted Zig compiler build.
Custom Build Options
Beyond standardTargetOptions() and standardOptimizeOption(), you can define arbitrary user-facing flags with b.option() and expose them to Zig source code via b.addOptions() (see Options.zig).
: CLI Flags
b.option(T, name, description) registers a user-facing flag and returns ?T (null if the user didn’t provide it):
const enable_logging = b.option(bool, "enable-logging", "Enable debug logging") orelse false;
const app_name = b.option([]const u8, "app-name", "Application name") orelse "MyApp";Users pass values via -Dname=value:
$ zig build -Denable-logging -Dapp-name=CustomName run: Passing Config to Code
b.addOptions() creates a step that generates a Zig source file from key-value pairs, which you then import as a module:
const std = @import("std");
// Demonstrating custom build options
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Custom boolean option
const enable_logging = b.option(
bool,
"enable-logging",
"Enable debug logging",
) orelse false;
// Custom string option
const app_name = b.option(
[]const u8,
"app-name",
"Application name",
) orelse "MyApp";
// Create options module to pass config to code
const config = b.addOptions();
config.addOption(bool, "enable_logging", enable_logging);
config.addOption([]const u8, "app_name", app_name);
const config_module = config.createModule();
const exe = b.addExecutable(.{
.name = "configapp",
.root_module = b.createModule(.{
.root_source_file = b.path("main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "config", .module = config_module },
},
}),
});
b.installArtifact(exe);
const run_step = b.step("run", "Run the app");
const run_cmd = b.addRunArtifact(exe);
run_step.dependOn(&run_cmd.step);
}
// Import standard library for debug printing functionality
const std = @import("std");
// Import build-time configuration options defined in build.zig
const config = @import("config");
/// Entry point of the application demonstrating the use of build options.
/// This function showcases how to access and use configuration values that
/// are set during the build process through the Zig build system.
pub fn main() !void {
// Display the application name from build configuration
std.debug.print("Application: {s}\n", .{config.app_name});
// Display the logging toggle status from build configuration
std.debug.print("Logging enabled: {}\n", .{config.enable_logging});
// Conditionally execute debug logging based on build-time configuration
// This demonstrates compile-time branching using build options
if (config.enable_logging) {
std.debug.print("[DEBUG] This is a debug message\n", .{});
}
}
$ zig build run -Denable-logging -Dapp-name=TestAppApplication: TestApp
Logging enabled: true
[DEBUG] This is a debug messageThis pattern avoids the need for environment variables or runtime config files when build-time constants suffice.
The Zig compiler itself uses the same approach: command-line -D options are parsed with b.option(), collected into an options step with b.addOptions(), and then imported as a build_options module that regular Zig code can read.
Treat b.addOptions() as a structured, type-checked configuration channel from your zig build command line into ordinary Zig modules, just as the compiler does for its own build_options module.
Debugging Build Failures
When zig build fails, the error message usually points to a missing module, incorrect dependency, or misconfigured step. The -v flag enables verbose output showing all compiler invocations.
: Inspecting Compiler Invocations
$ zig build -v
zig build-exe /path/to/main.zig -target x86_64-linux -O Debug -femit-bin=zig-cache/...
zig build-lib /path/to/lib.zig -target x86_64-linux -O Debug -femit-bin=zig-cache/...
...This reveals the exact zig subcommands the build runner executes, helping diagnose flag issues or missing files.
Common Graph Errors
- "module 'foo' not found": The
.importstable doesn’t include a module namedfoo, or a dependency wasn’t wired correctly. - "circular dependency detected": Two steps depend on each other transitively—build graphs must be acyclic.
- "file not found: src/main.zig": The path passed to
b.path()doesn’t exist relative to the build root. - "no member named 'root_source_file' in ExecutableOptions": You’re using Zig 0.15.2 syntax with an older compiler, or vice versa.
Notes & Caveats
- The build runner caches artifact hashes in
zig-cache/; deleting this directory forces a full rebuild. - Passing
--afterzig build runforwards arguments to the executed binary:zig build run — --help. b.installArtifact()is the canonical way to expose outputs; avoid manual file copying unless you have a specific need.- The default install step (
zig buildwith no arguments) installs all artifacts registered withinstallArtifact()—if you want a no-op default, don’t install anything.
Exercises
- Modify the minimal example to hard-code a cross-compilation target (e.g.,
wasm32-wasi) and verify the output format withfile zig-out/bin/hello. 43 - Extend the modules example to create a second module
utilsthatmathimports, demonstrating transitive dependencies. - Add a custom option
-Dmax-threads=Nto the options example and use it to initialize a compile-time constant thread pool size. - Create a library with both static and dynamic linkage modes, install both, and inspect the output files to see the size difference.
Caveats, Alternatives, Edge Cases
- Zig 0.14.0 introduced the
root_modulefield; older code usingroot_source_filedirectly onExecutableOptionswill fail on Zig 0.15.2. - Some projects still use
--pkg-begin/--pkg-endflags manually instead of the module system—these are deprecated and should be migrated toModule.addImport(). 20 - The build runner does not support incremental compilation of
build.zigitself—changingbuild.zigtriggers a full graph re-evaluation. - If you see "userland" mentioned in documentation, it means the build system is implemented entirely in Zig standard library code, not compiler magic—you can read
std.Buildsource to understand any behavior.