Zig Code Review Checklist: Memory Safety and Explicit Control

Published on
Written byChristoffer Artmann
Zig Code Review Checklist: Memory Safety and Explicit Control

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
  • defer statements release resources on all code paths
  • errdefer cleans 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 unreachable only when error truly impossible
  • Errors propagated with try in 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 anytype parameters appropriately
  • Type reflection with @TypeOf, @typeInfo when needed
  • Build scripts in build.zig configure 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)
  • undefined used 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 safely
  • orelse provides 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 return clear and intentional

Code Style & Clarity

  • Functions return errors unions when they can fail
  • Public API documented with doc comments: ///
  • Naming follows Zig conventions: camelCase functions, PascalCase types
  • No macros or preprocessor (use comptime instead)
  • Pointer types explicit: *T single item, [*]T many items, []T slice
  • Constants defined with const, variables with var
  • 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.