Zig Memory Management: The Explicit Allocator Pattern

This article is based on Zig 0.16.

In the previous articles we covered Zig’s basic syntax and error handling. Now we arrive at the most distinctive part of Zig—memory management.

If you come from Go or Rust, Zig’s memory philosophy will feel unfamiliar: it provides neither garbage collection nor an ownership system. Instead, it chooses a fundamentally different path—the Allocator Pattern. The core convention is remarkably simple, yet far-reaching:

Any function that might allocate memory must accept a std.mem.Allocator parameter.

No exceptions, no hidden malloc, no implicit GC allocation. What you see is what will happen.

Three Languages, Three Philosophies

LanguageApproachTypical OverheadDeveloper Control
GoGarbage Collection (GC)GC pauses, doubled memory footprintLow—tuning GC is an art
RustOwnership + Borrow Check + DropCompile-time checks (zero runtime cost)Medium—ownership rules sometimes force data structure redesign
ZigManual management + Allocator PatternEntirely up to youHigh—every allocation is an explicit choice

The Allocator Pattern

Zig’s standard library defines a std.mem.Allocator interface type (essentially a two-pointer structure of a vtable pointer and a context pointer). All allocators conform to this interface. It is one of the most important abstractions in Zig.

The most basic usage:

zig
1
2
3
4
5
6
7
8
const std = @import("std");

pub fn main() !void {
    const allocator = std.heap.page_allocator;
    const buffer = try allocator.alloc(u8, 100);
    defer allocator.free(buffer);
    @memset(buffer, 0);
}

Here std.heap.page_allocator is a global Allocator value (not a type—no need to call .init()). It requests memory pages directly from the operating system. This is the simplest approach, but every allocation incurs a system call.

Core API:

MethodPurposeCounterpart in Other Languages
allocator.alloc(T, n)Allocate contiguous space for n elements of type T, returns []TGo: make([]T, n), Rust: vec![T; n]
allocator.free(slice)Free a previously allocated sliceGo: GC auto, Rust: Drop auto
allocator.create(T)Allocate a single T instance, returns *TGo: &T{}, Rust: Box::new(T)
allocator.destroy(ptr)Free an instance allocated via create

Note that alloc/free and create/destroy are paired—mixing them leads to undefined behavior.

defer and Resource Management

The defer allocator.free(buffer) pattern in the code above is critical—defer ensures deallocation executes no matter where the function returns. In Zig, this replaces Go’s GC and Rust’s Drop trait as the primary mechanism for manual memory management.

zig
1
2
3
4
5
6
7
8
9
fn example(allocator: std.mem.Allocator) !void {
    const a = try allocator.alloc(u8, 10);
    defer allocator.free(a);

    const b = try allocator.alloc(u8, 20);
    defer allocator.free(b);

    // On function exit, b is freed first, then a (defer executes in reverse order)
}

For Go developers: In Go, make([]byte, 100) behind the scenes is a GC-managed heap allocation—you neither need to nor can manually control deallocation. In Zig, every alloc must have a matching free. First habit when writing Zig: write defer immediately after every allocation.

For Rust developers: Rust’s Box::new and Vec::push use a global allocator—you cannot switch allocation strategies without changing the global setting. Zig’s allocators are passed as parameters—you can use different allocators for different modules or even different objects.

Common Allocators

Zig’s standard library provides a variety of allocators for different scenarios. The core principle: no single allocator fits all—choose the right one for your use case.

page_allocator

Requests memory pages directly from the OS. No initialization needed—use the global value directly. Since every allocation and deallocation involves a system call, it is suitable for infrequent, large allocations, not for high-frequency small objects.

zig
1
const allocator = std.heap.page_allocator;

ArenaAllocator

The arena allocator—create once, allocate in batches, then free everything at once. One of Zig’s most distinctive allocators:

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fn processLotsOfData(allocator: std.mem.Allocator) !void {
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();

    const arena_allocator = arena.allocator();

    for (0..1000) |_| {
        const item = try arena_allocator.create(i32);
        item.* = 42;
    }
    // No need to free each element individually—
    // arena.deinit() releases everything at once
}

Arena’s public API remains unchanged in Zig 0.16:

  1. .init(child) — Takes a backing allocator (typically page_allocator or a debug allocator)
  2. .allocator() — Returns an Allocator instance for subsequent allocations
  3. .deinit() — Frees all memory allocated through this arena at once

