Skip to content

Creating Build Steps

A Zig build is made from steps.

A Zig build is made from steps.

A step is one action in the build process. It might compile an executable, run tests, copy a file, generate code, or run a command.

When you write:

zig build

Zig loads build.zig, creates a graph of steps, and runs the default step.

The build graph is important. A build step can depend on another build step. If step B depends on step A, Zig must finish A before B can run.

The Default Build Step

Every build.zig file receives a build object:

pub fn build(b: *std.Build) void {
    // build description goes here
}

The object b owns the build graph.

When you add an executable:

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

Zig creates a compile step internally. That step knows how to build the hello executable.

But creating the executable is not enough. You usually also want to install it:

b.installArtifact(exe);

This connects the executable to the default install step. Now zig build will build and install it.

Named Steps

You can create your own named step with:

const run_step = b.step("run", "Run the program");

The first string is the command name. The second string is the help text.

After this, the user can run:

zig build run

But the step does nothing yet. A named step needs dependencies.

Running an Executable

To run an executable from the build system, create a run command:

const run_cmd = b.addRunArtifact(exe);

Then connect it to the named step:

run_step.dependOn(&run_cmd.step);

Full example:

const std = @import("std");

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

    const exe = b.addExecutable(.{
        .name = "hello",
        .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 program");
    run_step.dependOn(&run_cmd.step);
}

Now the project supports:

zig build
zig build run

The first command builds and installs the program.

The second command builds the program and then runs it.

Passing Arguments to a Run Step

Sometimes you want to pass arguments to the program.

Suppose your program reads command-line arguments:

const std = @import("std");

pub fn main() !void {
    var args = std.process.args();

    while (args.next()) |arg| {
        std.debug.print("{s}\n", .{arg});
    }
}

You can allow arguments after --:

if (b.args) |args| {
    run_cmd.addArgs(args);
}

Full build snippet:

const run_cmd = b.addRunArtifact(exe);

if (b.args) |args| {
    run_cmd.addArgs(args);
}

const run_step = b.step("run", "Run the program");
run_step.dependOn(&run_cmd.step);

Now you can run:

zig build run -- hello zig users

The arguments after -- are passed to your program, not to the build system.

Test Steps

Tests are also build steps.

You create a test artifact with:

const unit_tests = b.addTest(.{
    .root_module = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    }),
});

This compiles the tests, but does not yet connect them to a named command.

To run the tests:

const run_unit_tests = b.addRunArtifact(unit_tests);

Then create a named step:

const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_unit_tests.step);

Now this works:

zig build test

Full example:

const std = @import("std");

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

    const exe = b.addExecutable(.{
        .name = "hello",
        .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);
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }

    const run_step = b.step("run", "Run the program");
    run_step.dependOn(&run_cmd.step);

    const unit_tests = b.addTest(.{
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });

    const run_unit_tests = b.addRunArtifact(unit_tests);

    const test_step = b.step("test", "Run unit tests");
    test_step.dependOn(&run_unit_tests.step);
}

Step Dependencies

A step dependency means one step must happen before another.

This line:

run_step.dependOn(&run_cmd.step);

means:

Before the run step is complete, the run command step must be complete.

The run command itself depends on the executable being built. So Zig knows the order:

compile executable
run executable
finish run step

You do not manually write that order as a shell script. You describe the relationships, and Zig executes the needed steps.

Multiple Dependencies

A step can depend on more than one thing.

For example, you can create a check step that runs several checks:

const check_step = b.step("check", "Run all checks");

check_step.dependOn(&run_unit_tests.step);

Later, you might add formatting checks, generated-code checks, or integration tests:

check_step.dependOn(&run_unit_tests.step);
check_step.dependOn(&run_integration_tests.step);
check_step.dependOn(&lint_generated_files.step);

Then:

zig build check

runs everything connected to that step.

This is a clean way to define project workflows.

Custom System Commands

You can also create a step that runs an external command.

For example:

const echo_cmd = b.addSystemCommand(&.{
    "echo",
    "Hello from the build system",
});

const hello_step = b.step("hello", "Print a message");
hello_step.dependOn(&echo_cmd.step);

Now this works:

zig build hello

This is useful when a project needs to call another tool, such as a code generator.

Use this carefully. External commands make the build less portable if they depend on tools that may not exist on every system.

Generated Files

Build steps are often used to generate files before compilation.

For example, a project might generate a source file from a schema, grammar, protocol definition, or asset list.

The general idea is:

generate file
compile program that imports generated file
install program

The exact APIs depend on what kind of generation you need, but the dependency idea stays the same. The compile step must depend on the generation step, so the file exists before the compiler needs it.

This is the build system’s main job: make the order explicit.

Helpful Step Names

Good step names are short and predictable.

Common step names:

run
test
check
docs
bench
install

A user should be able to guess them.

The help text should explain the step plainly:

const bench_step = b.step("bench", "Run benchmarks");
const docs_step = b.step("docs", "Build documentation");
const check_step = b.step("check", "Run all checks");

You can see available steps with:

zig build --help

Your named steps appear in that help output.

The Important Idea

A build.zig file describes a graph of work.

Each step is a node in that graph. Dependencies are edges between nodes.

You do not write a long script that says “do this, then this, then this.” Instead, you tell Zig what each step needs.

That gives you a build that is easier to extend.

For a beginner, remember this pattern:

const step = b.step("name", "Description");
step.dependOn(&some_other_step.step);

That is the basic shape of custom build logic in Zig.