Zig code should be explicit, simple, and easy to inspect. The goal is not cleverness. The goal is code that another programmer can read, verify, and maintain.
Zig code should be explicit, simple, and easy to inspect. The goal is not cleverness. The goal is code that another programmer can read, verify, and maintain.
I.1 Prefer const by Default
Use const unless the value must change.
const x = 10;Use var only when mutation is required.
var count: usize = 0;
count += 1;This makes the code easier to reason about. When a value is const, the reader knows it will not be reassigned.
I.2 Give Types When They Help
Zig can infer many types.
const x = 10;But explicit types help when the exact size matters.
const port: u16 = 8080;
const count: usize = items.len;Use explicit types for public APIs, integer widths, file formats, protocols, and data structures.
I.3 Keep Functions Small
A good Zig function should do one clear job.
Bad:
fn run() !void {
// parse args
// open files
// allocate buffers
// process data
// write output
}Better:
fn run() !void {
const config = try parseArgs();
const input = try readInput(config);
const result = try process(input);
try writeOutput(result);
}Small functions make error paths, ownership, and cleanup easier to see.
I.4 Put Cleanup Near Acquisition
Use defer immediately after acquiring a resource.
const file = try std.fs.cwd().openFile("data.txt", .{});
defer file.close();For allocated memory:
const buffer = try allocator.alloc(u8, 1024);
defer allocator.free(buffer);This reduces leaks and makes ownership visible.
I.5 Use errdefer for Partial Construction
When a function builds an object in stages, use errdefer.
fn createUser(allocator: std.mem.Allocator, name: []const u8) !User {
const owned_name = try allocator.dupe(u8, name);
errdefer allocator.free(owned_name);
return User{
.name = owned_name,
};
}If the function fails before returning, errdefer cleans up.
I.6 Pass Allocators Explicitly
A function that may allocate should usually take an allocator.
fn readName(allocator: std.mem.Allocator) ![]u8 {
return try allocator.dupe(u8, "zig");
}This tells the caller that memory ownership is involved.
Avoid hidden global allocation.
I.7 Document Ownership
When returning allocated memory, say who frees it.
/// Returns newly allocated memory.
/// Caller owns the returned slice and must free it with `allocator.free`.
fn copyText(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
return try allocator.dupe(u8, text);
}When returning borrowed memory, say how long it lives.
/// Returns a slice pointing into `input`.
/// The returned slice must not outlive `input`.
fn firstWord(input: []const u8) []const u8 {
// ...
}I.8 Prefer Slices for Buffers
Use slices when passing many items.
fn sum(values: []const i32) i32 {
var total: i32 = 0;
for (values) |value| {
total += value;
}
return total;
}A slice carries both pointer and length. That is usually safer than a raw pointer plus a separate length.
I.9 Avoid Long-Lived Borrowed Slices
Do not store a slice unless you know the memory behind it will stay alive.
Risky:
const User = struct {
name: []const u8,
};This is safe only if name points to memory that outlives the User.
For long-lived data, copy the bytes:
user.name = try allocator.dupe(u8, input_name);I.10 Keep Error Handling Visible
Do not hide errors unnecessarily.
Use try when the current function should return the error.
try saveFile(path, data);Use catch when this function can handle it.
saveFile(path, data) catch |err| {
std.log.err("failed to save file: {}", .{err});
return err;
};Avoid empty catches unless ignoring the error is truly safe.
doSomething() catch {};That code should be rare.
I.11 Use Specific Errors
Prefer meaningful error names.
const ConfigError = error{
MissingName,
InvalidPort,
UnknownOption,
};This is better than returning a vague failure everywhere.
I.12 Avoid Clever comptime
comptime is powerful, but it can make code harder to read.
Good use:
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}Risky use:
// large compile-time code generator
// hard to debug
// hard to understandUse compile-time programming when it removes repetition or enforces correctness. Avoid it when ordinary runtime code is clearer.
I.13 Use Clear Names
Prefer names that explain meaning.
Good:
const request_count = 10;
const input_path = "data.txt";
const allocator = arena.allocator();Weak:
const x = 10;
const s = "data.txt";
const a = arena.allocator();Short names are fine in small scopes:
for (items) |item| {
// item is clear here
}I.14 Keep Public APIs Boring
Public APIs should be predictable.
Good:
pub fn parseConfig(allocator: std.mem.Allocator, text: []const u8) !ConfigThis signature shows:
The function may allocate.
The function reads text.
The function may fail.
The function returns Config.
Avoid APIs where allocation, failure, or ownership is hidden.
I.15 Prefer Plain Data
Zig works well with plain structs.
const Point = struct {
x: f64,
y: f64,
};Add methods when they improve readability.
const Point = struct {
x: f64,
y: f64,
fn lengthSquared(self: Point) f64 {
return self.x * self.x + self.y * self.y;
}
};Do not force object-oriented patterns where plain data is enough.
I.16 Use Tagged Unions for Variants
When a value can be one of several shapes, use union(enum).
const Token = union(enum) {
number: i64,
identifier: []const u8,
plus,
minus,
};Then handle it with switch.
switch (token) {
.number => |n| std.debug.print("{}\n", .{n}),
.identifier => |name| std.debug.print("{s}\n", .{name}),
.plus => {},
.minus => {},
}This is safer than manually tracking a tag and payload separately.
I.17 Avoid Global Mutable State
Global mutable state makes programs harder to test and reason about.
Prefer passing state explicitly.
fn run(config: Config, allocator: std.mem.Allocator) !void {
// ...
}Instead of hiding dependencies in globals.
I.18 Keep Imports Simple
Put imports near the top.
const std = @import("std");
const parser = @import("parser.zig");Avoid deeply nested import tricks. A file should make its dependencies easy to see.
I.19 Use Tests Near the Code
Zig lets you place tests in the same file.
fn add(a: i32, b: i32) i32 {
return a + b;
}
test "add returns the sum" {
try std.testing.expectEqual(@as(i32, 5), add(2, 3));
}Small tests close to the code help future changes.
I.20 Format Code Consistently
Use:
zig fmt .Zig has a standard formatter. Use it.
Do not hand-format code into a personal style that fights the formatter.
I.21 Check Return Values
If a function returns a value, either use it or explicitly discard it.
_ = c.printf("hello\n");The discard shows intent.
Do not leave unused values by accident.
I.22 Write Comments for Why, Not Noise
Bad comment:
// increment i
i += 1;Good comment:
// Keep one byte for the trailing zero required by the C API.
const usable_len = buffer.len - 1;Comments should explain decisions, invariants, ownership, or non-obvious constraints.
I.23 Prefer Simple Control Flow
Deep nesting makes error paths hard to follow.
Harder:
if (valid) {
if (ready) {
if (allowed) {
try run();
}
}
}Clearer:
if (!valid) return error.Invalid;
if (!ready) return error.NotReady;
if (!allowed) return error.NotAllowed;
try run();Early returns are often clearer in Zig.
I.24 Keep Unsafe Code Small
Pointer casts, manual alignment, C interop, volatile memory, and packed layouts need care.
Put risky code behind a small API.
fn readHeader(bytes: []const u8) !Header {
// unsafe or low-level details stay here
}Then the rest of the program uses the safe wrapper.
I.25 Style Rule Summary
Good Zig style means:
| Practice | Reason |
|---|---|
Prefer const | Reduces accidental mutation |
| Pass allocators | Makes allocation visible |
Use defer | Keeps cleanup reliable |
| Use slices | Keeps pointer plus length together |
| Handle errors explicitly | Makes failure visible |
| Keep functions small | Makes ownership and control flow clearer |
Use zig fmt | Keeps style consistent |
| Avoid hidden globals | Improves testing and reasoning |
| Document ownership | Prevents memory bugs |
Zig style follows Zig’s main idea: make the important parts of the program visible.