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.”