Lesson 10 / 25
Allocators: Explicit Memory Management
Choose and use allocators for different lifetimes.
No hidden allocations
Zig has no global heap that functions use behind your back. Any function that needs dynamic memory takes a std.mem.Allocator parameter, an interface with alloc, free, create, destroy, realloc and dupe. The caller chooses the strategy: std.heap.page_allocator asks the OS for pages directly (simple, slow for small allocations); std.heap.DebugAllocator (formerly GeneralPurposeAllocator) detects leaks, double frees and use-after-free in development; std.heap.ArenaAllocator wraps another allocator and frees everything at once with deinit, ideal for request-scoped or phase-scoped data; std.heap.FixedBufferAllocator allocates from a buffer you provide, with no heap at all, ideal for embedded or hot paths; std.heap.smp_allocator is a fast general-purpose allocator for release builds; and std.heap.c_allocator uses C's malloc when linking libc. std.testing.allocator fails tests that leak. Passing allocators explicitly makes memory use visible, testable and swappable: the same parsing code can use an arena in a server and a fixed buffer on a microcontroller.
Choosing an allocator
The caller picks the strategy; library code just accepts an Allocator.
Debug, arena and fixed-buffer allocators
The same function used with three allocation strategies.
const std = @import("std");
// Library code: allocates, and documents that the caller owns the result
fn formatReceipt(allocator: std.mem.Allocator, customer: []const u8, total_paise: u64) ![]u8 {
return std.fmt.allocPrint(allocator, "Receipt for {s}: Rs {d}.{d:0>2}", .{
customer, total_paise / 100, total_paise % 100,
});
}
pub fn main() !void {
// 1. DebugAllocator: reports leaks when deinit is called
var debug_alloc: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_alloc.deinit();
const gpa = debug_alloc.allocator();
const r1 = try formatReceipt(gpa, "Asha", 24_855);
defer gpa.free(r1); // caller frees
std.debug.print("{s}\n", .{r1}); // Receipt for Asha: Rs 248.55
// 2. Arena: many allocations, one free
var arena = std.heap.ArenaAllocator.init(gpa);
defer arena.deinit(); // frees everything allocated below
const a = arena.allocator();
for ([_][]const u8{ "Ravi", "Meera", "Kabir" }) |name| {
const r = try formatReceipt(a, name, 9_900);
std.debug.print("{s}\n", .{r}); // no individual free needed
}
// 3. FixedBufferAllocator: no heap at all
var buffer: [128]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const r3 = try formatReceipt(fba.allocator(), "Zoya", 100);
std.debug.print("{s}\n", .{r3}); // fails with OutOfMemory if 128 bytes is not enough
}Arenas simplify lifetimes
When many allocations share one lifetime (one HTTP request, one compiler pass, one frame of a game), an arena removes almost all free calls and the bugs that come with them; you free everything with a single deinit.
Quick check: Why do Zig functions that allocate take an Allocator parameter?
- Because Zig has a garbage collector
- For faster compilation
- So callers control and can see the memory strategy, and allocation is never hidden
- Because global variables are forbidden
Answer
So callers control and can see the memory strategy, and allocation is never hidden — Explicit allocators make memory behaviour visible and swappable.