The Zig build system replaces the traditional pattern of Makefiles, CMakeLists, or shell scripts with a declarative-yet-programmable build.zig file written in Zig itself. Instead of learning a separate build DSL, developers use the full power of a general-purpose language to define compilation steps, link dependencies, manage cross-compilation targets, and orchestrate custom build logic. This is particularly valuable for systems-level projects like a database engine written in Zig, where the build must handle conditional compilation for different platforms, embed assets, link against C libraries, and produce optimized binaries for multiple architectures from a single build definition.
Package management in Zig centers on the build.zig.zon manifest file, which declares external dependencies as either URL-fetched tarballs or local path references. Unlike package managers that maintain a central registry and resolve semver constraints through a solver, Zig’s approach pins exact content hashes. Each dependency entry specifies a URL and a hash — there is no version resolution step, no lock file separate from the manifest, and no ambiguity about what code will be compiled. The build system fetches, caches, and exposes dependencies as importable modules, making the entire dependency graph reproducible and auditable.
This topic covers the build.zig API for defining build steps, the build.zig.zon format for dependency declaration, cross-compilation workflows, conditional compilation with build options, and patterns for structuring builds in larger Zig projects.
Unlike Makefiles or CMake, Zig’s build system uses Zig code to define the build graph. The build.zig file exports a pub fn build(b: *std.Build) void function that programmatically adds compilation steps, sets compiler flags, links libraries, and declares install artifacts. Because it’s real code, complex build logic — conditional platform-specific steps, code generation, asset embedding — uses standard control flow (if/else, loops, switch) rather than macro gymnastics. The build system executes this function to construct a dependency graph of steps, then runs only the steps needed to produce the requested target.
The .zon (Zig Object Notation) manifest declares project metadata and dependencies. Each dependency specifies a fetch URL and a content hash. When zig build runs, the build system downloads any missing dependencies, verifies their hash, and caches them in the global Zig cache directory. Because dependencies are identified by content hash rather than version number, two builds with the same build.zig.zon are guaranteed to use identical source code — no lock file synchronization issues, no “works on my machine” from registry state differences.
Zig can cross-compile to any supported target triple (e.g., aarch64-linux-musl, x86_64-macos) without installing separate toolchains. The build system exposes the target as a build option, and the same build.zig file handles all platforms. Combined with Zig’s bundled libc implementations, a single developer machine can produce release binaries for Linux, macOS, and Windows on both x86_64 and ARM64 — a capability that normally requires dedicated CI infrastructure with platform-specific runners.
Build options allow the build to be parameterized at invocation time via -D flags (e.g., zig build -Doptimize=ReleaseFast -Denable-tracy=true). The build.zig file declares options with b.option(), and their values drive conditional logic: linking a profiler, enabling debug assertions, selecting backend implementations. This replaces C’s #ifdef preprocessor with a type-safe, discoverable mechanism — zig build --help lists all available options with descriptions.
The build system models work as a directed acyclic graph of steps. Each step (compile, run, install, custom command) can depend on other steps. When you request a specific step (e.g., zig build test), only the transitive dependencies of that step execute. This graph-based execution ensures minimal rebuilds and enables parallel compilation of independent modules. Custom steps can run arbitrary commands — code generators, asset compilers, integration tests — integrated seamlessly into the build pipeline.
A Zig project’s build is defined entirely by two files at the project root: build.zig (the build script) and build.zig.zon (the package manifest). When you run zig build, the Zig compiler first compiles and executes build.zig to produce a build plan, then executes that plan to produce the requested artifacts.
// build.zig.zon — package manifest for mydb-zig
.{
.name = "mydb-zig",
.version = "0.1.0",
.dependencies = .{
// Each dependency: a name, URL, and content hash.
.zap = .{
.url = "https://github.com/zigzap/zap/archive/refs/tags/v0.8.0.tar.gz",
.hash = "1220aabff7b33e7bc1bd4c6e6cae4a3f5e7a33e4cde27e8fa3e9af61f51af3c87b1d",
},
.clap = .{
.url = "https://github.com/Hejsil/zig-clap/archive/refs/tags/0.9.1.tar.gz",
.hash = "12205bf3e9e0c64b921f8e137f tried4bd4c2c14e1a30e1f4b6e7d39f8e7b2a1c3d5e",
},
// Local path dependency (for monorepo packages).
.shared_types = .{
.path = "../shared-types",
},
},
// Paths included when this package is consumed as a dependency.
.paths = .{
"build.zig",
"build.zig.zon",
"src",
},
}
const std = @import("std");
pub fn build(b: *std.Build) void {
// Standard target and optimization options.
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Declare the main executable.
const exe = b.addExecutable(.{
.name = "mydb-server",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// Add a dependency from build.zig.zon.
const zap_dep = b.dependency("zap", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("zap", zap_dep.module("zap"));
// Link against system C library (for POSIX APIs).
exe.linkLibC();
// Install the artifact to zig-out/bin/.
b.installArtifact(exe);
// --- Test step ---
const unit_tests = b.addTest(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
unit_tests.root_module.addImport("zap", zap_dep.module("zap"));
const run_tests = b.addRunArtifact(unit_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_tests.step);
// --- Run step ---
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run the server");
run_step.dependOn(&run_cmd.step);
}
# Build for Linux ARM64 (e.g., AWS Graviton) from macOS:
zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseFast
# Build for Windows x86_64:
zig build -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseSafe
# The same build.zig handles all targets — no platform-specific build files.
# The binary at zig-out/bin/ is a fully static, self-contained executable
# (when using musl libc on Linux).
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Declare custom build options.
const enable_tracy = b.option(bool, "enable-tracy", "Enable Tracy profiler integration") orelse false;
const log_level = b.option(
std.log.Level,
"log-level",
"Set the log level for the application",
) orelse .info;
const exe = b.addExecutable(.{
.name = "mydb-server",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// Pass options to the compiled code via build-time constants.
const options = b.addOptions();
options.addOption(bool, "enable_tracy", enable_tracy);
options.addOption(std.log.Level, "log_level", log_level);
exe.root_module.addOptions("config", options);
// Conditionally link Tracy client library.
if (enable_tracy) {
exe.linkSystemLibrary("TracyClient");
}
b.installArtifact(exe);
}
// src/main.zig — consuming build options at compile time
const config = @import("config");
pub const std_options = .{
.log_level = config.log_level,
};
pub fn main() !void {
if (config.enable_tracy) {
// Tracy profiler initialization...
std.log.info("Tracy profiler enabled", .{});
}
// Application startup...
}
For example, a database engine written in Zig uses the build system to produce high-performance components that must run on multiple platforms. The build.zig handles compiling the core storage engine, linking against platform-specific I/O primitives (io_uring on Linux, kqueue on macOS), embedding the web dashboard HTML as compile-time assets, and producing release binaries for all deployment targets. A single CI job on a Linux runner cross-compiles for Linux x86_64, Linux ARM64, and macOS ARM64 — eliminating the need for macOS CI runners.
The build.zig.zon dependency management pins every third-party library to an exact content hash. When a team member updates a dependency, the PR diff shows exactly which hash changed, and the content can be audited before merging. This is critical for a database engine where a subtle change in a compression library or allocator could cause data corruption. The content-addressed model means there is no possibility of a “phantom dependency update” where a semver-compatible release introduces breaking behavior.
Build options parameterize the binary for different deployment contexts. Production builds use -Doptimize=ReleaseFast with assertions disabled. Debug builds enable Tracy profiling (-Denable-tracy=true) and verbose logging. CI builds add sanitizers (-Doptimize=Debug with address sanitizer linked) for catching memory safety issues. All configurations are defined in a single build.zig file, avoiding the configuration drift that plagues projects with separate Makefiles per platform.
Structure build.zig to mirror the project’s module boundaries. Each major subsystem (storage engine, network layer, query parser) should be a separate compilation unit with its own test step. This enables running zig build test-storage to test only the storage engine, avoiding the cost of compiling and testing the entire project during focused development. The top-level test step depends on all sub-test steps for CI.
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Each subsystem is a module with its own test target.
const storage_mod = b.addModule("storage", .{
.root_source_file = b.path("src/storage/root.zig"),
});
const network_mod = b.addModule("network", .{
.root_source_file = b.path("src/network/root.zig"),
});
// Storage tests
const storage_tests = b.addTest(.{
.root_source_file = b.path("src/storage/root.zig"),
.target = target,
.optimize = optimize,
});
const run_storage_tests = b.addRunArtifact(storage_tests);
const storage_test_step = b.step("test-storage", "Run storage engine tests");
storage_test_step.dependOn(&run_storage_tests.step);
// Top-level test step depends on all sub-tests.
const test_step = b.step("test", "Run all tests");
test_step.dependOn(&run_storage_tests.step);
// ... add other subsystem test dependencies
}
Zig’s @embedFile builtin compiles file contents directly into the binary at build time. For an embedded server dashboard, the pre-built HTML/JS/CSS files are embedded so the server binary is fully self-contained — no filesystem paths to configure, no missing asset files in production. The build.zig can add a custom step to run the frontend build before compilation, ensuring assets are always up-to-date.
// src/dashboard.zig — embedded dashboard served from memory
const index_html = @embedFile("../dashboard/dist/index.html");
const app_js = @embedFile("../dashboard/dist/app.js");
const styles_css = @embedFile("../dashboard/dist/styles.css");
pub fn handleDashboard(request: *Request) !void {
const path = request.path();
const content = switch (path) {
"/" => .{ .body = index_html, .content_type = "text/html" },
"/app.js" => .{ .body = app_js, .content_type = "application/javascript" },
"/styles.css" => .{ .body = styles_css, .content_type = "text/css" },
else => return request.respond404(),
};
try request.respond(200, content.body, content.content_type);
}
When updating a dependency URL in build.zig.zon, you must also update the hash. If you change the URL but leave the old hash, zig build will download the new archive, find that its hash doesn’t match, and fail. Run zig fetch --save <url> to both download the dependency and update the hash in build.zig.zon automatically. Never manually edit hashes — let the tooling compute them.
A common mistake: the main executable has dependencies added via addImport, but the test target does not. Tests that @import a module depending on an external package will fail with “unknown import” errors. Every addTest call must mirror the addImport calls from the executable. Extract dependency setup into a helper function to avoid divergence between executable and test configurations.
Zig’s global cache (~/.cache/zig/) stores compiled artifacts and fetched dependencies. After upgrading the Zig compiler, cached artifacts compiled with the old version may cause subtle compilation errors or linker failures. Run zig build --clear-cache or delete the cache directory after major Zig version upgrades. CI systems should use a fresh cache per Zig version to avoid cross-version contamination.
Zig’s module system does not allow circular imports. If module A imports module B and module B imports module A, the build fails. This is intentional — circular dependencies indicate a design problem. The solution is to extract shared types into a third module that both A and B import, or to use interface-based decoupling where module A defines a function pointer type that module B’s implementation satisfies.