Skip to Content

Zig build time options

Making compile time choices
October 18, 2026 by
Zig build time options
Jim Fitzpatrick

With the last two post on using the Zig build system I though it was time to look at getting build values into the binaries at, well, build time. This is far simpler that you think, and below we will go through two different ways that we may want to get data into the binary at build time.

Defining the use cases

There is two use cases that we can demo. The first will be having the name, and version of the application printed to console. Of course this is simple, but something I find doing with every tool I make. It is the topical information that would be in a --version flag. 

The next case is a bit different. We want to enable a "dev" mode via a build flag. I use such a flag for when I don't want to clean up resources after a tool run. This can be useful for debugging during development, but is also a feature that you may not want in the release builds.

Building the example

For this I will be using zig 0.17.0. This example should work with zig 0.16.0, but just in case be aware. 

Lets start by creating a blank zig project. Change to the directory where you create test projects.  Make a new directory called build_zig, and then change into it.

cd /tmp
mkdir build_zig
cd build_zig 

Next we will create a blank zig project. We will add our custom build steps. 

$ zig init -m
$ tree .
.
├── build.zig
└── build.zig.zon

Now that we have that much done we will create our initial super complex app that will change the world. Create a new directory call src, and then create a file called main.zig. This file will the entry point to our application.

mkdir src
touch src/main.zig

Once the file is create with your favorite editor add the following.

/// src/main.zig
const std = @import("std");

pub fn main() !void {
std.log.info("initial app", .{});
}

We can check that the application runs with zig run.

$ zig run src/main.zig
info: initial app

First use case: Name and version from a file

Now that our app is working lets create the build scripts to add the applications name and version to the build. In the the build.zig we are going to add the following.

/// build.zig
const std = @import("std");
const zon = @import("build.zig.zon");

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

const options b.addOptions();

const name = @tagName(zon.name);
const verson = zon.version;

options.addOption([]const u8, "name", name);
options.addOption([]const u8, "version", version);

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

exe.root_module.addOptions("build_options", options);

b.installArtifact(exe);
}

There is now a lot going on in this build file that we will talk about, but first lets update the src/main.zig to make use of the new options that we have added.

/// src/main.zig
const std = @import("std");
const build_options = @import("build_options");

pub fn main() !void {
std.log.info("Application name: {s}", .{build_options.name});
std.log.info("Version: {s}", .{build_options.name});
}

Before we go over what we did, lets make sure it builds and runs. 

$ zig build
$ tree zig-out/bin
zig-out
└── bin
└── build_zig
$ zig-out/bin/build_zig
info: Application name: build_zig
info: Version: 0.0.1

Now that we know the application is working as expected what did we actual add to the build file.

The first thing that we did was import the contents from the build.zig.zon file. We could have used any zon file here. 

const zon = @import("build.zig.zon");

The next thing that we did, will not directly related to this example, was setting up the build target and its optimization level. This allow us to build on any system. For example I am writing this example of Linux, but it should just work on MacOS and on Windows. I might go deeper into these setting in a later post, but for now this is a "just trust me" bit of configuration.

const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOptions(.{});

Next we define an options object with b.addOptions(), and add the data fields we want to have access to with options.addOption(). Before doing the name, and version are defined as constants. These could have being inlined, but as the name is used later I decided not. As the name field is an Enum in the zon file that needs to be converted to a value we can work with. That is done using @tagName.

const options = b.addOptions();

const name = @tagName(zon.name);
const verson = zon.version;

options.addOptions([]const u8, "name", name);
options.addOptions([]const u8, "verson", version);

The next block is setting up the executable that we will want to build. For this we will set the name of the executable to that found in the build.zig.zon file. The root_module will be our entry point into the application, so that will point at our main source file. And final we also say what system we are targeting, and at what level of optimization. 

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

The second last step that we do is add the options that we defined earlier to the root module in the executable. Here we also define the name that we will import the options by. In this case we are using build_options. 

exe.root_module.addOptions("build_options", options);

Finally, we state that we want to install the executable. Again there is more here that could be explored, but that is for a different post.

b.installArtifact(exe);

Inside the src/main.zig we also made a few changes. While they should explain themselves, lets take a look at them.  The first change is to import the options by the name we defined in the build.zig file.

const build_options = @import("build_options");

Next we use the values from the build options to print the name, and version. As the values could be classed as "string" we use the {s} to format the output. Zig has no type string, everything is a []const u8, that takes some time to get use to. We also removed our initial log statement that was use to test the initial setup.

std.log.info("Application name: {s}", .{build_options.name});
std.log.info("Version: {s}", .{build_options.version});

