Write Your First CLI Tool in Zig and Cross-Compile It for Linux, macOS, and Windows
Build a small word-count utility in Zig and ship static, single-file binaries for three operating systems from one machine, no Docker or VMs required.
What you'll build
You'll write wordcount, a tiny CLI that mimics wc (counts lines, words, and characters in a file), then compile it into standalone binaries for Linux, macOS, and Windows using nothing but the Zig toolchain.
Prerequisites
- Zig 0.13.0 exactly. Zig's
build.zigAPI has changed across minor versions, and code that compiles on 0.12 or 0.14 can fail on 0.13. Match the version or expect friction. - A terminal. macOS (Intel or Apple Silicon), Linux, or Windows all work as your build machine, since Zig cross-compiles from any host to any target.
- No C compiler, no libc install, no Docker. Zig ships its own linker and bundles libc sources for the targets we're using.
1. Install Zig and confirm the version
Download the tarball/zip for your OS from https://ziglang.org/download/ and extract it somewhere on your PATH. On macOS you can also use Homebrew, but check the version it installs matches:
brew install zig
Either way, confirm:
zig version
You should see 0.13.0. If not, adjust your PATH so the right binary resolves first.
2. Scaffold the project
mkdir wordcount && cd wordcount
mkdir src
We'll write the two files by hand rather than relying on zig init, since its generated template varies by version and it's easier to reason about a file you typed yourself.
3. Write the CLI logic
Create src/main.zig:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
if (args.len < 2) {
std.debug.print("usage: wordcount <file>\n", .{});
return;
}
const file = try std.fs.cwd().openFile(args[1], .{});
defer file.close();
const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
defer allocator.free(content);
var lines: usize = 0;
var words: usize = 0;
var in_word = false;
for (content) |c| {
if (c == '\n') lines += 1;
if (c == ' ' or c == '\n' or c == '\t') {
in_word = false;
} else if (!in_word) {
in_word = true;
words += 1;
}
}
const stdout = std.io.getStdOut().writer();
try stdout.print("{d} {d} {d} {s}\n", .{ lines, words, content.len, args[1] });
}
This reads a whole file into memory, walks the bytes once, and prints lines/words/chars, same output shape as wc.
4. Wire up build.zig
Create build.zig in the project root:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "wordcount",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
b.installArtifact(exe);
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 app");
run_step.dependOn(&run_cmd.step);
}
standardTargetOptions is what makes cross-compilation possible: it exposes a -Dtarget= flag on the CLI without you writing any target-handling code.
5. Build and run natively
zig build run -- src/main.zig
You should see something like 18 30 512 src/main.zig. Compare against the real thing:
wc src/main.zig
The numbers should match (order may differ; wc prints lines/words/chars in that order too).
6. Cross-compile for Linux, macOS, and Windows
Make an output folder and build one target at a time. Each zig build invocation overwrites zig-out/bin/, so copy the result out before moving to the next target.
mkdir -p dist
# Linux (musl = fully static, no glibc version dependency)
zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseFast
cp zig-out/bin/wordcount dist/wordcount-linux-x86_64
# macOS (Apple Silicon)
zig build -Dtarget=aarch64-macos -Doptimize=ReleaseFast
cp zig-out/bin/wordcount dist/wordcount-macos-arm64
# Windows
zig build -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseFast
cp zig-out/bin/wordcount.exe dist/wordcount-windows-x86_64.exe
A few notes on target strings:
| Target | Why |
|---|---|
x86_64-linux-musl |
Static libc, runs on any Linux distro regardless of glibc version. Use -linux-gnu only if you specifically need glibc. |
aarch64-macos / x86_64-macos |
macOS never links fully static (Apple doesn't support it), but linking only against libSystem, which ships on every Mac, is effectively "no install needed." |
x86_64-windows-gnu |
The -gnu suffix uses Zig's bundled MinGW-w64 headers, fully self-contained. Avoid -msvc here, it needs the actual Windows SDK, which defeats the point. |
Verify it works
Check each binary's format without running the ones that don't match your host:
file dist/wordcount-linux-x86_64
# ELF 64-bit LSB executable, x86-64, statically linked
file dist/wordcount-macos-arm64
# Mach-O 64-bit executable arm64
file dist/wordcount-windows-x86_64.exe
# PE32+ executable (console) x86-64, for MS Windows
On your native OS, run the matching binary directly against a real file and confirm the counts against wc. For Windows, test in an actual Windows box or wine, since a Linux host can't execute a PE binary.
Troubleshooting
error: no field named 'root_source_file' in struct: your Zig version doesn't match 0.13.0'saddExecutablesignature. Runzig versionand reinstall the exact release.zig: command not found: the extracted Zig folder isn't onPATH. Add it (export PATH="$PATH:/path/to/zig") and restart your shell.- Linux binary segfaults on an old distro: you built against
-linux-gnuand the target machine's glibc is older than your build's. Rebuild with-linux-muslfor a static binary with no external libc dependency. FileNotFoundwhen runningwordcount: you're passing a relative path but running from a different directory than expected. Run from the project root or pass an absolute path.
Next steps
For real argument parsing (flags, subcommands, help text) look at the zig-clap package via Zig's package manager (build.zig.zon) instead of hand-rolling std.process.args. To automate this across every push, set up a GitHub Actions matrix that runs the same zig build -Dtarget=... commands on a single ubuntu-latest runner, no separate OS runners needed, since cross-compilation means one machine builds all three. Read std.Build's docs at https://ziglang.org/documentation/0.13.0/std/#std.Build for the full options standardTargetOptions and addExecutable expose.
Priya covers AI frameworks, developer productivity tooling, and the startup ecosystem across South and Southeast Asia, bringing a researcher's rigour and a practitioner's empathy to every story. She is deeply sceptical of benchmarks and asks hard questions so her readers don't have to.
Discussion 0
No comments yet
Be the first to weigh in.