Getting My Allocators Straight
Sep 07, 2025
One of Zig’s distinguishing features is that there are no hidden memory allocations. Functions that need to allocate memory, even those in the standard library, advertise that they allocate memory by accepting an “allocator” parameter. The allocator parameter gives the caller control over which concrete allocator the function should use and therefore also control over how and where the function allocates memory.
Another of Zig’s distinguishing features is that the standard library is, let’s say, economical when it comes to documentation. There are several different concrete allocator implementations in the standard library; I’ve found it hard to understand what they all do or how they work. People not directly involved with Zig’s development have filled in some blanks by writing guides and blog posts explaining Zig’s allocators, but these resources don’t cover all the allocators I can find in the standard library (as of Zig 0.15.1) and still leave me with questions.
This is my attempt to figure out how all the available allocators work and what each one is for. What I’ve discovered here is based on the little official documentation I could find and on my (possibly incorrect) reading of standard library code.
To make things easier to wrap my head around, I’ve divided the available allocators into four categories: the primitive allocators, the basic allocators, the advanced allocators, and the utility allocators.
The Primitive Allocators
The primitive allocators directly manage memory without delegating to another allocator. More sophisticated allocators are built on top of the primitive allocators.
The primitive allocators include std.heap.FixedBufferAllocator,
std.heap.PageAllocator, std.heap.SbrkAllocator, and
std.heap.WasmAllocator.
The important ones here are FixedBufferAllocator and PageAllocator.
SbrkAllocator seems to exist specifically to support Plan 9, which I believe
does not have the mmap() system call (so sbrk() must be used
instead). WasmAllocator is only relevant when targeting WebAssembly.
FixedBufferAllocator is the simplest of all the allocators. It doesn’t even
really allocate anything; it must be initialized with a slice of memory. A
FixedBufferAllocator will then allow you to alloc() and free() chunks
from this slice. But the allocator never has access to more memory than you
first initialized it with. You’d use a FixedBufferAllocator when you know at
compile-time how much memory you will need and can’t just use stack memory
because you require something that conforms to the std.mem.Allocator
interface.
Calling free() on a FixedBufferAllocator doesn’t do anything unless the
memory you are trying to free was the last thing you allocated. Typically you’d
just call reset() to free all the memory at once.
PageAllocator is the primary allocator for requesting memory from the
operating system. It does this, at least on POSIX platforms, using the mmap()
system call. It invokes mmap() each time you call alloc() and never maps
less than a page of memory, so this is not an allocator for making many small
allocations. Instead, this allocator is meant to be used by other allocators to
request large chunks of memory that then get managed internally.
The Basic Allocators
The “basic” allocators are the ones you’ll probably use most in Zig. They build
on the primitive allocators to provide memory-handling strategies useful in
most Zig programs. In my taxonomy, there are two basic allocators:
std.heap.DebugAllocator and std.heap.ArenaAllocator.
DebugAllocator used to be called GeneralPurposeAllocator. It wraps
PageAllocator and does its own bookkeeping so that you can performantly
alloc() and free() small chunks of memory. It implements useful debug
functionality like tracking leaks, printing stack traces on double-frees, etc.
When you need a general-purpose allocator (i.e. one that supports lots of
arbitrarily interleaved alloc() and free() calls) but are more concerned
about safety than performance, DebugAllocator is the right choice.
The name DebugAllocator suggests to me that this allocator should never be
used in release builds, but that doesn’t seem to be the case. The Zig compiler
currently uses a DebugAllocator as its general-purpose allocator when built
in “Release Safe” mode. It’s only when building in “Release Fast” or “Release
Small” (or when linking with libc) that another allocator is used instead.
|
|
DebugAllocator is currently Zig’s “default” general-purpose allocator, but
that seems likely to change in the future. SmpAllocator (discussed later) or
an entirely new allocator may become the default, in which case that
allocator should be considered basic and DebugAllocator should be relegated
to the utility allocators.
An ArenaAllocator is sort of like a FixedBufferAllocator that can grow.
Like with FixedBufferAllocator, calling free() on an ArenaAllocator
doesn’t do anything unless you are freeing the last chunk of memory allocated.
Instead, instances of ArenaAllocator are designed to support lots of calls to
alloc() followed by a single call to reset(). You would use an
ArenaAllocator to hold all the memory you need to handle a single HTTP
request or to render a single frame in a video game—wherever there are
independent “cycles” of computation that don’t need to share memory. Using an
ArenaAllocator in cases like these can be more performant than using a
general-purpose allocator.
Internally, an ArenaAllocator manages a linked list of buffers allocated
using a backing allocator. This linked list grows as you request more memory
with alloc(). Each new buffer that has to be added to the list is half again
as large as the previous buffer. This is an optimization meant to reduce the
total number of buffers that need to be allocated while keeping the memory
footprint of the arena low to start.
Unlike DebugAllocator, which always uses a PageAllocator, ArenaAllocator
expects you to specify the backing allocator when you initialize the struct. I
find this confusing. I’m not sure why you’d want to use anything other than a
PageAllocator as the backing allocator, unless perhaps you want to use a
DebugAllocator to trace allocations. Using a non-debug general purpose
allocator as the backing allocator might minimize memory usage if you only
allocate a small amount of memory relative to the page size. That’s my best
guess. But I suspect doing this would generally be less performant than using a
PageAllocator.
The Advanced Allocators
These allocators are “advanced” because you might only reach for them when writing certain kinds of Zig programs. I haven’t personally used any of these allocators yet so please don’t trust what I have to say about them too much.
If you are already linking against a libc and need a general-purpose allocator,
you can use the one provided by your libc. std.heap.c_allocator makes the
libc allocator available. As shown in the snippet above, the Zig compiler uses
the C allocator whenever it’s linked with libc.
In a multi-threaded Zig program, you shouldn’t share an allocator across
threads without first ensuring it is thread-safe.
std.heap.ThreadSafeAllocator is nothing more than a wrapper that mediates
access to its backing allocator with a mutex. You could use it to share an
ArenaAllocator across threads.
std.heap.SmpAllocator is new as of Zig 0.15.1. It is a performant,
general-purpose allocator with thread-safety baked in. According to Andrew
Kelley, SmpAllocator is for
“Release Fast” builds that use multi-threading.
Finally, there’s std.heap.MemoryPool. I haven’t seen this used in the wild
yet, but it’s a specialized allocator for allocating objects all of one type.
It only supports the create() and destroy() methods, so properly speaking
it isn’t an allocator (it doesn’t conform to the std.mem.Allocator
interface). But I can imagine it would be useful in something like a game
engine. It uses an ArenaAllocator under the hood, which it initializes with
the backing allocator you pass in. So don’t make the mistake of initializing a
MemoryPool with an ArenaAllocator!
The Utility Allocators
This final category includes just std.testing.allocator and
std.testing.FailingAllocator. These allocators are used more for their
additional behavior than for the memory management strategy they implement. As
I mentioned above, DebugAllocator would belong here if Zig weren’t currently
missing an alternative general-purpose allocator for single-threaded programs.
The testing allocator, std.testing.allocator, is just a DebugAllocator
initialized with a configuration specific to tests. It has a safety check to
make sure you don’t mistakenly use it outside of test code.
FailingAllocator is an allocator that always fails to allocate anything. (It
tries hard though, the poor thing.) This is useful for making sure, in tests,
that your code can handle out-of-memory errors.
Bonus: The Zig Language Reference on Allocators
The Zig Language Reference includes a helpful section on choosing an allocator. This section claims to present a “flow chart” for choosing an allocator but in fact gives you a numbered list. Here is an actual flow chart:

Choosing an allocator according to the language reference.
I should explain that “CLI tool” here refers to a command-line program that “runs from start to finish without any fundamental cyclical pattern.”