Zig Programming Language Fundamentals

Systems Programming intermediate Building with AI ~25 min read

Overview

Zig is a systems programming language designed to be a practical alternative to C. It provides manual memory management without garbage collection, compile-time code execution (comptime), first-class support for interoperability with C/C++ code, and explicit error handling through error unions. Zig aims to make systems programming more productive and less error-prone while maintaining the performance characteristics that systems programmers need.

Unlike C, Zig makes undefined behavior impossible to invoke accidentally. There are no implicit type coercions, no implicit pointer arithmetic, and no hidden control flow. The allocator-first design means every function that allocates memory takes an Allocator parameter explicitly, making resource usage visible and testable. This design philosophy aligns with building reliable, high-performance systems like database engines and search indexes.

In a database engine codebase, Zig is well-suited for performance-critical components. The language’s comptime capabilities enable generic data structures and algorithms that are monomorphized at compile time, producing specialized machine code without runtime overhead. Its C interop allows incremental adoption alongside existing C libraries like LevelDB and RocksDB.

Core Concepts

Comptime (Compile-Time Execution)

Zig’s comptime keyword marks values and code blocks that are evaluated at compile time. Unlike C macros or C++ templates, comptime uses the same language syntax as runtime code—you write regular Zig, and the compiler evaluates it during compilation. This powers generic types, compile-time string formatting, and static assertions without a separate metalanguage.

Allocator-First Design

Every Zig standard library function that allocates memory takes an std.mem.Allocator as its first parameter. There is no global allocator, no hidden malloc. This makes memory allocation explicit, testable (swap in a failing allocator for testing), and composable (stack arena allocators, logging allocators, etc.). It is the single most distinctive design decision in Zig.

Error Unions

Zig uses error unions (!T) instead of exceptions or error codes. A function returning FileOpenError!File returns either a File or a FileOpenError. The caller must handle the error with try, catch, or explicit checking—there is no way to accidentally ignore an error. Error sets are types, so the compiler tracks exactly which errors a function can return.

Tagged Unions

Zig’s union(enum) type is a tagged union: a value that can be one of several types, with a tag that identifies which variant is active. Combined with switch exhaustiveness checking, tagged unions model state machines, AST nodes, and protocol messages safely—the compiler ensures you handle every variant.

No Hidden Control Flow

Zig has no operator overloading, no hidden function calls from syntax sugar, and no implicit conversions. A + is always integer addition. A function call is always visible in the source. This makes Zig code auditable—you can reason about performance and behavior by reading the code, without needing to know what operators are overloaded.

How It Works

Basic Types and Variables

Zig provides fixed-width integer types (u8, i32, u64), floating point types (f32, f64), and bool. Variables are declared with const (immutable) or var (mutable). Type inference is available but explicit types are preferred for public APIs:

const std = @import("std");

pub fn main() !void {
    // Explicit types
    const max_size: u32 = 1024;
    var count: u32 = 0;

    // Type inference with const
    const name = "exampledb";  // inferred as *const [9:0]u8

    // Mutable slice from array
    var buffer: [256]u8 = undefined;
    var slice: []u8 = buffer[0..max_size];

    // Optional types — explicit null handling
    var maybe_value: ?u32 = null;
    maybe_value = 42;
    if (maybe_value) |value| {
        count = value;
    }

    // Print uses comptime format strings
    std.debug.print("count={d}, name={s}\n", .{ count, name });
}

Error Handling

Error unions are Zig’s primary error handling mechanism. The try keyword propagates errors, while catch handles them inline. Error sets can be explicit or inferred:

const FileError = error{
    NotFound,
    PermissionDenied,
    DiskFull,
};

fn readConfig(allocator: std.mem.Allocator, path: []const u8) FileError![]u8 {
    const file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) {
        error.FileNotFound => return FileError.NotFound,
        error.AccessDenied => return FileError.PermissionDenied,
        else => return FileError.NotFound,
    };
    defer file.close();

    return file.readToEndAlloc(allocator, 1024 * 1024) catch {
        return FileError.DiskFull;
    };
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // 'try' propagates the error to the caller
    const config = try readConfig(allocator, "config.json");
    defer allocator.free(config);

    // Or handle specific errors
    const data = readConfig(allocator, "data.json") catch |err| switch (err) {
        FileError.NotFound => {
            std.debug.print("File not found, using defaults\n", .{});
            return;
        },
        else => return err,
    };
    defer allocator.free(data);
}

