The question every allocation raises
In the heap lesson you saw that heap memory comes from an allocator, and that different allocators (bump, free list, pool) suit different jobs. In C, malloc is one global allocator that any function can call at any time, so you can’t tell from a function’s signature whether it allocates, or how.
Zig’s answer is: there is no global allocator. Any function that needs heap memory takes an allocator as a parameter. That has real consequences:
- You can see from a signature whether a function might allocate, and therefore might fail with
error.OutOfMemory. - The caller chooses the strategy. The same parsing function can use an arena in a web server, a fixed buffer on a microcontroller, and a leak-detecting allocator in tests.
- Running out of memory is an ordinary error you can handle, not a crash.
Using an allocator
An allocator is a value of type std.mem.Allocator, usually named gpa or allocator. The main operations:
// many items: returns a slice
const buf = try gpa.alloc(u8, 1024);
defer gpa.free(buf);
// one item: returns a pointer
const p = try gpa.create(Point);
defer gpa.destroy(p);
p.* = .{ .x = 1, .y = 2 };
// copy existing data into new memory
const copy = try gpa.dupe(u8, "hello");
defer gpa.free(copy);
Every call can fail with error.OutOfMemory, hence the try. And every allocation is followed immediately by the defer that frees it, the pattern from the last lesson. Pair them correctly: alloc with free, create with destroy.
The memory you get back is not zeroed. Its contents are undefined until you write to them.
How the interface works
std.mem.Allocator is a small struct with two fields: a pointer to the real allocator’s state (with its type hidden), and a pointer to a table of functions, called a vtable, that implement allocate, free and resize for that particular allocator. When you call gpa.alloc(...), it calls through the table. It’s the same idea as an interface in TypeScript, done by hand: anything that can produce one of these structs can be used wherever an allocator is expected.
The allocators you’ll reach for
| Allocator | Use it for
| std.heap.DebugAllocator | development: catches leaks, double frees and use-after-free (called GeneralPurposeAllocator before 0.14)
| std.heap.ArenaAllocator | many allocations freed together (a request, a frame, a parse)
| std.heap.FixedBufferAllocator | allocating from a buffer you provide, such as a stack array; no syscalls
| std.heap.page_allocator | whole pages straight from the OS; a backing allocator for others
| std.heap.c_allocator | libc’s malloc, when you link libc
| std.testing.allocator | tests; fails the test if anything leaks
A complete program
const std = @import("std");
pub fn main() !void {
var debug: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug.deinit(); // reports leaks on exit
const gpa = debug.allocator();
const nums = try gpa.alloc(u32, 8);
defer gpa.free(nums);
for (nums, 0..) |*n, i| n.* = @intCast(i * i);
var arena = std.heap.ArenaAllocator.init(gpa);
defer arena.deinit(); // frees every arena allocation at once
const scratch = arena.allocator();
const msg = try std.fmt.allocPrint(scratch, "last = {d}", .{nums[7]});
std.debug.print("{s}\n", .{msg});
}
Step by step:
DebugAllocatoris the allocator for development..initcreates it, anddefer _ = debug.deinit()checks for leaks whenmainends.debug.allocator()gives you thestd.mem.Allocatorinterface to pass around.- We allocate 8
u32s and immediately defer freeing them. - An
ArenaAllocatoris built on top of another allocator. It grabs big chunks fromgpaand hands out pieces bump-style. Nothing from the arena is freed individually;arena.deinit()releases everything at once. std.fmt.allocPrintformats a string into newly allocated memory. Since we gave it the arena, we don’t need a separate free formsg. Now delete the linedefer gpa.free(nums);and run it again. The DebugAllocator prints a leak report with a stack trace of the line that made the allocation. That one feature makes leaks easy to track down.
Testing for leaks
In a test block, use std.testing.allocator. If anything you allocated isn’t freed by the end of the test, the test fails:
test "no leaks" {
const gpa = std.testing.allocator;
const buf = try gpa.alloc(u8, 16);
defer gpa.free(buf); // remove this line and the test fails
}
Choosing an allocator
A simple rule of thumb:
- In
main, create a DebugAllocator while developing (you can swap in a faster one for release builds). - For work with a clear end (one request, one frame, loading one file), put an arena on top and free it all at the end.
- In tests, use
std.testing.allocator. - Library functions never choose an allocator; they take a
std.mem.Allocatorparameter.
Containers take the allocator on each call
Since Zig 0.15, std.ArrayList doesn’t store its allocator. You pass it to every call that might need memory:
var list: std.ArrayList(u8) = .empty;
defer list.deinit(gpa);
try list.append(gpa, 'z');
.empty starts the list with no memory at all. append might need to grow it, so it needs the allocator; always pass the same one for the same list. If a tutorial shows std.ArrayList(u8).init(allocator), it was written for Zig 0.14 or earlier.
Recap
- No global allocator: functions that allocate take a
std.mem.Allocator. alloc/freefor slices,create/destroyfor single items; new memory isn’t zeroed.DebugAllocatorin development, arenas for shared lifetimes,std.testing.allocatorin tests.- Allocate, then
deferthe free on the next line. - Allocation can fail with
error.OutOfMemory, an ordinary error.