Systems programming usually asks us to choose which kind of complexity we prefer. C offers a small language and direct access to the machine, but leaves many correctness checks to discipline. C++ adds powerful abstractions, along with a large language surface and complicated build environments. Languages with managed runtimes remove much of the memory burden, but make allocation, latency, and deployment less explicit.
Zig takes a different position: keep control over memory and data layout, then make that control visible in ordinary code. There is no hidden garbage collector, no preprocessor, no exception mechanism, and no mandatory runtime. The language instead provides explicit allocators, error unions, optional values, compile-time evaluation, and a toolchain designed for reproducible cross-compilation.
This is not a claim that Zig makes low-level programming easy. The machine is still the machine. Zig's value is that it tries to make difficult operations legible.
The Design in One Picture
Zig keeps the path from source code to machine behavior deliberately short:
compile-time execution
|
v
Zig source ---> typed program ---> machine code
| | |
| | +-- no required VM or GC
| +-- explicit errors and optionals
+-- explicit allocation and C imports
The recurring theme is that important costs should have a visible owner. If a function allocates, it usually receives an allocator. If it can fail, its return type says so. If a value may be absent, the type records that fact.
One rough model for the latency of a systems operation is
\[ T_{total} = T_{work} + T_{allocation} + T_{copy} + T_{waiting}. \]
High-level abstractions sometimes hide the last three terms. Zig does not make them disappear; it gives the program a vocabulary for controlling them.
A Small First Program
A Zig program starts with an import and a public entry point:
const std = @import("std");
pub fn main() void {
const language = "Zig";
std.debug.print("Hello from {s}!\n", .{language});
}
Save it as hello.zig and run it directly:
zig run hello.zig
Or build a native executable:
zig build-exe hello.zig -O ReleaseSafe ./hello
The optimization mode is part of the build decision. Debug prioritizes
diagnostics, ReleaseSafe keeps safety checks while optimizing,
ReleaseFast prioritizes speed, and ReleaseSmall prioritizes binary size.
That makes the safety/performance choice explicit instead of burying it in a
collection of unrelated compiler flags.
Errors Are Values
Zig functions do not throw exceptions. A function that may fail returns an
error union, written E!T: either an error from E or a successful value of
type T.
const MathError = error{
DivisionByZero,
};
fn divide(numerator: i64, denominator: i64) MathError!i64 {
if (denominator == 0) return error.DivisionByZero;
return @divTrunc(numerator, denominator);
}
pub fn main() !void {
const result = try divide(84, 2);
std.debug.print("result = {d}\n", .{result});
}
The try keyword is compact, but not magical. It means: unwrap the success
value, or return the error from the current function. A caller that can recover
may use catch instead:
const result = divide(10, 0) catch |err| switch (err) {
error.DivisionByZero => 0,
};
This preserves context in the type system and keeps failure paths visible in the control flow.
Absence Is Different From Failure
An optional ?T represents either a value of type T or null. It should be
used when absence is expected rather than exceptional.
fn findEven(values: []const i32) ?i32 {
for (values) |value| {
if (@mod(value, 2) == 0) return value;
}
return null;
}
pub fn main() void {
const values = [_]i32{ 1, 3, 8, 13 };
if (findEven(&values)) |value| {
std.debug.print("first even value: {d}\n", .{value});
} else {
std.debug.print("no even value\n", .{});
}
}
Separating ?T from !T is useful design pressure. "No matching item" and
"the file could not be read" are different outcomes and should not be forced
through the same mechanism.
Memory Has an Owner
Zig has no default allocator hidden behind collection operations. Code that
needs dynamic memory typically accepts a std.mem.Allocator from its caller.
const std = @import("std");
fn sumSquares(allocator: std.mem.Allocator, values: []const i64) !i64 {
const squares = try allocator.alloc(i64, values.len);
defer allocator.free(squares);
for (values, 0..) |value, index| {
squares[index] = value * value;
}
var total: i64 = 0;
for (squares) |square| total += square;
return total;
}
The function does not care whether memory comes from a general-purpose heap, an arena, a fixed buffer, or a testing allocator. The caller chooses the allocation strategy, while the callee states that it needs one.
defer runs when the current scope exits. errdefer is the related tool for
cleanup that should happen only when the scope returns an error. Together they
keep acquisition and cleanup close:
caller chooses allocator
|
v
+--------------+
| allocate |---- failure ---> return error
+--------------+
|
v
+--------------+
| use memory |
+--------------+
|
v
defer cleanup on every scope exit
Explicit allocation is more typing than calling a global allocator. It also makes lifetimes testable and allows an entire subsystem to switch memory strategies without rewriting its internal logic.
Slices Make Bounds Visible
A pointer identifies an address. A slice, written []T or []const T, carries
both a pointer and a length. This gives ordinary iteration enough information
for bounds checking.
fn checksum(bytes: []const u8) u32 {
var total: u32 = 0;
for (bytes) |byte| {
total +%= byte;
}
return total;
}
The +%= operator requests wrapping addition explicitly. Zig distinguishes
ordinary arithmetic from wrapping arithmetic because overflow behavior is an
important part of low-level code, not a detail to infer later.
Conceptually, a slice is a two-field view:
\[ \text{Slice}(T) = (\text{pointer}: *T,\; \text{length}: \mathrm{usize}). \]
It does not own the underlying memory. The lifetime still belongs to whatever created the array or allocation.
Compile-Time Programming Without a Preprocessor
comptime allows normal Zig code to run during compilation. Types are values
at compile time, which makes generic functions possible without a separate
template language.
fn maximum(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
test "maximum works for several numeric types" {
const std = @import("std");
try std.testing.expectEqual(@as(i32, 9), maximum(i32, 4, 9));
try std.testing.expectEqual(@as(f64, 2.5), maximum(f64, 2.5, 1.0));
}
The compiler specializes maximum for each supplied type. Invalid operations
fail during compilation. The same mechanism can inspect type information,
construct data structures, generate lookup tables, and validate configuration
without textual macros.
This power deserves restraint. Compile-time code is still code that someone must understand. Use it to remove repetition or enforce an invariant, not to hide a straightforward runtime operation behind metaprogramming.
Data-Oriented Types
Structs, enums, and tagged unions make representation choices direct. A tagged union can model a value that is exactly one of several variants:
const Token = union(enum) {
number: i64,
plus,
minus,
};
fn evaluate(token: Token) i64 {
return switch (token) {
.number => |value| value,
.plus => 1,
.minus => -1,
};
}
The switch is exhaustive. Adding another token variant forces relevant
switches to account for it, turning a model change into a compile-time task
instead of a latent production bug.
Data layout matters because processors move cache lines, not object graphs. A rough upper bound for sequential traversal is
\[ T \approx \frac{N \cdot S}{B}, \]
where \(N\) is the element count, \(S\) is bytes per element, and \(B\) is effective memory bandwidth. Smaller, contiguous representations often matter more than clever instruction-level optimization.
The following TikZ source captures the contrast for a PDF export of this Org document:
\begin{tikzpicture}[
node distance=8mm,
box/.style={draw, rounded corners, minimum width=16mm, minimum height=7mm}
]
\node[box] (a1) {item 1};
\node[box, right=of a1] (a2) {item 2};
\node[box, right=of a2] (a3) {item 3};
\draw[->] (a1) -- (a2);
\draw[->] (a2) -- (a3);
\node[below=5mm of a2] {contiguous traversal};
\end{tikzpicture}
C Interoperability Is a Migration Strategy
Zig can import C headers directly rather than requiring a separate binding generator for every library.
const c = @cImport({
@cInclude("stdio.h");
});
pub fn main() void {
_ = c.puts("Hello through libc");
}
Compile and link it with libc:
zig build-exe hello_c.zig -lc
Interoperability changes adoption from an all-or-nothing rewrite into an incremental decision. A Zig executable can call an established C library, and a Zig library can expose a C ABI to existing applications. Unsafe boundaries still need careful review, but they can remain narrow and explicit.
Cross-Compilation Is Part of the Toolchain
Cross-compiling traditionally means assembling a compiler, linker, target headers, system libraries, and matching flags. Zig packages these concerns behind a consistent target model.
# Build on macOS for 64-bit Linux using the GNU ABI. zig build-exe src/main.zig -O ReleaseSafe -target x86_64-linux-gnu # Build for 64-bit Windows. zig build-exe src/main.zig -O ReleaseSafe -target x86_64-windows-gnu # Inspect supported targets for the installed toolchain. zig targets
The result is especially useful for command-line tools, build utilities, and small services that need predictable artifacts for several platforms. Cross-compilation does not replace testing on the target operating system, but it removes much of the ceremony required to produce the binary in the first place.
Tests Live Beside the Code
Tests are language-level declarations and can sit next to the behavior they verify:
const std = @import("std");
fn double(value: u32) u32 {
return value * 2;
}
test "double multiplies by two" {
try std.testing.expectEqual(@as(u32, 42), double(21));
}
test "allocation is released" {
const allocator = std.testing.allocator;
const bytes = try allocator.alloc(u8, 128);
defer allocator.free(bytes);
try std.testing.expectEqual(@as(usize, 128), bytes.len);
}
Run the file's tests with:
zig test example.zig
The testing allocator reports leaked allocations, making ownership mistakes observable in small tests rather than after long-running production use.
Performance Still Requires Measurement
Explicit control creates opportunities; it does not guarantee fast software. Algorithm choice, cache behavior, system calls, lock contention, and I/O patterns still dominate most real programs.
Amdahl's law is a useful defense against optimizing the wrong section:
\[ S(p, s) = \frac{1}{(1-p) + \frac{p}{s}}, \]
where \(p\) is the fraction of runtime improved and \(s\) is that section's speedup. Making five percent of a program ten times faster produces only about a five percent total improvement. Measure first, keep representative inputs, and compare end-to-end behavior rather than isolated instruction counts.
A practical loop is short:
- State the performance goal and acceptable tradeoff.
- Measure a representative workload.
- Locate the dominant cost.
- Change one thing.
- Verify correctness and measure again.
Where Zig Fits Well
Zig is a compelling option when control over resources and deployment is part of the problem:
- Command-line tools distributed as native binaries.
- Embedded software and constrained environments.
- Games, engines, and data-oriented simulation code.
- Libraries that need a stable C-compatible boundary.
- Infrastructure components where predictable latency matters.
- Build tooling that must cross-compile C or C++ dependencies.
It may be a poor fit when a team depends on a mature framework ecosystem, when rapid product iteration matters more than resource control, or when the available libraries would have to be built from scratch. Zig is still evolving before a 1.0 release, so compiler upgrades can require source changes. Pinning the compiler version in development and CI is part of maintaining a Zig project today.
A Sensible Learning Path
The best first Zig project is small enough to finish but low-level enough to exercise the language's model.
- Write a command-line program that parses a file.
- Represent expected failures with error unions.
- Pass an allocator into the code that needs dynamic memory.
- Add tests that use
std.testing.allocator. - Produce a binary for another target.
- Only then explore advanced reflection and compile-time generation.
A file indexer, binary format inspector, checksum utility, or tiny HTTP parser is more instructive than beginning with a large web application. These projects expose byte slices, ownership, errors, and system interfaces without requiring a large dependency graph.
The Useful Constraint
Zig's most important feature is not a single keyword. It is the constraint that consequential behavior should be apparent in the program. Allocation is an argument. Failure is a return type. Absence is an optional. Generic code is ordinary code evaluated at compile time. Foreign interfaces are visible at the boundary.
That explicitness can feel demanding because it asks design questions early: who owns this memory, how can this operation fail, and what representation does this data need? Those questions exist in every systems program. Zig's approach is to keep them in the source code, where they can be reviewed, tested, and improved.
