Memory leaks from forgotten defer allocator.free() statements exhaust
resources. Unchecked error unions cause silent failures. Integer overflow in
release builds creates security vulnerabilities. Hidden control flow from macros
makes code unpredictable. These issues compile successfully but cause crashes,
vulnerabilities, or resource exhaustion in production.
This checklist helps catch what matters: manual memory management errors, missing error handling, undefined behavior, and code that fights Zig's philosophy of explicit control and no hidden allocation.
Quick Reference Checklist
Use this checklist as a quick reference during code review.
Memory Management
- Every allocation has corresponding
defer allocator.free() - Allocators passed explicitly, not hidden in function calls
deferstatements release resources on all code pathserrdefercleans up on error paths specifically- Memory aligned appropriately for type
- No memory leaks from early returns without cleanup
- Arena allocators used for batch allocations
- Allocator choice appropriate for use case (page, arena, GPA)
Error Handling
- Error unions used for operations that might fail:
!T - Errors handled explicitly with
try,catch, or explicit check - Error sets document all possible errors:
error{OutOfMemory, InvalidInput} catch unreachableonly when error truly impossible- Errors propagated with
tryin functions returning error unions - Error context provided when catching:
catch |err| handleError(err) - No ignored error unions (compiler enforces handling)
- Error sets combined appropriately:
ErrorSet1 || ErrorSet2
Comptime & Build System
- Comptime used for compile-time computation:
comptime var x = calculate() - Generic functions use
anytypeparameters appropriately - Type reflection with
@TypeOf,@typeInfowhen needed - Build scripts in
build.zigconfigure compilation properly - No runtime cost for comptime operations
- Comptime assertions validate assumptions:
comptime assert(condition) - Generic code tested with multiple types
- Build flags control features consistently
Undefined Behavior & Safety
- Integer overflow checked in debug mode (undefined in release)
- Wrapping arithmetic used when overflow intentional:
+%,-%,*% - Saturating arithmetic for bounds:
+|,-|,*| - No null pointer dereferences (use optional types:
?T) - Array bounds checked (slice access safe)
undefinedused appropriately (only when value will be set before read)- No type punning through pointer casts without
@ptrCast - Alignment requirements met for all types
Optionals & Control Flow
- Optional types used for nullable values:
?T if (optional) |value|unwraps safelyorelseprovides defaults:optional orelse default_value- No force unwrapping with
.?unless truly guaranteed non-null - While loops with optionals:
while (next()) |item| - Switch expressions exhaustive (all cases covered)
- No hidden control flow (Zig makes everything explicit)
- Early returns with
returnclear and intentional
Code Style & Clarity
- Functions return errors unions when they can fail
- Public API documented with doc comments:
/// - Naming follows Zig conventions:
camelCasefunctions,PascalCasetypes - No macros or preprocessor (use comptime instead)
- Pointer types explicit:
*Tsingle item,[*]Tmany items,[]Tslice - Constants defined with
const, variables withvar - Struct fields have explicit layout when needed:
packed,extern - Test blocks for unit tests:
test "description"
Explicit Memory Management
Zig requires explicit memory allocation and deallocation with no hidden costs.
Every allocation needs a corresponding free. The defer statement ensures
cleanup happens when scope exits, even through error returns. The pattern
const buf = try allocator.alloc(u8, size); defer allocator.free(buf);
allocates and schedules cleanup in one place. When we see allocations without
defer, that's a potential memory leak.
Allocators are passed explicitly as parameters rather than being global or
implicit. This makes allocation visible in function signatures and allows
choosing different allocators for different use cases. The pattern
fn process(allocator: Allocator, data: []const u8) shows that the function
might allocate. When functions allocate without receiving an allocator, they
hide memory costs from callers.
The errdefer statement cleans up specifically on error paths, essential for
functions that allocate multiple resources. When early errors occur, errdefer
frees previously allocated resources before returning the error. Without
errdefer, early error returns leak resources. When we see multiple allocations
without errdefer between them, later allocation failures leak earlier
allocations.
Memory alignment matters for performance and correctness. Types have alignment
requirements that allocators must meet. Manual alignment specifications use
@alignOf and @alignCast when needed. Misaligned access causes slow
performance or crashes on some architectures. When code casts between pointer
types with different alignment, we verify alignment requirements are met.
Arena allocators simplify memory management for operations that allocate many small objects. The arena allocates from a large buffer and frees everything at once when done. This beats individual allocations and frees for temporary operations. When functions allocate many short-lived objects, an arena allocator reduces boilerplate and improves performance.
Different allocator implementations suit different needs. The GeneralPurposeAllocator detects leaks and errors in debug mode. PageAllocator directly allocates pages from the OS. ArenaAllocator batches allocations. When reviewing allocator choices, we verify the allocator matches the use pattern.
Error Handling Without Exceptions
Error unions combine regular types with error sets using the ! syntax. The
type !u32 represents either an error or a u32 value. Functions that can fail
return error unions, making failure explicit in the type signature. This forces
callers to handle errors—the compiler prevents ignoring error unions. When we
see operations that can fail returning bare values, error unions make failures
visible.
The try keyword propagates errors from error union expressions, returning
early if the expression is an error, otherwise extracting the success value. The
expression const result = try operation(); returns the error if operation
fails, otherwise assigns the success value to result. This beats explicit error
checking and early returns for every operation that might fail.
Error sets define all possible errors a function might return. The declaration
const MyError = error{OutOfMemory, InvalidInput}; creates a set of error
values. Functions can return specific error sets or inferred error sets using
! without specifying errors. When functions return errors, we verify the error
set documents all possible failures or uses inference appropriately.
The catch expression handles errors with fallback values or custom logic. The
pattern result = operation() catch |err| handleError(err) catches errors and
runs custom handling. For simple defaults, operation() catch default_value
provides fallback. When error handling is more complex than propagation, catch
expressions enable custom recovery.
The expression catch unreachable asserts that an error cannot occur. This
eliminates error handling when the programmer guarantees an operation cannot
fail. Using unreachable incorrectly causes crashes in release builds when errors
do occur. When we see catch unreachable, we verify the error is truly impossible
or if proper error handling is needed.
Error sets compose with the || operator to combine possible errors. When a
function calls multiple operations with different error sets, the return type
combines them. Zig infers error sets automatically in many cases, reducing
boilerplate. When we see manually specified error sets missing errors from
called functions, inference or explicit combination fixes the type.
Comptime for Zero-Cost Abstractions
Comptime evaluation runs code at compile time, enabling generic programming
without runtime cost. The comptime keyword forces compile-time execution. The
declaration comptime var x = calculate(); runs calculate during compilation,
embedding the result in the binary. This enables optimizations impossible in
runtime-only languages. When expensive computations produce constant results,
comptime eliminates the runtime cost.
Generic functions use anytype parameters to work with any type, with actual
types resolved at compile time. The function generates specialized versions for
each type used. Type checking happens at comptime, catching type errors before
runtime. When functions need to work with multiple types, anytype provides type
safety without runtime polymorphism overhead.
Type reflection through builtins like @TypeOf and @typeInfo inspects types
at comptime. This enables writing code that adapts to type properties without
runtime dispatch. The pattern checks type characteristics and generates
appropriate code for each type. When code needs different behavior for different
types, comptime reflection makes decisions without runtime cost.
Comptime assertions validate assumptions during compilation using
comptime assert(condition). These catch logic errors early and document
invariants. Assertions that fail prevent compilation, catching bugs before they
reach runtime. When code has assumptions about types or constants, comptime
assertions make requirements explicit and verified.
Build scripts in build.zig use Zig code to configure compilation, enabling
complex build logic without external tools. The build script can conditionally
enable features, configure dependencies, and generate code. This provides
flexibility while keeping everything in Zig. When reviewing build.zig, we verify
it correctly configures the project for different targets and build modes.
Generic code should be tested with multiple concrete types to verify it works correctly. While comptime provides type safety, different types might expose edge cases. Testing with representative types catches issues that type checking alone might miss. When generic functions are tested with only one type, additional test cases verify correctness across types.
Safety and Undefined Behavior
Integer overflow is checked in debug builds but undefined in release builds,
enabling optimizations. Code that might overflow should use wrapping arithmetic
+%, -%, *% which has defined two's complement wrapping. Saturating
arithmetic +|, -|, *| clamps to min/max values. When arithmetic might
overflow, we verify the code uses appropriate operators or checks bounds.
Optional types represent values that might be null using ?T syntax. The type
?u32 is either null or a u32 value. This makes nullability explicit in the
type system, unlike languages where any pointer might be null. Accessing
optionals requires explicit unwrapping with if (opt) |val| or orelse. When
code uses null-like sentinel values instead of optionals, the type system can
enforce null checks.
Array and slice bounds are checked at runtime, preventing buffer overflows.
Indexing with array[index] includes bounds checks in debug builds. For
performance-critical code where bounds are guaranteed, slicing avoids redundant
checks. When we see manual bounds checking before access, Zig's built-in checks
provide safety.
The undefined value indicates uninitialized memory that will be written before
reading. Using undefined for variables that get set before use avoids
zero-initialization cost. Reading undefined values before writing causes
undefined behavior. When we see var x: u32 = undefined;, we verify x is
written before any read.
Pointer casts require explicit @ptrCast to convert between pointer types. The
compiler doesn't allow implicit conversions that might violate type safety.
Alignment casts use @alignCast when changing pointer alignment. When code
casts pointers, we verify the cast is necessary and correct for the actual
memory layout.
Alignment requirements specify how values must be positioned in memory. Each
type has natural alignment that allocators respect. Custom alignment uses
align(N) annotation when needed. Misaligned access causes crashes on some
architectures or performance penalties on others. When code specifies custom
alignment, we verify it matches actual usage requirements.
Optionals and Control Flow
Optional types make null explicitly part of the type system. The syntax ?T
creates an optional that's either null or a value of type T. Functions returning
optional values document that result might be absent. This beats sentinel values
or null pointers that might be missed. When functions can fail to produce
values, optional return types make this possibility explicit.
Unwrapping optionals with if (optional) |value| extracts the value only when
non-null. The value is available in the if block scope. This combines null check
and unwrapping in one expression. When we see code checking for null then
accessing the value separately, if-unwrap combines these steps safely.
The orelse operator provides default values for null optionals. The expression
optional orelse default evaluates to the optional's value if non-null,
otherwise the default. This beats if expressions that just return different
values. When code has if statements only to provide defaults for null, orelse
expresses this more directly.
Force unwrapping with .? extracts a value, asserting it's non-null. This
crashes if the optional is null. Force unwrapping should be rare, only when we
can guarantee non-null through program logic the compiler can't verify. When we
see .?, we verify the null case truly can't occur or if proper unwrapping is
safer.
While loops can unwrap optionals with while (next()) |item| syntax. This
combines iteration with optional handling, looping while the function returns
non-null. When the function returns null, the loop ends. This pattern works well
for iterators and operations that process until done. When manual loops check
for null to continue, while-unwrap simplifies the logic.
Switch expressions must be exhaustive, covering all possible values. The
compiler enforces that switch handles every case or includes else. This
catches bugs when new cases are added to enums or when ranges aren't fully
covered. When we see switch expressions, we verify all cases are meaningful and
not just satisfying exhaustiveness with else.
Zig makes all control flow explicit with no hidden jumps or exceptions. Code flows linearly until explicit return, break, or continue statements. This makes following code paths straightforward—there are no invisible error returns or hidden allocations. When reviewing control flow, we verify the code does what it appears to do without hidden behavior.
Bringing It Together
Effective Zig code review recognizes patterns that embrace explicit control versus those that hide costs or complexity. Zig's philosophy makes everything visible—memory allocation, error handling, control flow. Code that fights this with complex abstractions or hidden behavior misses Zig's advantages. Well-written Zig is readable because everything important is explicit.
Not every issue requires immediate attention. A missing doc comment is documentation issue. A memory leak from forgotten defer is a production problem. Unchecked error unions cause silent failures. The key is identifying issues that affect safety, correctness, and resource management versus style preferences.
Zig's compile-time execution enables zero-cost abstractions while maintaining runtime simplicity. Code review helps teams learn when to use comptime for generics versus runtime polymorphism, when to use different allocators, and how to write error handling that's both safe and ergonomic. The language's simplicity makes code easier to review—there are fewer places for bugs to hide.

