Mitchell Hashimoto

Zig Build System Internals

February 24, 2022
Table of Contents

Zig has a built-in build system for building projects. It runs on every platform Zig supports and is capable of building everything from simple executables and libraries to complex, multi-artifact, multi-step projects. This page will dive into how the internals of the Zig build system works.

Build systems are an extremely important detail of any software project. When they work, they can feel like magic: you execute a command, and after a series of potentially complicated steps, a working binary (or other artifact) is produced! When they don’t work, they can feel like confusing, non-transparent roadblocks that you wish didn’t exist. This is typical for any powerful tool: magic or a headache depending on the day and the task.

Understanding the internals of the build system are a way to remove that “magic” and make it easier to get things done. This page aims to go one level deep so that Zig users can understand how zig build works so they can hopefully be more productive everyday.

NOTE: This page is not an introduction to the Zig build system itself, but an introduction to the internals of how the build system works. For an introduction to the build system, see the official documentation, this great blog series on the Zig build system, or this blog post on how existing C/C++ projects can utilize the build system.

High-Level Operation

Before diving into details, we’ll describe the Zig build system at a zoomed-out level.

The Zig build system uses the build.zig file for configuration and the zig build CLI for execution. Within the build.zig file, developers must implement a build function (something like fn build() void). The build function has access to a builder API (std.build.Builder) for defining steps, dependencies, artifacts, etc. It is a mostly declarative API that the build system uses to determine what to execute and in what order.

If you’ve used zig build before to build a Zig project, you’ve probably noticed that it seems to sometimes compile twice; you see the output for semantic analysis and codegen twice. This is because on an uncached project, it does compile twice: it first compiles a build binary and then that build binary subsequently compiles your project code.

As just noted, the zig build CLI begins by compiling the build.zig file into a build binary for the running system and then subprocesses this binary to perform the actual build. The build binary benefits from the Zig cache system so that unless build.zig (or any imports) are modified, subsequent zig build commands do not have the recompilation cost.

The build binary is then executed which determines and runs the necessary steps to build the project. The build binary does not embed a Zig compiler; it further subprocesses to the Zig compiler to build individual artifacts as necessary.

A diagram of the high level steps is shown below:

                                   ┌────────────────────┐
                                   │                    │
                                   │     zig build      │
                                   │                    │
                                   └────────────────────┘
        ┌────────────────────┐                │
        │                    │                ▼
        │     build.zig      │──┐  ┌────────────────────┐
        │                    │  │  │                    │
        └────────────────────┘  ├─▶│    build binary    │
        ┌────────────────────┐  │  │                    │
        │  lib/std/special/  │  │  └────────────────────┘
        │  build_runner.zig  │──┘             │
        │                    │                │
        └────────────────────┘                ▼
        ┌────────────────────┐     ┌────────────────────┐┌───────┐
        │                    │     │                    ││       │
        │    source files    │────▶│   zig build-exe    ││  ...  │
        │                    │     │                    ││       │
        └────────────────────┘     └────────────────────┘└───────┘
                                   ┌────────────────────┐
                                   │    artifact(s)     │
                                   │ (exe, libs, etc.)  │
                                   │                    │
                                   └────────────────────┘

Building the Builder

The zig build process does only two things: (1) build the build binary and (2) execute the build binary as a child process. The first step — building the build binary — uses the build.zig file as input but doesn’t yet execute the build function.

The source for zig build can be found in src/main.zig and is implemented in the function cmdBuild. It isn’t an overly complex function and I recommend reading it through. There is an initial block that builds the build binary, and then a subsequent set of logic that subprocesses to it.

The build binary uses lib/std/special/build_runner.zig as the main entrypoint for the build. If you look at this file you’ll see a pub fn main. This is the main entrypoint for the actual build binary.

The entrypoint file imports @build, this is a special package that cmdBuild defines to point to your build.zig file. This is how the build runner eventually executes the build function in your build.zig file.

The final build binary is not installed anywhere. It is stored in the Zig cache directory (usually zig-cache relative to your build.zig file). You can prove to yourself the build binary exists by looking for it:

$ find ./zig-cache -type f -name 'build'
./zig-cache/o/c4c75a71df444bff10945728759e174c/build

Finding “build_runner.zig”

How does zig build know where lib/std/special/build_runner.zig is on your system?

There is a file src/introspect.zig that has an API that is used to find the Zig installation. It works by starting at the directory of the current executable and traversing upwards until it finds either lib/zig/std/std.zig or lib/std/std.zig. When you execute zig build, it starts at the directory where the zig binary is and begins looking for these files.

NOTE: This is also how @import("std") is resolved in projects. Specifically, the std package is defined ahead of time to point to std.zig using the introspection API from the zig CLI during the build process.

The implementation of the function that looks for the stdlib is shown below. From this directory, you can get any file in a standard Zig installation.

