Idiomatic Zig Programming
Expert guidance for writing idiomatic Zig code that embodies the Zen of Zig: explicit intent, no hidden control flow, and compile-time over runtime.
Prefer Zig 0.16.0 idioms:
std.Iowith explicitio,main(init: std.process.Init|Minimal)for args/env, destructuring, labeled switch,@branchHinton hot paths,.emptyfor std containers,DebugAllocatorin debug builds, and explicit backing integers for packed types.
Zen of Zig
| Principle | Implication | |-----------|-------------| | Communicate intent precisely | APIs should make ownership, failure, and allocation obvious | | Edge cases matter | Handle invalid states explicitly rather than hoping they never happen | | Favor reading over writing | Prefer straightforward code over abstraction tricks | | Compile errors > runtime crashes | Push invariants into types and comptime checks | | Runtime crashes > silent bugs | Fail loudly rather than corrupting state | | Memory is a resource | Accept allocators explicitly; define ownership clearly | | No hidden control flow | No exceptions, GC, implicit allocations, or magical globals |
Workflow Decision Tree
- Need mutation? Use
var; otherwise useconst. - Need allocation? Accept
std.mem.Allocator; never hide allocation behind globals. - Can fail? Return
!Tand propagate withtry. - Need cleanup? Place
deferorerrdeferimmediately after acquisition. - Need OS interaction? Pass
std.Ioexplicitly; keep side effects at the boundary. - Need environment variables or args? Read them in
main(init)and pass explicit config downward. - Need generic code? Use
comptimeparameters and exhaustiveswitch. - Need C interop?
@cImportstill works, but prefer build-system-managed translate-c for new code when practical. - Need hot-path tuning? Measure first, then use layout/data changes and targeted
@branchHint.
Essential Patterns
Error Unions
const std = @import("std");
const FileError = error{ NotFound, PermissionDenied, InvalidPath };
fn readConfig(io: std.Io, path: []const u8) FileError!Config {
const file = std.Io.Dir.cwd().openFile(io, path, .{}) catch |err| {
return switch (err) {
error.FileNotFound => error.NotFound,
error.AccessDenied => error.PermissionDenied,
else => error.InvalidPath,
};
};
defer file.close(io);
// ... parse config
return config;
}
Allocator Ownership
const std = @import("std");
fn processData(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
const result = try allocator.alloc(u8, input.len * 2);
errdefer allocator.free(result);
// ... write into result
return result;
}
pub fn main() !void {
var debug_alloc: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_alloc.deinit();
const allocator = debug_alloc.allocator();
const data = try processData(allocator, "input");
defer allocator.free(data);
}
Tagged Unions
const PaymentState = union(enum) {
pending: void,
processing: struct { transaction_id: []const u8 },
completed: Receipt,
failed: PaymentError,
pub fn describe(self: PaymentState) []const u8 {
return switch (self) {
.pending => "Waiting for payment",
.processing => |p| p.transaction_id,
.completed => |r| r.summary,
.failed => |e| e.message,
};
}
};
Resource Management with std.Io
const std = @import("std");
fn processFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) !void {
const file = try std.Io.Dir.cwd().openFile(io, path, .{});
defer file.close(io);
const buffer = try allocator.alloc(u8, 4096);
defer allocator.free(buffer);
// ... read/write using file.reader(io, ...) or file.writer(io, ...)
}
Quick Reference
const std = @import("std");
// const by default
const answer: u32 = 42;
var counter: u32 = 0;
// optionals and error unions
const maybe_value: ?u32 = null;
const value = maybe_value orelse 0;
fn canFail() !u32 {
return error.SomeError;
}
const n = try canFail();
// destructuring (0.16+)
const sum, const overflow = @addWithOverflow(a, b);
// std containers prefer .empty
var list: std.ArrayListUnmanaged(u8) = .empty;
var map: std.AutoHashMapUnmanaged(u32, u32) = .empty;
// type info uses quoted identifiers
switch (@typeInfo(T)) {
.@"struct" => |s| _ = s,
.@"int" => |i| _ = i,
else => {},
}
// std.Io at the boundary
pub fn main(init: std.process.Init.Minimal) !void {
var debug_alloc: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_alloc.deinit();
const allocator = debug_alloc.allocator();
const io = std.testing.io; // use a real Io backend in non-test code
_ = io;
_ = allocator;
_ = init;
}
Zig 0.16.0 Notes
std.Io Is the Default
- Prefer
std.IoAPIs for filesystem, networking, time, process, and synchronization boundaries. Io.Threadedis the stable default implementation.Io.Eventedremains experimental; do not assume feature parity, especially around networking.
Args and Environment Belong to main(init)
- Read environment variables and CLI args in
main(init: std.process.Init|Minimal). - Pass explicit config structs, individual values, or
*const std.process.Environ.Mapto lower layers. - Avoid new code that reaches for process-global environment state when explicit plumbing is possible.
const std = @import("std");
pub fn main(init: std.process.Init.Minimal) !void {
var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator);
defer arena.deinit();
const port = init.environ.getPosix("PORT") orelse "8080";
var args = init.args.iterate();
_ = args.next(); // argv0
try run(port);
}
New Type Construction Builtins
@Type has been replaced by specific builtins:
@EnumLiteral()@Int(...)@Tuple(...)@Pointer(...)@Fn(...)@Struct(...)@Union(...)@Enum(...)
There is no @Array builtin in 0.16.0; use normal array syntax. There is no @Opaque; write opaque {} directly.
Packed Types Need Explicit Backing Integers
const Flags = packed struct(u8) {
writable: bool = true,
enumerable: bool = true,
_reserved: u6 = 0,
};
Use the same explicitness for packed unions when representation matters.
Allocator Defaults
GeneralPurposeAllocatoris gone.- Prefer
DebugAllocatorin debug-heavy development workflows. - Prefer
std.heap.smp_allocatoror another deliberate allocator choice in release/runtime code. ArenaAllocatoris thread-safe in 0.16.0, but still use it for lifetime grouping, not as a universal default.
Small but Useful 0.16 Changes
- Prefer
.emptyover oldinit()patterns for unmanaged std containers. - Prefer
std.mem.find*names over oldindexOf*names in new code. @trap()is preferable to relying onunreachablewhen you want a defined abort in release builds.
Forbidden Patterns
| ❌ Never | ✅ Instead |
|----------|-----------|
| Global allocator / hidden malloc | Pass Allocator explicitly |
| Hidden environment or args lookups deep in the stack | Read in main(init) and pass config explicitly |
| @Type(...) or made-up @StructType/@IntType names | Use @Struct, @Int, @Enum, @Pointer, etc. |
| GeneralPurposeAllocator | Use DebugAllocator, smp_allocator, arena, or another deliberate choice |
| std.fs-style examples without io in 0.16-only code | Use std.Io APIs with explicit io |
| packed struct or packed union without backing int when layout matters | Spell the backing integer explicitly |
| Blindly enabling Io.Evented in production guidance | Treat it as experimental until validated |
| var when const works | Default to const |
| Ignoring errors silently | Handle with catch or propagate with try |
| OOP inheritance hierarchies | Use composition and tagged unions |
Detailed References
- idioms.md - Data-oriented design, memory patterns, testing
- async-io.md - New async/Io model, futures, cancellation
- c-interop.md - C FFI, build-system integration, ABI compatibility