· 5 min read ·

Why Zig's Allocator Pattern Changes How You Think About Memory

Source: lobsters

Most systems languages hide memory allocation behind global state or trait implementations. Zig takes the opposite approach. Every function that needs to allocate memory accepts an Allocator as an explicit parameter. This sounds like boilerplate until you realize what it enables.

In an interview with Andrew Kelley, the Zig creator discusses the philosophy behind the language’s design. While the conversation covers many topics, the allocator pattern deserves deeper examination because it represents a fundamental rethinking of how memory management interfaces with API design.

The Standard Approach

In C++, allocation typically happens through new and delete, or smart pointers that call them internally. The allocator is either the global heap or a template parameter that most code never specifies:

std::vector<int> numbers;  // Uses default allocator
numbers.push_back(42);     // Allocation hidden inside push_back

Rust improves on this with Box, Rc, and Arc, but the allocator is still implicit in most standard library code. You can use custom allocators through the unstable allocator API, but it remains opt-in complexity.

let mut numbers = Vec::new();  // Global allocator
numbers.push(42);              // Allocation happens somewhere

Both approaches optimize for convenience. The allocator is infrastructure, something most code should not need to think about. Zig questions this assumption.

Allocation as a First-Class Concern

In Zig, there is no global allocator. If a function needs to allocate, it must receive an Allocator parameter:

const ArrayList = std.ArrayList;
const allocator = std.heap.page_allocator;

var numbers = ArrayList(i32).init(allocator);
try numbers.append(42);
defer numbers.deinit();

The ArrayList initialization takes an allocator explicitly. The append method uses that allocator when it needs to grow the backing array. There is no hidden state, no thread-local variable, no global function it calls behind the scenes.

This looks verbose compared to C++ or Rust, but the verbosity is local to the initialization. Once you have the data structure, you use it normally. The important part is that the allocation strategy is visible at construction time.

What This Enables

Explicit allocators make it trivial to swap memory strategies without changing downstream code. Want to use an arena allocator for a batch of allocations you’ll free all at once?

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();

const allocator = arena.allocator();
var list1 = ArrayList(i32).init(allocator);
var list2 = ArrayList(i32).init(allocator);

// Use list1 and list2
// arena.deinit() frees everything at once

The lists do not know or care that they are using an arena. They just call allocator.alloc() and allocator.free(). The arena implementation handles the rest.

Want to track allocations for debugging? Write a wrapper allocator:

const TrackingAllocator = struct {
    parent: Allocator,
    bytes_allocated: usize,

    pub fn allocator(self: *TrackingAllocator) Allocator {
        return Allocator{
            .allocFn = alloc,
            .resizeFn = resize,
        };
    }

    fn alloc(ctx: *anyopaque, len: usize, ptr_align: u29, ret_addr: usize) ?[*]u8 {
        const self = @ptrCast(*TrackingAllocator, @alignCast(@alignOf(TrackingAllocator), ctx));
        const result = self.parent.rawAlloc(len, ptr_align, ret_addr);
        if (result) |ptr| {
            self.bytes_allocated += len;
        }
        return result;
    }
};

Pass this tracking allocator to your code, and every allocation flows through your instrumentation. No need to recompile with special flags or link against a profiling library. The allocation path is just function calls.

Testing Benefits

Explicit allocators make testing memory-intensive code straightforward. Zig’s standard library includes std.testing.allocator, which tracks every allocation and verifies that your code frees what it allocates:

test "parseJSON does not leak" {
    const allocator = std.testing.allocator;
    const result = try parseJSON(allocator, "{\"key\": \"value\"}");
    defer result.deinit();
    
    try std.testing.expectEqualStrings("value", result.get("key"));
    // Test fails if parseJSON leaked any allocations
}

If parseJSON forgets to free something, the test fails with details about where the leaked allocation occurred. This works because the allocator is a parameter you control in tests, not global state you have to mock.

The Design Trade-off

Explicit allocators add a parameter to many function signatures. Kelley argues this is acceptable because allocation is genuinely important. Hiding it behind global state does not make it cheaper or simpler; it just makes it harder to reason about.

The pattern also forces library authors to confront memory management at the API boundary. A library function that allocates must declare this in its signature. Users see immediately that calling this function might fail if allocation fails, and they know where the memory comes from.

This is not free abstraction. You pay with slightly longer function signatures. In return, you get explicit control over memory strategy, trivial testing of allocation behavior, and no hidden performance surprises from allocations you did not expect.

Comparing with Rust’s Approach

Rust has experimented with custom allocators through the Allocator trait, but it remains unstable and rarely used. Most Rust code relies on the global allocator, which you can replace at the program level but not at the function level.

Zig’s approach is more granular. Different subsystems in the same program can use different allocators without coordination. A network server might use arena allocators for per-request data, a page allocator for long-lived structures, and a fixed buffer allocator for a hot path that cannot tolerate allocation failure.

Rust achieves memory safety through ownership and borrowing. Zig achieves clarity through explicit parameters. Both are valid approaches to the same underlying problem: programs need to manage resources, and the language should help rather than obscure this.

Adoption Implications

Zig remains pre-1.0, so the allocator pattern could still evolve. However, it has been stable for several releases, and major projects like the Zig compiler itself use it extensively. The pattern appears to scale from small utilities to large codebases.

For developers coming from C, explicit allocators feel familiar. You are already accustomed to managing memory manually; Zig just provides better abstractions for it. For developers coming from garbage-collected languages, the pattern requires adjustment. You must think about where memory comes from and when it gets freed.

The real test will come as more production systems adopt Zig. Will explicit allocators prove maintainable in large teams? Will the pattern evolve, or will community pressure push toward more implicit defaults? The language is young enough that these questions remain open.

Conclusion

Explicit allocators are not a novel idea. Custom memory pools have existed since the early days of C. What makes Zig’s approach interesting is codifying the pattern at the language level. Every standard library API that allocates takes an allocator parameter. This consistency means you can rely on the pattern, build tooling around it, and trust that libraries will follow the same conventions.

The pattern trades convenience for visibility. Whether that trade-off is worthwhile depends on your priorities. For systems where performance and resource control matter, making allocation explicit is a defensible choice. For applications where developer ergonomics dominate, the extra parameter might feel like needless ceremony.

Zig forces the question: is allocation infrastructure that should be hidden, or a first-class concern that APIs should acknowledge? The answer shapes everything from function signatures to testing strategies to how you decompose problems into modules.

Was this interesting?