Zig Allocator Patterns
Choosing the right allocator for each part of your program is a key skill in Zig. Different allocators have different performance profiles, lifetime rules, and debugging capabilities. This topic goes deeper than the introduction, covering pool allocators, custom allocators, allocator composition, and the patterns experienced Zig developers use in production code.
The Allocator Hierarchy
Page Allocator (OS)
│
├── GeneralPurposeAllocator ← debug + leak detection
│ │
│ └── ArenaAllocator ← free all at once
│
├── FixedBufferAllocator ← stack memory, no OS calls
│
└── MemoryPool(T) ← fixed-size object reuse
Arena Allocator — Bulk Free Pattern
An arena allocates freely but only frees everything at once. Perfect for request handling, parsers, and any workflow with a clear start and end:
const std = @import("std");
fn handleRequest(parent_alloc: std.mem.Allocator, request: []const u8) !void {
// Create an arena that lasts for this request only
var arena = std.heap.ArenaAllocator.init(parent_alloc);
defer arena.deinit(); // frees ALL request allocations at once
const alloc = arena.allocator();
// Allocate freely — no individual frees needed
const parsed = try parseRequest(alloc, request);
const headers = try buildHeaders(alloc, parsed);
const body = try generateBody(alloc, parsed);
_ = headers;
_ = body;
// When this function returns, arena.deinit() frees everything
}
fn parseRequest(alloc: std.mem.Allocator, raw: []const u8) ![]const u8 {
return try alloc.dupe(u8, raw);
}
fn buildHeaders(alloc: std.mem.Allocator, _: []const u8) ![]u8 {
return try alloc.alloc(u8, 256);
}
fn generateBody(alloc: std.mem.Allocator, _: []const u8) ![]u8 {
return try alloc.alloc(u8, 1024);
}
Request arrives
│
arena.init() ← one allocator created
│
parse → alloc 100B ─┐
headers → alloc 256B ─┤ all tracked by arena
body → alloc 1024B ─┘
│
Send response
│
arena.deinit() ← 100+256+1024 bytes freed in one call
MemoryPool — Fixed-Size Object Reuse
A memory pool pre-allocates slots for objects of one specific type. Allocation and deallocation become nearly instant — no searching for free space, just mark a slot used or free:
const std = @import("std");
const Particle = struct {
x: f32,
y: f32,
vx: f32,
vy: f32,
life: f32,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
var pool = std.heap.MemoryPool(Particle).init(gpa.allocator());
defer pool.deinit();
// Allocate particles from the pool
const p1 = try pool.create();
const p2 = try pool.create();
const p3 = try pool.create();
p1.* = .{ .x = 0, .y = 0, .vx = 1, .vy = 0.5, .life = 1.0 };
p2.* = .{ .x = 5, .y = 2, .vx = -1, .vy = 1, .life = 0.8 };
p3.* = .{ .x = 3, .y = 7, .vx = 0, .vy = -1, .life = 0.5 };
// Return a dead particle to the pool for reuse
pool.destroy(p2);
const p4 = try pool.create(); // reuses p2's slot
p4.* = .{ .x = 1, .y = 1, .vx = 0.5, .vy = 0.5, .life = 1.0 };
std.debug.print("p1: ({d},{d})\n", .{p1.x, p1.y});
std.debug.print("p4: ({d},{d})\n", .{p4.x, p4.y});
pool.destroy(p1);
pool.destroy(p3);
pool.destroy(p4);
}
Pool slots: [ slot 0 = p1 ][ slot 1 = p2 ][ slot 2 = p3 ] destroy(p2): [ slot 0 = p1 ][ slot 1 = FREE ][ slot 2 = p3 ] create() → reuses slot 1: [ slot 0 = p1 ][ slot 1 = p4 ][ slot 2 = p3 ]
Stacked Allocators — Composition
Allocators compose — wrap one inside another for layered behavior:
pub fn main() !void {
// Layer 1: page allocator (talks to OS)
const page_alloc = std.heap.page_allocator;
// Layer 2: GPA wraps page_alloc for leak detection
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
// Layer 3: arena wraps GPA for bulk-free
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
// Use the arena for this scope's allocations
const alloc = arena.allocator();
const data = try alloc.alloc(u8, 512);
_ = data;
// arena.deinit() → GPA.free() → page_alloc.free() → OS
_ = page_alloc;
}
Allocation chain:
arena.alloc(512)
│
gpa.alloc(512) ← tracks for leak detection
│
page_alloc.alloc(512) ← requests from OS
│
OS grants memory page
Logging Allocator — Debugging Allocations
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
// Wrap with logging to print every alloc/free
var log_alloc = std.heap.loggingAllocator(gpa.allocator());
const alloc = log_alloc.allocator();
const buf = try alloc.alloc(u8, 100); // prints: alloc 100 bytes
defer alloc.free(buf); // prints: free 100 bytes
Failing Allocator — Testing Out-of-Memory Paths
// Test what your code does when allocation fails
var fail_alloc = std.testing.FailingAllocator.init(
std.testing.allocator,
.{ .fail_index = 2 }, // fail on the 3rd allocation (index 2)
);
const alloc = fail_alloc.allocator();
const a = try alloc.alloc(u8, 10); // succeeds (index 0)
const b = try alloc.alloc(u8, 20); // succeeds (index 1)
_ = a; _ = b;
const c = alloc.alloc(u8, 30) catch |err| blk: {
std.debug.print("Got expected error: {}\n", .{err});
break :blk null;
};
_ = c;
Allocator Selection Guide
Situation Best Allocator ────────────────────────────────────────────────────────── General development / debugging GeneralPurposeAllocator Many small request-scoped allocs ArenaAllocator Many objects of one type MemoryPool(T) Embedded / no OS heap FixedBufferAllocator Backing store for other allocators page_allocator Debugging allocation behavior loggingAllocator Testing OOM error paths FailingAllocator CLI tools, short-lived programs page_allocator direct
The Allocator Interface — Writing Your Own
// Every allocator implements this interface:
pub const Allocator = struct {
ptr: *anyopaque,
vtable: *const VTable,
pub const VTable = struct {
alloc: *const fn(ctx, n, align, ret_addr) ?[*]u8,
resize: *const fn(ctx, buf, new_n, ret_addr) bool,
free: *const fn(ctx, buf, ret_addr) void,
};
};
Any struct that implements these three operations — alloc, resize, free — becomes a valid Zig allocator. You can write an allocator that logs to a file, rounds sizes to power-of-two, tracks peak usage, or enforces a budget. The interface is small and the barrier to implementation is low.
