Zig: Modern Systems Programming Without a Hidden Control Flow
Zig is a modern systems programming language that prioritizes performance, safety, and simplicity. It has no hidden control flow — no garbage collector, no exceptions, no hidden memory allocations. What you see in the code is exactly what the CPU executes.
In this tutorial, you’ll learn Zig’s design philosophy, manual memory management, comptime (compile-time execution), cross-compilation out of the box, C interop without a separate build system, error handling with error unions, and the Zig build system.
What You’ll Learn
- Zig’s goals and how it differs from C and Rust
- No hidden allocations — explicit memory management
- Comptime — run code at compile time
- Cross-compilation — target any platform from any platform
- C interop — import C headers directly
- Error handling with error unions
- The Zig build system (build.zig)
Why Zig Matters
Zig is the new generation of systems programming. It’s used in Bun (JavaScript runtime), TigerBeetle (financial database), and embedded systems. At DodaTech, Zig’s predictable performance and zero-overhead C interop make it ideal for Durga Antivirus Pro’s low-level file scanning layer.
Learning Path
flowchart LR
A[C/Rust Basics] --> B[Zig Basics<br/>You are here]
B --> C[Comptime]
C --> D[C Interop]
D --> E[Cross-Compilation]
style B fill:#f90,color:#fff
Zig Syntax
Zig syntax is familiar to C and Rust programmers:
const std = @import("std");
// Main function — return type is inferred
pub fn main() !void {
// String (sentinel-terminated slice)
const name = "Alice";
const age: u32 = 30;
// Print to stdout
std.debug.print("Hello, {s}! You are {d} years old.\n", .{name, age});
// Array
const numbers = [_]u32{ 1, 2, 3, 4, 5 };
// For loop
for (numbers) |n| {
if (n % 2 == 0) {
std.debug.print("{d} is even\n", .{n});
}
}
// While loop
var i: u32 = 0;
while (i < 3) : (i += 1) {
std.debug.print("Count: {d}\n", .{i});
}
}Expected output: A greeting, even numbers from the array, then count 0-2.
Memory Management — No Hidden Allocations
Zig has no default allocator. Every allocation is explicit:
const std = @import("std");
pub fn main() !void {
// Choose an allocator
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Allocate an array — explicit allocation
var list = try std.ArrayList(u32).initCapacity(allocator, 5);
defer list.deinit(); // Explicit deallocation
// Append values
try list.append(10);
try list.append(20);
try list.append(30);
// Print
for (list.items) |item| {
std.debug.print("{d}\n", .{item});
}
// 10
// 20
// 30
}Expected behavior: The GeneralPurposeAllocator is created, used for all allocations, then deinitialized. No GC, no hidden mallocs. The defer keyword schedules cleanup when the scope exits.
Comptime — Compile-Time Execution
Zig can run arbitrary code at compile time:
const std = @import("std");
// Compile-time function
fn fibonacci(n: comptime_int) comptime_int {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// Value computed at compile time
const fib10 = comptime fibonacci(10);
// Generic function — type parameter at compile time
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
pub fn main() void {
std.debug.print("Fibonacci(10) = {d}\n", .{fib10});
// Fibonacci(10) = 55
std.debug.print("Max(42, 17) = {d}\n", .{max(i32, 42, 17)});
// Max(42, 17) = 42
std.debug.print("Max(3.14, 2.71) = {d}\n", .{max(f64, 3.14, 2.71)});
// Max(3.14, 2.71) = 3.14
}Expected behavior: fibonacci(10) is evaluated at compile time. The max function is instantiated for both i32 and f64 types at compile time — zero runtime overhead.
Cross-Compilation
Zig cross-compiles to any target from any host, with no additional toolchains:
# Target ARM Linux from x86_64 macOS
zig build-exe -target aarch64-linux hello.zig
# Target WebAssembly
zig build-exe -target wasm32-freestanding hello.zig
# Target Windows from Linux
zig build-exe -target x86_64-windows hello.zig
# List all supported targets
zig targetsZig ships with all the C headers and libraries needed for cross-compilation. No need to install separate toolchains.
C Interop
Import C headers directly — no bindings, no FFI:
// Call C standard library directly
const c = @cImport({
@cInclude("stdio.h");
@cInclude("math.h");
});
pub fn main() void {
// Call printf
_ = c.printf("Hello from C: sqrt(2) = %f\n", c.sqrt(2.0));
// Call malloc/free
const ptr = c.malloc(100);
defer _ = c.free(ptr);
c.printf("Allocated 100 bytes at %p\n", ptr);
}// compile with: zig build-exe hello.zig -lc
// -lc links libc automaticallyExpected behavior: Standard C functions are called directly. Zig handles the ABI, linking, and platform differences transparently.
Error Handling
Zig has no exceptions. Functions that can fail return an error union:
const std = @import("std");
// Error set definition
const FileError = error{
NotFound,
PermissionDenied,
};
// Function that can fail
fn readUserFile(path: []const u8) FileError![]const u8 {
// Simulate file operation
if (std.mem.eql(u8, path, "/etc/passwd")) {
return "root:x:0:0:root:/root:/bin/bash\n";
}
return FileError.NotFound;
}
pub fn main() void {
// Handle errors with catch
const data = readUserFile("/etc/passwd") catch |err| {
std.debug.print("Error: {}\n", .{err});
return;
};
std.debug.print("{s}\n", .{data});
// Or propagate with try (in a function returning error union)
// const data2 = try readUserFile("/nonexistent");
}Expected behavior: readUserFile returns an error union. The caller must handle errors with catch or propagate with try. Unhandled errors are a compile error.
The Build System
Zig projects use build.zig — a Zig file that configures the build:
// build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Executable
const exe = b.addExecutable(.{
.name = "myapp",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
exe.linkLibC(); // Link libc
// Install
b.installArtifact(exe);
// Run command
const run_cmd = b.addRunArtifact(exe);
b.step("run", "Run the app").dependOn(&run_cmd.step);
// Tests
const tests = b.addTest(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
b.step("test", "Run tests").dependOn(&tests.step);
}zig build # Build
zig build run # Build and run
zig build test # Run testsCommon Mistakes
1. Forgetting to Handle Errors
Zig doesn’t let you ignore errors accidentally. If a function returns an error union, you must handle it.
2. Not Using defer for Cleanup
Manual memory management means every allocation needs a matching free. Use defer to pair them.
3. Confusing const with Runtime Values
const in Zig means const in the mathematical sense — it binds a name to a value. It doesn’t guarantee compile-time evaluation (that’s comptime).
4. Ignoring Sentinel-Terminated Arrays
Zig strings are not null-terminated by default. When calling C functions, use @ptrCast or explicitly null-terminate.
5. Not Using var for Mutable Pointers
const pointer means you can’t modify through it. Use var for mutable pointers.
6. Overusing anyopaque
anyopaque is Zig’s void*. Use typed pointers instead of casting through anyopaque.
Practice Questions
1. What makes Zig different from C?
No hidden control flow (no GC, no exceptions), comptime execution, built-in cross-compilation, and a modern build system.
2. What is comptime?
Code execution at compile time. Functions marked comptime run during compilation, enabling generics and compile-time computation.
3. How does Zig handle errors?
With error unions (ErrorType!T). Functions return either a success value of type T or an error. Callers must handle errors explicitly.
4. How do you cross-compile in Zig?
Pass -target <triple> to any Zig command. Zig includes all needed headers — no separate toolchain installation.
5. Challenge: Build a comptime JSON parser.
Write a comptime function that parses a JSON string at compile time and returns a Zig struct.
Mini Project: File Copy Tool
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} <source> <dest>\n", .{args[0]});
return;
}
const source = try std.fs.cwd().readFileAlloc(allocator, args[1], 10 * 1024 * 1024);
defer allocator.free(source);
try std.fs.cwd().writeFile(.{ .sub_path = args[2], .data = source });
std.debug.print("Copied {d} bytes\n", .{source.len});
}FAQ
What’s Next
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro