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.
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.
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.
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.
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.
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.
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 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);
}
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");
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);
}
};
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.
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.
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.
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();
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));
}
allocator.dupe to create owned copies when the slice must outlive its source.allocator.alloc needs a corresponding allocator.free. Use defer immediately after allocation to pair them. The GeneralPurposeAllocator with .safety = true detects leaks in debug builds.var x: [N]u8 = undefined leaves memory uninitialized (faster but dangerous if read before write). std.mem.zeroes([N]u8) zero-initializes. In debug mode, undefined is set to 0xAA to help catch use-before-init bugs, but release mode does not do this.Allocator parameter. Functions that don’t allocate should not. Violating this convention makes code harder to test and reason about— if a function secretly allocates through a global, it becomes non-deterministic and untestable with a failing allocator.