Contents

Zig 1.0 Tutorial: Build a Systems Programming Project Without C

Zig is a modern systems language built to replace C. It keeps manual memory management and zero hidden control flow: no garbage collector, no runtime, and one statically-linked binary that runs anywhere. Install Zig from ziglang.org/download , scaffold a project with zig init, and you’ll have a working CLI tool in about 50 lines using comptime, error unions, and first-class C interop. The killer feature: zig build-exe -target x86_64-linux-musl cross-compiles to any target from any host with zero toolchain setup.

What Zig Is and Why It Finally Matters in 2026

Before you open a terminal, you need to know what Zig is, how it differs from Rust and C, and why the 2026 release timeline turned it from a hobby project into a production option. This section sets expectations. If you want a garbage-collected language, Zig isn’t it.

Image: Wikimedia Commons

Zig rests on three core promises: no hidden control flow, no hidden allocations, and solid error handling. No hidden control flow means no exceptions, no operator overloading, and no destructors running behind your back. If you see a function call, it’s a function call. If you see an assignment, it’s an assignment.

No hidden allocations means every allocator is passed in. If a function needs memory, it takes an Allocator as an argument. Error unions round it out. They make it impossible to ignore an error by accident.

Zig often gets called “simpler than Rust” because it skips the borrow checker and lifetimes. Instead, it leans on manual memory management plus the defer keyword to clean up resources. That makes it easy to pick up for C programmers who want more safety and better tooling.

The 2026 state of Zig sits on the 0.15.x release line. It has locked down most of the core features. A true 1.0 is still the goal. Still, the work done in 2025 and early 2026 makes Zig a fit for production systems where speed and binary size are key.

Zig also works as a drop-in C/C++ compiler. The zig cc and zig c++ commands ship the full LLVM toolchain. They can replace gcc or clang in existing C projects. That means you can use Zig’s cross-compilation perks even if you haven’t written a line of Zig yet.

When is Zig the right tool? It shines in embedded systems, game engines, WebAssembly (WASM), and replacing legacy C deps. It’s a poor fit for high-level web apps or data science. Languages with higher-level abstractions and garbage collection (Go, Python) ship faster there.

Installing Zig and Scaffolding Your First Project

To get started, install the current stable version. In early 2026, that’s 0.15.2 .

Installation

  • Linux: Grab the tarball from the downloads page, unpack it, and add the binary to your PATH.
  • macOS: Use the zig Homebrew formula: brew install zig.
  • Windows: Use the winget package: winget install zig.

For editor support, you’ll want the Zig Language Server (zls) . It gives you autocomplete, go-to-definition, and live error checks. Install it via the VS Code Zig extension, or wire it into Neovim with nvim-lspconfig.

Image: ZLS on GitHub

Scaffolding

Running zig init in a new directory scaffolds a project with a standard layout:

  • build.zig: The build script.
  • build.zig.zon: The package manifest (ZON stands for Zig Object Notation).
  • src/main.zig: The entry point for your application.
  • src/root.zig: The library entry point.

The Zig build system is one of the language’s best parts. build.zig replaces Makefiles, CMake, and shell scripts with a clean, declarative Zig syntax. Run your first project with zig build run. It compiles the code and runs the binary. Artifacts land in zig-out/.

Core Language Features: Comptime, Error Unions, and Optionals

Three features define idiomatic Zig code: comptime, error unions, and optional types.

Comptime

comptime is compile-time code execution. It replaces macros, generics, and templates with plain Zig functions that run at compile time. The payoff: rich abstractions with no runtime cost. A generic container in Zig is just a function that takes a type as a comptime argument and returns a struct:

fn List(comptime T: type) type {
    return struct {
        items: []T,
        len: usize,
    };
}

Error Unions

Error unions (!T) give you explicit, stack-allocated error handling. Exceptions can fly from anywhere and get caught much later. Zig forces you to handle errors or pass them up the stack on purpose. The try keyword is a shorthand: it returns the error if one shows up. catch lets you supply a default value or handle the error in place. This fits kernel and embedded code far better than C’s errno dance.

Optionals

Optional types (?T) wipe out the risk of null pointer crashes. You can’t use an optional value directly. You must unwrap it with if (optional) |value|, or give it a fallback with orelse.

Slices and Pointers

Slices ([]T) are how Zig handles strings and buffers. A slice is just a pointer plus a length. It’s safer and easier than raw pointers. The defer and errdefer keywords clean up without RAII destructors. Memory and file handles get freed even if an error fires.

Image: Ziggy mascot on ziglang.org

Building a Real CLI Tool: A File Search Utility in 80 Lines