fn testZigInstallPrefix(base_dir: fs.Dir) ?Compilation.Directory {
    const test_index_file = "std" ++ fs.path.sep_str ++ "std.zig";

    zig_dir: {
        // Try lib/zig/std/std.zig
        const lib_zig = "lib" ++ fs.path.sep_str ++ "zig";
        var test_zig_dir = base_dir.openDir(lib_zig, .{}) catch break :zig_dir;
        const file = test_zig_dir.openFile(test_index_file, .{}) catch {
            test_zig_dir.close();
            break :zig_dir;
        };
        file.close();
        return Compilation.Directory{ .handle = test_zig_dir, .path = lib_zig };
    }

    // Try lib/std/std.zig
    var test_zig_dir = base_dir.openDir("lib", .{}) catch return null;
    const file = test_zig_dir.openFile(test_index_file, .{}) catch {
        test_zig_dir.close();
        return null;
    };
    file.close();
    return Compilation.Directory{ .handle = test_zig_dir, .path = "lib" };
}

Manually Building the Build Binary

To further remove any magic, let’s build the build binary manually. Later on this page, we’ll execute this binary manually, but we’ll start with just building it.

For our example, we’ll build the build.zig file for the Zig compiler itself. Clone the Zig source code and have zig installed (ideally a version compiled from the source you check out, but any recent version should work). Next, from the checkout directory, we can build the build binary:

$ zig build-exe \
    --pkg-begin '@build' build.zig \
    --pkg-end \
    -femit-bin=custom-builder \
    lib/std/special/build_runner.zig

That’s it! After running the command, the custom-builder executable should exist. This is identical to the executable that is created when zig build is called.

The command-line invocation helps make it really clear that our main package is the build_runner.zig file from the standard library and that our build.zig file is exposed as the @build package.

This is half of what zig build does under the covers.

Executing the Build Binary

After the build binary is built, zig build spawns a child process and immediately executes it.

The binary expects four positional arguments in the following order:

  1. Path to the Zig compiler
  2. Path to the build root where build.zig is — although importantly it does not need access to the build.zig file anymore. It uses this only to resolve build-relative paths from your build.zig code that was already compiled into the binary.
  3. Path to the local cache directory (does not have to exist).
  4. Path to the global cache directory (does not have to exist).

The zig build implementation automatically populates these arguments then forwards any additional arguments to the build binary and executes it. This binary performs the actual project build.

As a reminder, the main entrypoint function for the build binary is defined in lib/std/special/build_runner.zig. It isn’t complicated and I recommend you read the file. The actual builder API (std.build.Builder) gets more complicated so I recommend starting by just reading the build_runner.zig file to understand the high-level control flow.

Manually Invoking the Build Binary

If you created the custom-builder binary from the previous section, you can now manually invoke it to perform a full build of the Zig compiler.

As a reminder, in practice you never have to do this because zig build does this. We’re only manually executing the build binary to illustrate how zig build works internally.

$ ./custom-builder $(which zig) . ./cache ./global-cache

We used the Zig compiler as our example, but this same pattern would work for any project that uses the Zig build system.

Supported Arguments and Flags

The build binary supports almost every flag zig build supports. In fact, zig build blindly copies all arguments directly to the build binary child process except for a limited set that directly control the build binary building process.

You can see this in action by executing the build binary with the --help flag (along with the four required positional arguments). If you manually built the build binary from the previous section, you can try it now:

$ ./custom-builder $(which zig) . ./cache ./global-cache --help

The four positional arguments assume that zig is on your PATH and that your current working directory is the build root where the build.zig file exists.

The help will look pretty much identical to zig build --help because it is! zig build --help builds the build binary first, then forward the --help flag to the child process. The output should exactly match.

Invoking the Build Function

Something I found really interesting was how the build runner invokes the build function in the build.zig file. The build runner uses Zig's comptime capabilities to introspect on the function signature of the build function in order to support multiple function signatures.

The runBuild function from build_runner.zig is reproduced below to show this in action:

fn runBuild(builder: *Builder) anyerror!void {
    switch (@typeInfo(@typeInfo(@TypeOf(root.build)).Fn.return_type.?)) {
        .Void => root.build(builder),
        .ErrorUnion => try root.build(builder),
        else => @compileError("expected return type of build to be 'void' or '!void'"),
    }
}

This allows the build function signature to be both:

  • fn build(*std.build.Builder) void
  • fn build(*std.build.Builder) !void (note the !void)

And more specifically, the error union case (the second case) can be any error union. It can be an inferred error union like in the list above or it can be an explicitly defined error union.

In terms of understanding how the build system works, this is not a very important detail, but this is an example of a cool inner working you can discover by studying the implementation of tools. It also showed me a cool comptime use case.

Build Steps

We’ve seen how zig build creates a dedicated build binary using the build.zig file and how that build binary can be executed to build your project, but what is the build binary actually doing and how does it relate to the build.zig file?

