zig-best-practices
Zig Best Practices
Helps you write, review, and improve idiomatic, safe, and performant Zig code. Adapt depth to the user's level — skip basics for advanced comptime questions, explain fundamentals for allocator newcomers.
How to use this skill
Read SKILL.md for quick guidance, then consult 1-2 relevant reference files as needed. Do NOT load all references at once.
Reference files — when to read each one
| Reference | Read when... |
|---|---|
references/memory-management.md |
Allocators, alloc/free, defer/errdefer, arena patterns, init/deinit, FixedBufferAllocator, allocation failure testing, custom allocator wrappers. |
references/error-handling.md |
Error unions, try/catch, errdefer chains, specific error sets, error return traces, optional handling patterns. |
references/comptime-and-generics.md |
Comptime parameters, @typeInfo, generic structs, compile-time validation, lookup tables, state machines, anytype, fat pointer interfaces, type-level metaprogramming, event emitter pattern, typed EventEmitter(comptime EventEnum, comptime PayloadMap) with per-event payloads, callback storage with comptime dispatch. |
references/types-and-pointers.md |
Pointer types (*T, [*]T, []T, sentinels), type casting (@ptrCast, @alignCast, @bitCast, @intCast), packed structs, zero-sized types, integer overflow, saturating arithmetic (|+, |-), type coercion, anonymous structs/tuples, custom formatting with comptime fmt, HashMap key types (eql/hash/HashContext), volatile/hardware MMIO, std.atomic.Value, alignment rules, complete Color module example (format + hash + lerp + saturating blend + tests). |
references/testing-and-build.md |
Inline tests, table-driven tests, std.testing.allocator, coverage, build.zig, build.zig.zon, dependencies, cross-compilation, WASM target (.cpu_arch = .wasm32, .os_tag = .freestanding), custom build steps, b.addSystemCommand with addOutputFileArg for code generation, getEmitDocs for documentation, complete multi-target build.zig example (CLI + WASM + cross-compile + codegen + docs + named steps), project structure, library setup. |
references/stdlib-recipes.md |
Data structures (ArrayList, HashMap, LinkedList), file I/O, string handling, networking (HTTP, TCP), concurrency (threads, mutex, thread pool). |
references/performance.md |
SIMD/@Vector, cache-friendly layout, benchmarking, buffered I/O, arena in hot paths, build modes, comptime lookup tables, stack vs heap. |
references/c-interop.md |
@cImport, extern struct, C pointers, string conversion, exporting Zig to C, sentinel termination for C APIs. |
references/code-review-checklist.md |
Reviewing Zig code, code review, PR review, common mistakes to check for, formatting/style rules, structured review methodology. |
Mandatory workflows
When writing code: completeness checklist
Before finalizing any code response, verify it includes all of the following that apply:
- Error set definition — define specific error sets, never use
anyerrorin public APIs. - Build boilerplate — if a project is requested, include both
build.zigANDbuild.zig.zon. - init/deinit pair — every struct that owns resources must have both.
- Trait implementations — if a type will be used as a HashMap key, implement
eql()andhash(). If it should be printable, implementformat(). - Doc comments — all
pubfunctions and types must have///doc comments. - Inline tests — include at minimum one test per public function using
std.testing.allocator. - Integer overflow safety — any arithmetic on bounded integers (
u8,u16, etc.) must use widening, saturating operators, or explicit overflow checks. State which approach and why.
When reviewing code: structured analysis
Review code in three mandatory passes. Do NOT skip any pass.
Pass 1 — Memory Safety:
- Every
allochas a matchingfreeviadefer - Every fallible path after allocation has
errdefer - No global mutable allocators — allocators passed as parameters
- No dangling pointers from slices into freed memory
- No reading from
undefinedmemory without initialization first
Pass 2 — Concurrency, Hardware, and Low-Level Safety:
- Hardware register pointers use
volatile(not regular pointers) - Thread-shared state uses
std.atomic.Valueorstd.Thread.Mutex(NOT volatile) @ptrCastis always paired with@alignCast— or usestd.mem.readIntfor unaligned access- Structs for hardware/binary protocols use
extern structorpacked structfor guaranteed layout - Integer overflow operators (
+%,|+) are used intentionally undefinedbuffers are not read before being written
Pass 3 — API Design and Completeness:
anyerrorreplaced with specific error setsvarreplaced withconstwherever mutation isn't needed- File/network I/O uses buffered readers/writers
- Public API has doc comments
- Null/optional handling is safe (no unguarded
.?unwrap)
When doing arithmetic on bounded integers: overflow verification
Before finalizing code that does arithmetic on u8, u16, or any bounded integer type:
- Write the range — what is the maximum value of each intermediate expression? (e.g.,
u8 * u8max = 65025, which overflowsu8andu16alike — needsu32.) - Widen before operating — promote operands to a larger type (
u16,u32) before multiplication or addition that could overflow, then truncate back with@intCast. - Choose the right operator — default
+/*for bug-catching,|+/|-for clamping (audio, color),+%/*%for intentional wrapping (hashes). - Never rely on implicit safety — in
ReleaseFastbuilds overflow is undefined behavior, not a panic.
When writing comptime/pointer-heavy code: verification step
Before outputting complex comptime or low-level pointer code, mentally trace through:
- Does every comptime block actually run at comptime? (No runtime variables in comptime context)
- Are
@setEvalBranchQuotacalls needed for large iterations? - Do all pointer casts maintain alignment? (
@ptrCast+@alignCasttogether) - Are packed struct fields accessed correctly? (Cannot take address of non-byte-aligned fields)
- For
@typeInfo(.@"struct")iteration: mentally substitute a concrete type and trace field access — does it handle all field types (u8,u16,u32,u64,i8–i64,bool,[N]u8)? - For serialization/deserialization: use
std.mem.readInt/std.mem.writeIntwith explicit endianness — never raw pointer casts for wire formats.
Compile-check step for comptime type-safety
When writing generic comptime code (event emitters, serializers, type-safe builders):
- Draft the type signature first — write
fn MyType(comptime Param: type, comptime mapFn: fn (Param) type) typebefore the body. - Verify callback/function pointer signatures — ensure
*const fn (*const PayloadType) voidmatches what callers pass. Never useanytypefor stored callbacks. - Check that
@ptrCast/@alignCastround-trips are correct — type-erased pointers (*const anyopaque) must be cast back to the original type with matching alignment. - Simulate a call mentally — pick a concrete enum variant, trace the comptime function resolution, verify the callback type matches.
Constraint checklist against anytype misuse
Before using anytype in a function signature, verify:
- The function genuinely works with multiple unrelated types (not just one)
- You cannot express the constraint with a concrete type or comptime parameter
- Stored function pointers use concrete types, not
anytype(you cannot storeanytype) - The doc comment documents what interface the
anytypeparameter must satisfy
When responding to multi-requirement prompts: requirement verification
When the prompt lists multiple requirements (numbered or bulleted), verify completeness before finalizing:
- Label each requirement — mentally map each prompt requirement to a specific section of your code.
- Check for missing requirements — scan the prompt again after writing code. Each requirement must have corresponding code.
- Verify test coverage — each requirement should have at least one test exercising it.
- For complete module requests — output a single, self-contained module (not scattered fragments). Include all imports, the type definition, all methods, and all tests in one code block.
Quick principles
- Default to
const. Only usevarwhen you genuinely need mutation. - Pair every allocation with deallocation via
defer. Useerrdeferfor cleanup that should only run on error paths. - Pass allocators as explicit parameters. Functions that allocate accept an
Allocator— no globals, no hidden heap. - Errors are values. Use specific error sets, propagate with
try, handle withcatch. Never silently discard errors without good reason. - Use optionals (
?T) for absence, notundefined. Reserveundefinedfor buffers you'll fill immediately. - Prefer slices (
[]T) over many-item pointers ([*]T). Convert raw pointers to slices as early as possible. - Push work to
comptimewhen it makes sense. But don't overuseanytype— if a concrete type works, use it. - Write inline tests with
std.testing.allocator. It catches memory leaks automatically. - Run
zig fmtunconditionally. No exceptions, no debates. - Profile before optimizing. Choose the right build mode first — it's the single biggest performance lever.