Skip to content
Dev Tools Beginner Tutorial

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.

Priya Nair
Priya Nair
AI & Developer Experience Writer · Jul 10, 2026 · 9 min read
Write Your First CLI Tool in Zig and Cross-Compile It for Linux, macOS, and Windows

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.zig API 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's addExecutable signature. Run zig version and reinstall the exact release.
  • zig: command not found: the extracted Zig folder isn't on PATH. Add it (export PATH="$PATH:/path/to/zig") and restart your shell.
  • Linux binary segfaults on an old distro: you built against -linux-gnu and the target machine's glibc is older than your build's. Rebuild with -linux-musl for a static binary with no external libc dependency.
  • FileNotFound when running wordcount: 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 Nair
Written by
Priya Nair · AI & Developer Experience Writer

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

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading