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.
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
zigHomebrew formula:brew install zig. - Windows: Use the
wingetpackage: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.
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.
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:
| Language | Typical Binary Size | Compile Time (Incremental) | Key Advantage |
|---|---|---|---|
| C | ~10 KB | Extremely Fast | Baseline for minimalism |
| Zig | ~50–150 KB | 60ms (0.15.x) | Zero hidden control flow |
| Rust | ~300 KB – 1 MB | 1.5s – 5s | Memory safety without GC |
| Go | ~2–15 MB | 100ms – 500ms | Developer 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.
Botmonster Tech