This has now shown how we can get compile time values into the build from a file. In this use case the file most always be there. In the second case we will look at using a build flag, which will have a default if not provided.

Second Use case: Dev mode build flag

Now we want to add a flag which can be used to enable development features. We would expect a flag to be passed into zig build. So we will expect to call zig build -Ddev. The D is a convention borrowed from C/C++ for defining preprocessing flags. 

Lets jump back into the build.zig file, and the following between the adding of the version options and the defining of build executable.

/// build.zig

...
options.addOptions([]const u8, "version", version);

const dev_mode = b.option(bool, "dev", "Enable dev mode") orelese false;
options.addOptions(bool, "dev_mode", dev_mode);

const exe = b.addExecutable(.{
...

Like the last time lets update the application before viewing the code, because there is also a very interesting thing that is going to happen. So in the src/main.zig we are going to add the following logs to the tail of the application.

/// src/main.zig    

std.log.info("Vesion: {s}", .{build_options.version});
if (build_options.dev_mode) {
std.log.warn("Dev mode enabled", .{});
}

Before we use the flag, or discuss the changes, lets run zig build --help.

$ zig build --help
Usage: /home/boomatang/.zvm/0.17.0/zig build [steps] [options]

Steps:
install (default) Copy build artifacts to prefix path
uninstall Remove build artifacts from prefix path

Project-Specific Options:
-Dtarget=[string] The CPU architecture, OS, and ABI to build for
-Dcpu=[string] Target CPU features to add or subtract
-Dofmt=[string] Target object format
-Ddynamic-linker=[string] Path to interpreter on the target system
-Doptimize=[enum] Prioritize performance, safety, or binary size
Supported Values:
debug
safe
fast
small
-Ddev=[bool] Enable dev mode

...

Now lets go back over what we have done before we run own new application so we can understand what we expect to happen. We will start by looking at the output from zig build --help. There is more content that has being left out, but at the bottom of that snippet we see our new flag, -Ddev=[boo]. This gives us the type that is expected, and a describe of what that flag is for. Really we should have putting a better description, but it is a start. I want to highlight that the flag is documented under the build help and we didn't need to do any magic. That is a dig a Make and how you would set flags there. 

Working backwards we can now look at the changes we made in src/main.zig. Here we create a very simple if block that checks the value of the build_options.dev_mode. If we are in a dev mode, we log a warning saying dev mode is enabled. This is a very simple example, but you can see how this could be doing, or not doing some action based on that flag.

if (build_options.dev_mode) {
std.log.warn("Dev mode enabled", .{});
}

The next part to look at is the changes to the build.zig. In the build file we define our flag. In our case it is a simple boolean, but this could a be a string, enum or some kind of number. Using b.option we define the flag, including its type and description. It even adds the default value for the flag, in this case false. Next it is added to the options that we configured during the first case. The use of the orelese allows us to define the default if the flag is not provided. Of course more complex logic could have being used to define the default.

const dev_mode = b.option(bool, "dev", "Enable dev mode") orelse false;
options.addOption(bool, "dev_mode", dev_mode);

Now lets build and run the application with, and without the the flag enable. Doing the non flag version first we will see the very same output from the first version. While on the version with the flag enabled we get the warning log message.

$ zig build
$ zig-out/bin/build_zig
info: Application name: build_zig
info: Version: 0.0.1
$ zig build -Ddev
$ zig-out/bin/build_zig
info: Application name: build_zig
info: Version: 0.0.1
warning: Dev mode enabled

This example shows how simple it can be to add build flags, but also documenting those flags. Being able to use --help and see this information is usefully for anyone working on the project. I would bet also there would be away to get tab completion in shells also, but that is a different rabbit hole to go down.

What take ways are there here

A lot happened in this post, with out actually that much happening. What I think on one key take way may be is now simple it can be to add flags to enable different features with in your application at compile time. It can be far clearer where the values are coming from. All the operators I work on include the git commit and if it was compiled from a dirty branch as the first set of logs. In very code base there is a comment in the main.go saying that these values are set at compile time. In the zig world that would not be required as those options would come via an import, build_options in our example, and that would be defined in the build.zig. 

The other take away that I like is how natural it feels to use the --help to find out what flags you can set. Even the defining of the flag, it's simple. That is a word that we know not to use lightly. 

I am also very aware that this is a very simple application that does not have real complicity. I don't know how maintain able that would on a large project over years. But I do think I would like to find out, and with seen the sprawl that some of our projects have with Make targets it has is there a better way.

Zig build time options
Jim Fitzpatrick October 18, 2026
Share this post
Our blogs
Archive
Managing external dependencies in Zig
A fresh look on how things can be done.