Theory sticks better with code. Let’s build a grep-like tool that scans a directory for a pattern. (For a Go take on the same problem, see building a CLI tool with Cobra and Bubble Tea.) It puts allocators, error unions, and the standard library to work.

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 < 3) {
        std.debug.print("Usage: {s} <pattern> <path>\n", .{args[0]});
        return;
    }

    const pattern = args[1];
    const search_path = args[2];

    var dir = try std.fs.cwd().openDir(search_path, .{ .iterate = true });
    defer dir.close();

    var walker = try dir.walk(allocator);
    defer walker.deinit();

    while (try walker.next()) |entry| {
        if (entry.kind == .file) {
            try searchInFile(allocator, entry.dir, entry.basename, pattern);
        }
    }
}

fn searchInFile(allocator: std.mem.Allocator, dir: std.fs.Dir, filename: []const u8, pattern: []const u8) !void {
    const file = try dir.openFile(filename, .{});
    defer file.close();

    var buf_reader = std.io.bufferedReader(file.reader());
    var in_stream = buf_reader.reader();

    var buf: [1024]u8 = undefined;
    var line_num: usize = 1;
    while (try in_stream.readUntilDelimiterOrEof(&buf, '\n')) |line| {
        if (std.mem.indexOf(u8, line, pattern) != null) {
            std.debug.print("{s}:{d}: {s}\n", .{ filename, line_num, line });
        }
        line_num += 1;
    }
}

The tool parses CLI args with std.process.argsAlloc. It walks the tree with std.fs.Dir.walk. It reads files line by line through a buffered reader. Note how every failure path uses try, and every resource gets freed with defer.

Cross-Compiling and Shipping a Single Static Binary

Zig’s standout trick is one command that builds a static binary for any target. Other languages need a heavy toolchain setup for cross-compiling. Zig ships with all of it built in.

Cross-Compilation Commands

To compile our search utility for different platforms from a single machine:

  • Linux x86_64: zig build-exe src/main.zig -target x86_64-linux-musl -O ReleaseSmall
  • Linux aarch64: zig build-exe src/main.zig -target aarch64-linux-musl -O ReleaseSmall
  • macOS Apple Silicon: zig build-exe src/main.zig -target aarch64-macos -O ReleaseSmall
  • Windows: zig build-exe src/main.zig -target x86_64-windows-gnu -O ReleaseSmall

The -target x86_64-linux-musl flag is the one to know. It builds a fully static binary with no link to the host’s glibc version. That kills the “it works on my machine” problem at deploy time.

Performance and Binary Size (2026 Comparison)

Zig tools keep posting strong numbers in 2026. The table below stacks a simple CLI tool against other languages:

LanguageTypical Binary SizeCompile Time (Incremental)Key Advantage
C~10 KBExtremely FastBaseline for minimalism
Zig~50–150 KB60ms (0.15.x)Zero hidden control flow
Rust~300 KB – 1 MB1.5s – 5sMemory safety without GC
Go~2–15 MB100ms – 500msDeveloper velocity

For heavy file work, new Zig tools use SIMD and zero-allocation designs to hit NVMe speed limits (3+ GB/s). They often beat tuned Rust tools on raw throughput.

The Zig Package Manager: build.zig.zon

Starting with 0.11 and polished in 0.15.x, the Zig package manager is decentralized and uses ZON files. There’s no central registry. You fetch deps straight from URLs or local paths.

A typical build.zig.zon looks like this:

.{
    .name = .my_project,
    .version = "0.1.0",
    .minimum_zig_version = "0.15.0",
    .dependencies = .{
        .zap = .{
            .url = "https://github.com/zigzap/zap/archive/refs/tags/v0.11.0.tar.gz",
            .hash = "1220...",
        },
    },
    .paths = .{
        "build.zig",
        "build.zig.zon",
        "src",
    },
}

Add deps with zig fetch --save <URL>. The command grabs the package, hashes it with SHA-256, and updates your .zon file on its own.

Testing Your Zig Code

Zig treats testing as first class. You write tests right in your source files with the test keyword:

const std = @import("std");
const expect = std.testing.expect;

test "basic addition" {
    try expect(1 + 1 == 2);
}

Run zig test src/main.zig to fire those tests. The std.testing namespace gives you helpers for equality checks, leak reports (via the LeakCheckingAllocator), and proving that code fails with the expected error.

Conclusion

Zig in 2026 has grown from a test bed into a solid tool for systems work. Its focus on simple design, speed, and cross-compilation fits modern infrastructure. Whether you’re swapping out a legacy C library or building a fast new CLI tool, Zig hands you the control you want. It skips the baggage of older languages. If you’re weighing Zig against Rust for kernel or driver work, it’s worth reading what Rust’s stabilization in the Linux kernel means for the broader systems programming scene. With the 0.15.x series locked down, there’s never been a better time to start building without C.