No need to free each element individuallyarena.deinit() reclaims all memory. This is extremely efficient when processing batch data (e.g., parsing requests, handling many temporary objects).

Zig 0.16 improvement: ArenaAllocator’s internal implementation has been rewritten to be lock-free and thread-safe. Multiple threads can share the same arena for allocation without additional locking. Before 0.16, you needed to wrap the arena in std.heap.ThreadSafeAllocator for concurrent use—this wrapper has been removed in 0.16 because ArenaAllocator and others are already thread-safe on their own.

FixedBufferAllocator

Allocates from a pre-provided fixed byte slice—no heap allocation at all. Suitable for scenarios with known maximum memory requirements (embedded systems, interrupt handlers, or any environment that disallows heap allocation). Returns error.OutOfMemory when the buffer is exhausted.

Debug Allocators

Zig’s standard library provides DebugAllocator for development: it detects memory leaks, double frees, out-of-bounds writes, and other common errors. In unit tests, std.testing.allocator is the corresponding test allocator, automatically reporting leaks in test output.

The older standard library had a general-purpose allocator called GeneralPurposeAllocator. In Zig 0.16, the debug allocator is available as DebugAllocator with leak detection. Consult the current standard library documentation for the exact API.

Allocator Selection Quick Reference

AllocatorUse CaseHeap AllocationPerformanceThread-Safe
page_allocatorPrototyping, infrequent large blocksYes (syscall)SlowestYes
ArenaAllocatorBatch temporary objectsDepends on backing allocatorVery fast (batch free)Yes (0.16+ lock-free)
FixedBufferAllocatorEmbedded, deterministic sizeNoFastestNo (single-threaded)
DebugAllocatorDevelopment & debuggingYesSlow (with checks)Implementation-dependent
std.testing.allocatorUnit testsYesSlow (leak detection)

Three-Language Comparison

DimensionGoRustZig
Allocationnew / make implicitBox::new, Vec::new, etc.Explicit allocator.alloc / allocator.create
DeallocationGC auto-collectsDrop trait auto-freesManual allocator.free + defer
Allocator customizationNot customizable#[global_allocator] global settingEvery allocation as a parameter
Memory safetyGC guarantees release (not leaks)Borrow checker at compile timeDeveloper responsible (debug allocator assists)
Common pitfallsForgetting * causes copiesLifetime annotationsForgetting defer allocator.free causes leaks
Switching strategiesNot possibleGlobal switchCaller decides

Notes for Go Developers

In Go, you almost never think about “which allocator to use”—the GC handles everything. In Zig, every allocation is a deliberate choice. It may feel cumbersome at first, but you’ll soon appreciate the benefits of this explicitness:

  • You know exactly when and where your code allocates memory
  • You can use different allocation strategies for different modules
  • Real-time systems can use FixedBufferAllocator to guarantee zero heap allocation
  • Batch processing with ArenaAllocator eliminates per-element free overhead
  • No GC means no STW pauses—predictable latency

Notes for Rust Developers

Rust’s ownership system frees you from manual free, but the global allocator means switching allocation strategies is not the caller’s concern. Zig’s “allocator as a parameter” pattern gives the caller control over allocation strategy—a library function doesn’t dictate allocator choice; use whatever allocator you pass in.

Zig also has no borrow checker—no “fighting the borrow checker” experience. The trade-off is that you must ensure no use-after-free or double-free yourself. The debug allocator becomes your best friend in development.

Summary

Zig’s memory management philosophy can be summed up in one sentence: no implicit allocation, no implicit deallocation—everything is visible in the type system.

Key takeaways:

  • “Any function that might allocate must accept an Allocator parameter”—Zig’s most iconic convention, the core design decision that sets it apart from Go and Rust
  • page_allocator is the simplest allocator—a global value used directly, no initialization needed
  • ArenaAllocator excels at batch allocation + bulk deallocation; as of 0.16 it is lock-free and thread-safe internally, eliminating the need for ThreadSafeAllocator
  • FixedBufferAllocator provides zero-heap-allocation guarantees, suitable for deterministic memory scenarios
  • Debug allocators should always be used in development to catch leaks and out-of-bounds errors
  • Allocator-as-parameter makes it natural to switch allocation strategies across different scenarios

In the next article, we will explore Zig’s most powerful feature—Comptime, compile-time computation. You will see how Zig uses identical syntax for compile-time and runtime code, enabling generics, reflection, and even domain-specific languages.