A Field Guide to Zig Allocators
- Date
I’ve been learning Zig, and the thing that stuck with me isn’t comptime or the syntax everyone posts about. It’s that the language flat out refuses to allocate memory behind your back.
In most languages, memory just appears. You write new Thing() or malloc(64), or you let the garbage collector sort it out, and where those bytes actually came from is somebody else’s problem. Zig makes a different call: any function that needs to allocate takes an Allocator as a parameter. If a function doesn’t take one, it can’t allocate. You can tell what a function does to memory from its signature alone.
Sounds like a chore. In practice it’s the most interesting decision in the language, because it means the strategy for handing out memory is just a value you pass around. Swap the value, swap the behavior, and the code in between never changes.
That’s the part I want to dig into here. Not “how do I allocate in Zig,” but the handful of strategies you’d actually pass in: what each one is good at, and where each one bites you. There’s a little interactive strip in each section. Tap a size to allocate, tap a block to free it, or drag a block’s right edge to resize it, and watch how the same requests play out differently depending on who’s handing out the memory.
One interface, many strategies
The whole thing hangs on one small type. Simplified a little, std.mem.Allocator is a pointer plus a table of function pointers:
pub const Allocator = struct {
ptr: *anyopaque,
vtable: *const VTable,
pub const VTable = struct {
alloc: *const fn (ctx: *anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8,
resize: *const fn (ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) bool,
remap: *const fn (ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) ?[*]u8,
free: *const fn (ctx: *anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void,
};
};
Four functions. alloc hands you bytes, free gives them back, resize and remap try to grow or shrink in place. Every allocator in the standard library, and every one you write yourself, is just a different implementation of those four.
So a function that needs memory doesn’t care which one it got:
fn readLines(allocator: std.mem.Allocator, path: []const u8) ![][]u8 {
// ... uses allocator.alloc, allocator.free, and friends
}
Call it with an arena and every line lives until you drop the arena. Call it with a general purpose allocator and you free each line yourself. Same function. The caller picks the memory strategy, not the library. That inversion is the whole point, and once it clicks you start seeing the plain Allocator as a slot you drop a strategy into.
Here’s the lineup.
The bump allocator: fast, and it never gives anything back
Start with the simplest thing that could possibly work. You have a block of memory and a pointer to the next free spot. Every allocation hands back the pointer and shoves it forward by the requested size. That’s it. That’s the whole allocator.
std.heap.ArenaAllocator Bump a pointer forward. Free it all at once.Tap a size to allocate. Tap a block to free it, or drag its right edge to resize.
Allocate a few blocks and you can watch the region fill from the left. Now tap one to free it. Nothing happens, and the demo tells you why: a bump allocator has no idea how to reclaim one allocation out of the middle. There’s a single pointer and it only goes forward. You can drag the top block’s edge to grow it, since that’s just nudging the pointer a little further, but the blocks behind it don’t even have a handle to grab, and the only way to get memory back is to reset the whole thing, which frees everything at once.
That sounds broken until you notice how often it’s exactly what you want. Zig calls this an arena:
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
const a = try allocator.alloc(u8, 64);
const b = try allocator.alloc(u8, 128);
// no free() anywhere. deinit() drops all of it in one shot.
Think about handling a single web request. You allocate a pile of temporary junk, parse some JSON, build a response, and the second the request is done, all of it is garbage. You don’t want to free thirty things in the right order. You want to wipe the slate. An arena turns thirty frees into one, and every allocation in between costs about as much as bumping a pointer.
The tradeoff is right there in the demo: you can’t free just one thing. If some allocations in an arena are short lived and others need to stick around, the long lived ones pin the whole region, and your “temporary” memory quietly becomes permanent until the reset. Arenas are for phases with a clear end, not for objects with their own individual lifetimes.
(A stack allocator is the close cousin here: same bump pointer, but it lets you free the most recent allocation and roll the pointer back, as long as you free in reverse order. You’ll see exactly that behavior in the next section.)
The fixed buffer allocator: same idea, no heap at all
Take that bump allocator and hand it a buffer that lives on the stack. No malloc, no OS, no heap. Just an array and an offset.
var buf: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buf);
const allocator = fba.allocator();
Everything about the bump behavior carries over, with one hard new rule: the buffer is a fixed size and it never grows. Fill it up and the next allocation returns error.OutOfMemory. The demo below is deliberately tiny so you can hit the wall fast.
std.heap.FixedBufferAllocator A bump allocator on a fixed stack buffer. No heap.Tap a size to allocate. Tap a block to free it, or drag its right edge to resize.
Keep allocating and you’ll run out of room, and the allocator says so instead of quietly grabbing more from somewhere. That failure is a feature. On an embedded target with no heap, or in a hot path where you refuse to touch the OS allocator, a fixed buffer gives you a hard, visible ceiling. You know at compile time exactly how much memory this code can use, because you’re the one who declared it.
One nuance the demo shows: a FixedBufferAllocator can free, but only the most recent allocation, because that’s the only one it can prove sits right at the top of the buffer. Free the last thing and the offset rolls back. Try to free something in the middle and it can’t, for the same reason the arena couldn’t. That “free the top” move is the stack allocator I mentioned, hiding in plain sight. Growing works the same way: drag the top block’s edge and the offset slides up, right until it would run off the end of the buffer, where you hit the wall again.
The pool allocator: one size, no fragmentation, ever
Everything so far allocates arbitrary sizes and can only free from the end. The pool goes the other direction. It commits to exactly one size up front, and in exchange it lets you free anything, in any order, for basically free.
The trick is that if every slot is identical, freeing one just puts it back on a list of empty slots. Allocating grabs the first one off that list. No searching, no measuring, no leftover gaps that don’t fit anything. Both operations are O(1), and the memory can never fragment, because there’s nothing to fragment: every hole is exactly the right size for the next request, by construction.
std.heap.MemoryPool Uniform slots. Free any, in any order, O(1).Tap a size to allocate. Tap a block to free it, or drag its right edge to resize.
Allocate and free in whatever order you like. Slots light up and go dark, and the layout never gets messy, because it can’t. In Zig this is MemoryPool, parameterized by the type it hands out:
const Node = struct { value: i32, next: ?*Node };
var pool = std.heap.MemoryPool(Node).init(std.heap.page_allocator);
defer pool.deinit();
const n1 = try pool.create();
const n2 = try pool.create();
pool.destroy(n1); // n1's slot goes straight back on the free list
This is the allocator you reach for when you’re churning through a lot of one thing: nodes in a linked list or a graph, entities in a game, particles, connection structs. The catch is right in the name. A pool of Node holds nodes and nothing else. The moment you need two sizes, you need two pools, and the “one size fits all” superpower is also the whole limitation. That’s also why the pool slots in the demo have no resize handle at all: there’s only the one size, so there’s nothing to drag.
The general purpose allocator: does everything, and now you have to think
Sooner or later you need the real thing: arbitrary sizes, freed individually, in any order, memory actually reused. This is what malloc has always done, and it’s the default mental model most of us drag in from other languages. In Zig it’s the DebugAllocator, though most people still call it the GPA out of habit:
var gpa = std.heap.DebugAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const a = try allocator.alloc(u8, 64);
defer allocator.free(a);
To pull off “any size, freed in any order,” it keeps a list of free holes and, on each request, goes hunting for one big enough. Which is where it gets interesting, and where it can bite you. Play with the demo: allocate a few blocks of different sizes, then free a couple out of the middle.
std.heap.DebugAllocator Any size, freed in any order. Watch it fragment.Tap a size to allocate. Tap a block to free it, or drag its right edge to resize.
Watch the “largest hole” number. You can have plenty of total free space and still fail to allocate, because that free space is chopped into pieces too small to use. The demo will happily tell you that you’ve got, say, 9 bytes free but the biggest run is only 3, so a request for 4 fails anyway. That’s fragmentation, and it’s the price of admission for a general purpose allocator. It’s the exact failure mode the arena and the pool are built to dodge.
Freeing next to another free block helps: the demo coalesces adjacent holes back into one bigger hole, which is what a real allocator does to fight fragmentation. But it can’t fix everything, and no general purpose allocator can. That’s the deal. In return for handling every case, it makes you pay in bookkeeping, in the occasional slow request while it hunts for a hole, and in the fragmentation you just watched happen.
Resize is where the free list really shows off. Drag a block’s right edge to grow it: if the hole right after it is big enough, the allocator just extends in place and nothing moves. If it isn’t, let go, and watch the block jump somewhere else on the strip entirely. That’s a remap, the allocator finding a bigger hole, moving your bytes there, and handing back a new pointer. It’s the same thing that happens under the hood when you grow a dynamic array and it suddenly lives at a new address.
The Zig flavored bonus: the DebugAllocator is packed with safety checks. Double frees, leaks, use after free, it catches them and points at the exact line. You run it in development, and when it’s time to ship you swap in a faster backing allocator by changing one line, because everything downstream only ever knew about the Allocator interface.
The ring buffer: run in a circle and overwrite the past
Every allocator so far treats running out of room as an error. The ring buffer treats it as the normal case. You lay your memory out in a circle: allocations march forward like a bump allocator, but when the write pointer hits the end, it wraps back to the start and writes straight over the oldest data.
circular buffer A circle. New writes overwrite the oldest.Tap a size to allocate. Tap a block to free it, or drag its right edge to resize.
Keep allocating past the end and watch the writes wrap back around to the start. The oldest blocks just get stomped, no error, no freeing, no bookkeeping. That’s the whole point, and it’s exactly what you want when you only ever care about the most recent slice of a stream: the last few seconds of audio, incoming network packets you handle and forget, the tail of a log, a history buffer for undo. The data structure is the eviction policy.
The tradeoff is that you don’t get to keep anything. A ring buffer will happily overwrite data you were still using if you let the pointer lap you, so it only fits when “old enough” reliably means “safe to throw away.” It doesn’t really resize either, because the entire idea is a fixed window that recycles itself. Zig’s std.RingBuffer is a byte-oriented take on this, and the pattern shows up all over streaming and I/O code.
The buddy allocator: split in halves, merge in pairs
The free list fought fragmentation by searching and coalescing. The buddy allocator fights it with structure instead. Every block is a power of two, and the whole region starts life as one big block. To allocate, you round the request up to the nearest power of two, then split a block in half, and half again, until you land on one the right size. Each split makes two “buddies,” and that pairing is the trick: when you free a block, if its buddy is also free, the two snap back into the bigger block they came from. Merge all the way up and you’re back to one clean region.
buddy allocation Power-of-two blocks that split and merge.Tap a size to allocate. Tap a block to free it, or drag its right edge to resize.
Allocate a few odd sizes and you can watch the region divide into neat power-of-two pieces (the demo draws each free block separately so the structure is visible). Free two buddies and they merge. This is what a lot of kernels and GPU or DMA drivers reach for, because merging is O(1) and cheap: you don’t scan a free list, you check one buddy and you’re done.
Two tradeoffs fall out of the power-of-two rule, and the demo shows both. Ask for 3 bytes and you get 4, so a byte is wasted inside the block. That’s internal fragmentation, the cost of rounding. And two free blocks sitting right next to each other won’t always merge, because they have to be actual buddies, the specific pair some split created, so you can still strand free space you can’t recombine.
All six, one strip
Here’s all six in one place. Same buffer, same buttons, and a set of tabs to swap the strategy underneath. Try running the same sequence of allocs, resizes, and frees through each one and watch where they split: the arena that won’t free, the fixed buffer that hits a wall, the pool that never makes a mess, the free list that fragments and remaps, the ring that laps itself, the buddy that halves and merges.
std.heap.ArenaAllocator Bump a pointer forward. Free it all at once.Tap a size to allocate. Tap a block to free it, or drag its right edge to resize.
That divergence is the entire argument for doing it Zig’s way. In a language where allocation is baked in, “use an arena for this request” is a rewrite. Here it’s a different value passed to the same function. The code that reads the file, parses the input, builds the tree, none of it knows or cares. It asked for memory through an interface, and you got to decide what was behind the interface.
So which one
There’s no universal answer, which is sort of the point, but here’s the rough map I’ve landed on:
- Arena when a batch of allocations share a lifetime and die together: a request, a frame, a parse pass. Free everything at once, never free anything on its own.
- Fixed buffer when you want a hard memory ceiling and no heap at all: embedded, hot paths, anywhere a clean
error.OutOfMemorybeats a surprise. - Pool when you’re churning through many of the exact same thing and want O(1) alloc and free with zero fragmentation.
- General purpose when you genuinely need arbitrary sizes and independent lifetimes and you’re willing to pay for it. In Zig, also your safety net while developing.
- Ring buffer when you only care about the most recent slice of a stream and old data is safe to overwrite: audio, packets, logs, undo history.
- Buddy when you want arbitrary-ish sizes with fast, cheap coalescing and can eat a little rounding waste: kernels, GPU and DMA memory.
Most real programs use several at once: a GPA at the root, an arena per request hanging off it, a pool for the hot inner loop. Zig doesn’t make that composition clever, it makes it boring. Everything speaks the same four function interface, so you stack them like Lego.
I came to Zig for the comptime stuff. The allocator design is what made me want to stay. Passing memory strategy around as a plain value sounds like a small thing, right up until you realize most of the memory bugs you’ve ever chased were really just the language deciding this for you, out of sight.