A build option is a value passed from the command line into build.zig.
Build options let one project support several build configurations without editing source code every time. You can use them for feature flags, version strings, debug behavior, optional integrations, platform choices, and other settings.
A build option is usually passed with -D:
zig build -Denable-logging=true
zig build -Dapp-version=0.1.0
zig build -Dmax-clients=128The -D means “define this build option.”
Standard Options
You have already seen two standard options:
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});These enable common command-line options.
For example:
zig build -Dtarget=x86_64-linux
zig build -Doptimize=ReleaseFastYou did not manually define target or optimize. Zig provides helper functions for these because almost every project needs them.
The target option controls where the program will run.
The optimize option controls how the program is compiled.
Common optimization modes are:
Debug
ReleaseSafe
ReleaseFast
ReleaseSmallA debug build is best while developing. It keeps safety checks and is easier to debug.
A release build is for final output. Depending on the mode, it may prefer speed, size, or safety.
Custom Build Options
You can define your own option with b.option.
For example:
const enable_logging = b.option(
bool,
"enable-logging",
"Enable logging output",
) orelse false;This creates a boolean build option named:
enable-loggingThe type is:
boolThe default value is:
falseNow the user can run:
zig build -Denable-logging=trueInside build.zig, enable_logging will be true.
Without that command-line option, it will be false.
The Shape of b.option
The basic shape is:
const value = b.option(T, "name", "help text") orelse default_value;The first argument is the type.
The second argument is the command-line name.
The third argument is the help text.
The expression returns an optional value. That is why we use orelse.
orelse falsemeans: if the user did not provide the option, use false.
For a string option:
const version = b.option(
[]const u8,
"version",
"Application version string",
) orelse "dev";Now this works:
zig build -Dversion=1.2.3For an integer option:
const max_clients = b.option(
u32,
"max-clients",
"Maximum number of clients",
) orelse 64;Now this works:
zig build -Dmax-clients=128Passing Options to Source Code
Defining an option in build.zig does not automatically make it visible in src/main.zig.
To pass build options into your program, create an options module.
const options = b.addOptions();
options.addOption(bool, "enable_logging", enable_logging);
options.addOption([]const u8, "version", version);
options.addOption(u32, "max_clients", max_clients);Then attach it to the executable:
exe.root_module.addOptions("build_options", options);Now your Zig source code can import it:
const build_options = @import("build_options");And use the values:
if (build_options.enable_logging) {
std.debug.print("logging enabled\n", .{});
}
std.debug.print("version: {s}\n", .{build_options.version});
std.debug.print("max clients: {}\n", .{build_options.max_clients});This is a common Zig pattern.
The build file decides the configuration. The source code imports that configuration as a normal module.
Full Example
Here is a complete build.zig using custom build options:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const enable_logging = b.option(
bool,
"enable-logging",
"Enable logging output",
) orelse false;
const version = b.option(
[]const u8,
"version",
"Application version string",
) orelse "dev";
const max_clients = b.option(
u32,
"max-clients",
"Maximum number of clients",
) orelse 64;
const options = b.addOptions();
options.addOption(bool, "enable_logging", enable_logging);
options.addOption([]const u8, "version", version);
options.addOption(u32, "max_clients", max_clients);
const exe = b.addExecutable(.{
.name = "server",
.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);
}And here is src/main.zig:
const std = @import("std");
const build_options = @import("build_options");
pub fn main() void {
std.debug.print("version: {s}\n", .{build_options.version});
std.debug.print("max clients: {}\n", .{build_options.max_clients});
if (build_options.enable_logging) {
std.debug.print("logging enabled\n", .{});
} else {
std.debug.print("logging disabled\n", .{});
}
}Run it normally:
zig build runThen run it with options:
zig build run -Dversion=1.0.0 -Dmax-clients=128 -Denable-logging=trueThe exact command depends on whether your build.zig has a run step. If it only installs the executable, use:
zig build -Dversion=1.0.0 -Dmax-clients=128 -Denable-logging=trueBuild Options Are Compile-Time Values
Values from an options module are known at compile time.
That matters.
This means the compiler can remove unused branches when a feature is disabled.
For example:
if (build_options.enable_logging) {
std.debug.print("debug log\n", .{});
}If enable_logging is false at compile time, the compiler can remove that branch from the final program.
This is useful for feature flags.
You can build one binary with logging enabled and another binary with logging disabled, from the same source code.
Option Names
Command-line option names often use hyphens:
enable-logging
max-clientsZig field names use underscores:
enable_logging
max_clientsSo this is normal:
const enable_logging = b.option(bool, "enable-logging", "Enable logging") orelse false;
options.addOption(bool, "enable_logging", enable_logging);The command-line name is for users.
The source-code name is for Zig code.
Showing Options in Help
The help text appears when the user runs:
zig build --helpFor example:
const version = b.option(
[]const u8,
"version",
"Application version string",
) orelse "dev";This gives the user a discoverable option:
-Dversion=[string] Application version stringGood build options should have clear help text. A future reader should understand what the option does without reading the whole build file.
Options for Conditional Dependencies
Build options can control whether a dependency is used.
For example:
const use_tls = b.option(
bool,
"tls",
"Enable TLS support",
) orelse false;Then:
if (use_tls) {
const tls_dep = b.dependency("tls_library", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("tls_library", tls_dep.module("tls_library"));
}Now TLS support is optional:
zig build -Dtls=trueThis pattern is useful when a dependency is large, platform-specific, or only needed for some builds.
Options for Conditional Compilation
Because options are compile-time values, source code can use them to include or exclude code.
const build_options = @import("build_options");
pub fn connect() void {
if (build_options.tls) {
connectTls();
} else {
connectPlain();
}
}This gives you a simple configuration system without preprocessor macros.
In C, you might write:
#ifdef ENABLE_TLSIn Zig, you usually pass a typed build option and use a normal if.
That is easier to read because it stays inside the language.
Avoid Too Many Build Options
Build options are useful, but too many options make a project harder to test.
Every boolean option doubles the number of possible configurations.
With one boolean option, there are two configurations.
With two boolean options, there are four.
With five boolean options, there are thirty-two.
So use build options for real build-time differences, not for ordinary runtime settings.
Good build options:
enable TLS support
embed version string
choose release behavior
enable expensive debug checks
select optional backendPoor build options:
username
port number for normal local use
theme color
every small runtime settingThose usually belong in a configuration file, command-line argument, or environment variable.
Build-Time vs Runtime Configuration
A build-time option changes the compiled program.
A runtime option changes how the program behaves when it runs.
Build-time example:
zig build -Dsqlite=trueThis might include SQLite support in the binary.
Runtime example:
./server --database app.dbThis chooses which database file to open when the program runs.
Use build-time options when you need to change the binary itself.
Use runtime options when you only need to change behavior for one run.
Common Mistakes
A common mistake is defining an option but never passing it to source code.
This works only inside build.zig:
const enable_logging = b.option(bool, "enable-logging", "Enable logging") orelse false;To use it in src/main.zig, you still need:
const options = b.addOptions();
options.addOption(bool, "enable_logging", enable_logging);
exe.root_module.addOptions("build_options", options);Another common mistake is using the wrong option type.
This expects a boolean:
b.option(bool, "debug-ui", "Enable debug UI")So use:
zig build -Ddebug-ui=truenot:
zig build -Ddebug-ui=yesAnother common mistake is putting secrets into build options.
Do not pass API keys, passwords, or private tokens as build options. They may become embedded in the binary or visible in build logs.
The Important Idea
Build options let your build.zig file receive typed values from the command line.
The basic pattern is:
const value = b.option(T, "name", "help text") orelse default_value;To make the value available to source code, create an options module:
const options = b.addOptions();
options.addOption(T, "field_name", value);
exe.root_module.addOptions("build_options", options);Then import it:
const build_options = @import("build_options");Use build options when the compiled program itself should change. Use runtime options when only one execution should change.