Contents

Zig 1.0 Tutorial: Build a Systems Programming Project Without C

Zig is a modern systems programming language designed to replace C while keeping manual memory management and zero hidden control flow — no garbage collector, no runtime, and a single statically-linked binary that runs anywhere. You can install Zig from ziglang.org/download , scaffold a project with zig init, and have a working command-line tool in about 50 lines that takes advantage of Zig’s 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 opening a terminal, readers need to understand what Zig is, how it differs from Rust and C, and why the 2026 release timeline has turned it from a hobby project into a production option. This section sets expectations and filters readers who are looking for a garbage-collected language.

Image: Wikimedia Commons

Zig’s three core promises are foundational: no hidden control flow, no hidden allocations, and robust error handling. No hidden control flow means there are no exceptions, no operator overloading, and no destructors running behind your back. If you see a function call, it is a function call; if you see an assignment, it is an assignment. No hidden allocations means every allocator is passed explicitly. If a function needs to allocate memory, it takes an Allocator as an argument. Finally, robust error handling is achieved via error unions, making it impossible to ignore an error by accident.

Why Zig is often described as “simpler than Rust ” is because it avoids the complexity of the borrow checker and lifetimes. Instead, it relies on manual memory management with the defer keyword to ensure resources are cleaned up. This makes it much more accessible for C programmers who are used to manual management but want more safety and better tooling.

The 2026 state of Zig is marked by the 0.15.x release line, which has stabilized many of the language’s core features. While the genuine 1.0 is still the ultimate goal, the stabilization efforts in 2025 and early 2026 have made it a viable production option for systems where performance and binary size are paramount.

Zig also serves as a drop-in C/C++ compiler. The commands zig cc and zig c++ ship the full LLVM toolchain and can replace gcc or clang for existing C projects. This allows developers to use Zig’s powerful cross-compilation capabilities even if they aren’t writing a single line of Zig code yet. When is Zig the right tool? It shines in embedded systems, game engines, WebAssembly (WASM), and replacing legacy C dependencies. However, it might not be the first choice for high-level web applications or data science, where languages with higher-level abstractions and garbage collection (like Go or Python) often provide better developer velocity.

Installing Zig and Scaffolding Your First Project

To get started, you need to install the current stable version, which as of early 2026 is 0.15.2 .

Installation

  • Linux: Download the official tarball from the downloads page, extract 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 integration, the Zig Language Server (zls) is essential. It provides autocompletion, go-to-definition, and real-time error checking. You can install it via VS Code’s Zig extension or integrate it with Neovim using 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 its most powerful features. build.zig replaces Makefiles, CMake, and complex shell scripts with a reproducible, declarative Zig-native syntax. You can run your first project with zig build run, which compiles the code and executes the resulting binary. The artifacts are stored in the zig-out/ directory.

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 that replaces macros, generics, and templates with ordinary Zig functions that run at compile time. This allows for powerful abstractions without the runtime cost. For example, 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) provide explicit, stack-allocated error handling. Unlike exceptions, which can be thrown from anywhere and caught much later, Zig requires you to handle errors or explicitly pass them up the stack. The try keyword is a convenient shorthand for returning an error if it occurs, while catch allows you to provide a default value or handle the error locally. This maps much better to kernel and embedded code than C’s errno dance.

Optionals

Optional types (?T) eliminate the risk of null pointer dereferences. You cannot use an optional value directly; you must unwrap it using if (optional) |value| or provide a fallback with orelse.

Slices and Pointers

Slices ([]T) are the foundation of string and buffer handling in Zig. A slice is essentially a pointer and a length, providing safety and convenience over raw pointers. The defer and errdefer keywords allow for cleanup without RAII destructors, ensuring that memory or file handles are released even if an error occurs.

Image: Ziggy mascot on ziglang.org

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

Theory is best reinforced with code. Let’s build a grep-like tool that searches for a pattern in a directory recursively. (For a Go-based take on the same problem, see building a CLI tool with Cobra and Bubble Tea .) This exercises allocators, error unions, and the standard library.

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;
    }
}

This tool parses command-line arguments using std.process.argsAlloc, walks the directory tree with std.fs.Dir.walk, and reads files line-by-line with a buffered reader. Note how every potential failure is handled with try, and every resource is cleaned up with defer.

Cross-Compiling and Shipping a Single Static Binary

Zig’s most striking feature is its ability to produce a static binary for any target with a single command. Unlike other languages that require complex toolchain setups for cross-compiling, Zig ships with everything it needs.

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 particularly useful because it produces a fully static binary that has no dependencies on the host’s glibc version. This solves the “it works on my machine” problem for deployments.

Performance and Binary Size (2026 Comparison)

In 2026, the performance of Zig-based tools continues to impress. The following table compares the characteristics of a simple CLI tool across different 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 massive file processing, new Zig utilities leverage SIMD and zero-allocation architectures to reach NVMe speed limits (3+ GB/s), often outperforming even highly optimized Rust tools in raw throughput.

The Zig Package Manager: build.zig.zon

Starting with version 0.11 and further refined in 0.15.x, the Zig package manager is decentralized and uses ZON files. There is no central registry; you fetch dependencies directly 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",
    },
}

You can add dependencies easily with zig fetch --save <URL>. This command will fetch the package, compute the SHA-256 multihash, and update your .zon file automatically.

Testing Your Zig Code

Zig has first-class support for testing. You can write tests directly in your source files using the test keyword:

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

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

Running zig test src/main.zig executes these tests. The std.testing namespace provides various utilities for checking equality, reporting leaks (using the LeakCheckingAllocator), and verifying that code fails with the expected error.

Conclusion

Zig in 2026 has matured from an experimental language into a robust tool for systems programming. Its focus on simplicity, performance, and cross-compilation makes it an excellent choice for modern infrastructure. Whether you are replacing a legacy C library or building a new high-performance CLI tool, Zig provides the control you need without the baggage of older languages. If you are evaluating Zig against Rust for kernel-level or driver development, it is worth understanding what Rust’s stabilization in the Linux kernel means for the broader systems programming landscape. With its stabilization in the 0.15.x series, there has never been a better time to start building without C.