The build binary invokes the user-defined build function from the build.zig file. This build function is given a pointer to a std.build.Builder as an argument which is used to declaratively define the available flags, targets, target dependencies, etc. of the build. Finally, the build runner (build_runner.zig) executes the steps in dependency order for the given target.

Defining a Step

The Builder argument has a lot of functionality but it’s core goal is to build up a set of steps.

A “top level step” is a special distinction made for steps that can be invoked by name, i.e. zig build <name>. There are two predefined top level steps: “install” and “uninstall”. Additional top level steps can be created with the step function. Beyond having a designated invokable name, a top level step is functionally equivalent to any other step. The Builder maintains the set of top level steps in an ArrayList.

All steps can have zero or more dependencies specified by calling the dependOn function on a Step. This maintains the dependencies in a simple ArrayList.

Invoking a Top Level Step

One or more top level steps is invoked by calling the make function on Builder. This internally calls makeOneStep which makes a single top level step. makeOneStep is really simple and the entire implementation is shown below:

fn makeOneStep(self: *Builder, s: *Step) anyerror!void {
    if (s.loop_flag) {
        warn("Dependency loop detected:\n  {s}\n", .{s.name});
        return error.DependencyLoopDetected;
    }
    s.loop_flag = true;

    for (s.dependencies.items) |dep| {
        self.makeOneStep(dep) catch |err| {
            if (err == error.DependencyLoopDetected) {
                warn("  {s}\n", .{s.name});
            }
            return err;
        };
    }

    s.loop_flag = false;

    try s.make();
}

makeOneStep goes through one dependency at a time in the order they were added and invokes makeOneStep recursively. The loop_flag on each step is used to detect cycles (instead of building a graph structure or something more fancy). Finally, the step itself is invoked via make.

Anatomy of a Step

The Step structure is a relatively simple interface-like structure with some state attached to it. The unique logic for a step is encapsulated in the makeFn function pointer while the rest of the fields are shared state.

pub const Step = struct {
    id: Id,
    name: []const u8,
    makeFn: fn (self: *Step) anyerror!void,
    dependencies: ArrayList(*Step),
    loop_flag: bool,
    done_flag: bool,

    // ...
};

The name is used for debugging purposes only, unless this is a top level step. The loop_flag and dependencies were already covered previously. The done_flag makes it so that a step executes exactly once. Subsequent execution is a noop.

With this knowledge in hand, it should be directionally clear how one could go about creating a custom step. The built-in steps provide all the functionality necessary for building most projects.

Declarative vs. Imperative

The build function defines the set of steps and their dependencies, but does not execute them. One way to describe this would be that the build.zig file declaratively defines the build steps. This is an important concept to understand to avoid some common pitfalls I’ve seen people make with build systems in general, including Zig.

Because the steps are declaratively defined, you must be thoughtful to understand when and where logic actually occurs. If you have a step that reads a file that is generated by a previous step, then that must be done using a custom step with the makeFn. You can’t create the step and then read the file in the build function because it hasn’t yet executed.

On the other hand, if you’re trying to programmatically create a series of steps for a set of files that exist prior to any build steps running, you can (and likely should) do that directly in the build function so that the steps are fully defined. You can’t dynamically define steps at build execution time.

Advanced note: You can’t dynamically define steps at execution time into the existing step graph. But, you could create a custom step that dynamically creates other steps and executes them directly.

Compilation Steps

It is now understood how build.zig defines steps, how those steps are structured, and how they're invoked. The Zig Builder structure has high-level helpers for building executables, objects, etc. Let's take a deeper look at how that works.

All compilation-related functionality shares a single implementation as the LibExeObjStep. An executable, library, or other object type is built depending on the field values for the step structure. The details of this are usually hidden behind helpers such as addExecutable or addSharedLibrary.

The implementation for LibExeObjStep can be found in lib/std/build.zig. The step has a rich set of functionality and is such an important part of the build process that I highly recommend studying the full step, although it is a relatively large number of lines of code.

The step implementation works by subprocessing back out to the zig compiler. The make implementation takes the configuration on the step and builds up a set of command-line arguments and then invokes zig build-exe or zig build-obj or some other Zig command. Recall that the path to the Zig CLI is the first required positional argument for the build binary; this is the primary use case for that argument.

Importantly, this means that the build binary does not embed the full Zig compiler. Further, the build binary can point to different versions of Zig if you wanted to!

Conclusion

My primary takeaway from studying the internals of the Zig build system is that its just Zig. You can do anything you want in the build.zig file because it is compiled into a fully fledged executable for your current system. Zig gives you an opinionated structure and set of built-in steps, but you have the full power of Zig available to build your project.

I deeply believe that understanding the layer beneath the tools we use everyday makes us better tool users. It eliminates any mystery by exposing the machines inner workings, and I tend to find the inner workings are always simpler than I expected. The next time you’re wondering if you can do something with the build system or why the build system isn’t working the way you want, hopefully this deeper knowledge can help get you to an answer more quickly.