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.Allocatorparameter.
No exceptions, no hidden malloc, no implicit GC allocation. What you see is what will happen.
Three Languages, Three Philosophies
| Language | Approach | Typical Overhead | Developer Control |
|---|---|---|---|
| Go | Garbage Collection (GC) | GC pauses, doubled memory footprint | Low—tuning GC is an art |
| Rust | Ownership + Borrow Check + Drop | Compile-time checks (zero runtime cost) | Medium—ownership rules sometimes force data structure redesign |
| Zig | Manual management + Allocator Pattern | Entirely up to you | High—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:
| |
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:
| Method | Purpose | Counterpart in Other Languages |
|---|---|---|
allocator.alloc(T, n) | Allocate contiguous space for n elements of type T, returns []T | Go: make([]T, n), Rust: vec![T; n] |
allocator.free(slice) | Free a previously allocated slice | Go: GC auto, Rust: Drop auto |
allocator.create(T) | Allocate a single T instance, returns *T | Go: &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.
| |
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, everyallocmust have a matchingfree. First habit when writing Zig: writedeferimmediately after every allocation.
For Rust developers: Rust’s
Box::newandVec::pushuse 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.
| |
ArenaAllocator
The arena allocator—create once, allocate in batches, then free everything at once. One of Zig’s most distinctive allocators:
| |
Arena’s public API remains unchanged in Zig 0.16:
.init(child)— Takes a backing allocator (typicallypage_allocatoror a debug allocator).allocator()— Returns anAllocatorinstance for subsequent allocations.deinit()— Frees all memory allocated through this arena at once
No need to free each element individually—arena.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.ThreadSafeAllocatorfor 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 asDebugAllocatorwith leak detection. Consult the current standard library documentation for the exact API.
Allocator Selection Quick Reference
| Allocator | Use Case | Heap Allocation | Performance | Thread-Safe |
|---|---|---|---|---|
page_allocator | Prototyping, infrequent large blocks | Yes (syscall) | Slowest | Yes |
ArenaAllocator | Batch temporary objects | Depends on backing allocator | Very fast (batch free) | Yes (0.16+ lock-free) |
FixedBufferAllocator | Embedded, deterministic size | No | Fastest | No (single-threaded) |
DebugAllocator | Development & debugging | Yes | Slow (with checks) | Implementation-dependent |
std.testing.allocator | Unit tests | Yes | Slow (leak detection) | — |
Three-Language Comparison
| Dimension | Go | Rust | Zig |
|---|---|---|---|
| Allocation | new / make implicit | Box::new, Vec::new, etc. | Explicit allocator.alloc / allocator.create |
| Deallocation | GC auto-collects | Drop trait auto-frees | Manual allocator.free + defer |
| Allocator customization | Not customizable | #[global_allocator] global setting | Every allocation as a parameter |
| Memory safety | GC guarantees release (not leaks) | Borrow checker at compile time | Developer responsible (debug allocator assists) |
| Common pitfalls | Forgetting * causes copies | Lifetime annotations | Forgetting defer allocator.free causes leaks |
| Switching strategies | Not possible | Global switch | Caller 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
FixedBufferAllocatorto guarantee zero heap allocation - Batch processing with
ArenaAllocatoreliminates 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
Allocatorparameter”—Zig’s most iconic convention, the core design decision that sets it apart from Go and Rust page_allocatoris the simplest allocator—a global value used directly, no initialization neededArenaAllocatorexcels at batch allocation + bulk deallocation; as of 0.16 it is lock-free and thread-safe internally, eliminating the need forThreadSafeAllocatorFixedBufferAllocatorprovides 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.