Comptime Generics

Zig uses comptime parameters to implement generics. The compiler generates specialized code for each concrete type, similar to C++ templates but using regular Zig syntax:

fn BoundedArray(comptime T: type, comptime capacity: usize) type {
    return struct {
        const Self = @This();

        items: [capacity]T = undefined,
        len: usize = 0,

        pub fn append(self: *Self, item: T) !void {
            if (self.len >= capacity) return error.Overflow;
            self.items[self.len] = item;
            self.len += 1;
        }

        pub fn slice(self: *const Self) []const T {
            return self.items[0..self.len];
        }

        pub fn pop(self: *Self) ?T {
            if (self.len == 0) return null;
            self.len -= 1;
            return self.items[self.len];
        }
    };
}

// Usage — compiler generates specialized code for each instantiation
var int_buf = BoundedArray(u32, 64){};
try int_buf.append(42);

var str_buf = BoundedArray([]const u8, 16){};
try str_buf.append("hello");

Structs and Methods

Zig structs are the primary way to define composite types. Methods are functions in the struct’s namespace that take self as the first parameter:

const KeyValue = struct {
    key: []const u8,
    value: []const u8,
    timestamp: u64,

    pub fn init(key: []const u8, value: []const u8, ts: u64) KeyValue {
        return .{
            .key = key,
            .value = value,
            .timestamp = ts,
        };
    }

    pub fn isExpired(self: KeyValue, now: u64, ttl: u64) bool {
        return now - self.timestamp > ttl;
    }

    pub fn compare(a: KeyValue, b: KeyValue) std.math.Order {
        return std.mem.order(u8, a.key, b.key);
    }
};

Practical Applications

Database Engine Components in Zig

For performance-critical database engine components, Zig is a strong choice. Zig’s explicit memory management is ideal for implementing data structures like B-trees, skip lists, and bloom filters where allocation patterns are predictable and must be tightly controlled. Arena allocators handle batch operations (like processing a write batch), while the general-purpose allocator handles long-lived structures.

C Library Interop

Zig’s seamless C interop allows calling into existing C libraries like LevelDB, RocksDB, or zlib without bindings generators. The @cImport directive directly parses C header files and makes the functions available as Zig functions. This enables incremental migration: performance-critical hot paths can be rewritten in Zig while the rest of the system continues using C libraries.

Network Protocol Handling

Zig’s packed structs and bit-level control make it natural for parsing binary network protocols. The database wire protocol, storage file formats, and replication messages can be defined as packed structs that map directly to their byte representations, enabling zero-copy parsing.

Implementation Patterns

Resource Management with defer

Zig’s defer and errdefer ensure cleanup happens regardless of how a scope exits. errdefer only executes if the function returns an error, making partial-construction cleanup straightforward:

fn openDatabase(allocator: std.mem.Allocator, path: []const u8) !*Database {
    const db = try allocator.create(Database);
    errdefer allocator.destroy(db);  // only runs if we return an error below

    db.wal = try WAL.open(allocator, path);
    errdefer db.wal.close();  // only runs if subsequent init fails

    db.index = try Index.init(allocator);
    errdefer db.index.deinit();

    try db.recoverFromWAL();
    return db;
}

// Caller uses defer for normal cleanup
const db = try openDatabase(allocator, "/data/mydb");
defer db.close();

Testing with Built-in Test Framework

Zig has a built-in test runner. Tests are declared inline with the code they test using test blocks. The testing allocator detects memory leaks:

const testing = std.testing;

test "BoundedArray append and pop" {
    var arr = BoundedArray(u32, 4){};
    try arr.append(10);
    try arr.append(20);
    try arr.append(30);

    try testing.expectEqual(@as(usize, 3), arr.len);
    try testing.expectEqual(@as(?u32, 30), arr.pop());
    try testing.expectEqual(@as(usize, 2), arr.len);
}

test "BoundedArray overflow" {
    var arr = BoundedArray(u8, 2){};
    try arr.append(1);
    try arr.append(2);
    try testing.expectError(error.Overflow, arr.append(3));
}

Common Pitfalls

Further Reading