Zig Memory Allocators
Memory allocation is the process of reserving a region of memory at runtime for data whose size is not known at compile time. Zig gives you explicit control over every allocation through allocator objects. No hidden heap usage, no global malloc — every function that allocates memory receives an allocator as a parameter and uses it explicitly.
Stack vs Heap Memory
Stack: Heap: +--------------------+ +-----------------------------+ | Fixed at compile | | Size decided at runtime | | time | | | | Auto-freed when | | Must free manually | | function returns | | (or use defer) | | | | | | const x: i32 = 5; | | try allocator.alloc(u8, n) | | var arr:[10]u8=... | | allocator.free(arr) | | (size known ahead) | | (size known only at runtime)| +--------------------+ +-----------------------------+
The Allocator Interface
Every Zig allocator implements the std.mem.Allocator interface. Functions that need heap memory accept std.mem.Allocator as a parameter — they do not care which allocator you pass, only that it follows the interface.
fn buildMessage(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
return try std.fmt.allocPrint(allocator, "Hello, {s}!", .{name});
}
The caller decides which allocator to provide. The function itself is neutral — it works with any allocator.
General Purpose Allocator
The General Purpose Allocator (GPA) is the most useful allocator for applications. It detects memory leaks and double-frees in debug builds:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
const leaked = gpa.deinit();
if (leaked == .leak) std.debug.print("Memory leaked!\n", .{});
}
const allocator = gpa.allocator();
const buffer = try allocator.alloc(u8, 64);
defer allocator.free(buffer);
@memset(buffer, 0);
std.debug.print("Allocated {d} bytes\n", .{buffer.len});
}
gpa.deinit() reports: .ok → no leaks .leak → some memory was not freed
Allocating Memory
// Allocate a slice of n elements of type T const numbers = try allocator.alloc(u32, 10); defer allocator.free(numbers); // numbers is a []u32 with 10 elements // Allocate a single item of type T const item = try allocator.create(SomeStruct); defer allocator.destroy(item); // item is a *SomeStruct
alloc:
Heap before: [...................]
After alloc(u32, 10):
[████████████████...] ← 10 × 4 = 40 bytes reserved
↑
numbers pointer
After free:
[...................] ← memory returned to allocator
Resizing Allocations
var list = try allocator.alloc(u32, 5); // list.len = 5 list = try allocator.realloc(list, 10); // list.len = 10 — list may point to a new location in memory defer allocator.free(list);
Resizing may or may not keep the same address. The allocator returns a new slice reflecting the new size. Always use the returned value — the old slice reference may be invalid after a realloc.
Fixed Buffer Allocator
For situations where you want heap-like allocation without touching the operating system's heap — common in embedded systems and performance-critical code — use a fixed buffer allocator that draws from a stack array:
var backing_buf: [4096]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&backing_buf); const allocator = fba.allocator(); const data = try allocator.alloc(u8, 100); // data comes from backing_buf, not the OS heap // When fba goes out of scope, all memory is freed at once
backing_buf (4096 bytes on the stack): [████ 100B ████ ... ...............] ↑ data ↑ still free
The fixed buffer allocator fails (returns an error) when the backing buffer is full. It is extremely fast because it never calls into the OS — it just moves a pointer forward.
Arena Allocator
An arena allocator wraps another allocator and tracks all allocations made through it. When you call deinit(), it frees everything at once — no need to track individual allocations:
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); // frees ALL allocations at once const alloc = arena.allocator(); const a = try alloc.alloc(u8, 100); const b = try alloc.alloc(u8, 200); const c = try alloc.alloc(u8, 50); // No need to free a, b, c individually // arena.deinit() handles everything
Arena tracks:
┌───────────────────────────┐
│ alloc 1: 100 bytes │
│ alloc 2: 200 bytes │
│ alloc 3: 50 bytes │
└───────────────────────────┘
|
arena.deinit()
|
All freed in one operation
Arenas suit request-scoped data in servers, parse trees, and any situation where you create many small objects and release them all at once.
Page Allocator
const allocator = std.heap.page_allocator; const mem = try allocator.alloc(u8, 4096); defer allocator.free(mem);
The page allocator requests memory directly from the operating system in page-sized chunks (typically 4096 bytes). It is simple and correct, but slow for many small allocations. Use it as the backing allocator for arena or pool allocators, not for many small individual allocations.
Choosing an Allocator
Situation Allocator ───────────────────────────────── ────────────────────────── General application development GeneralPurposeAllocator Request/parse trees (free all at ArenaAllocator once) Embedded, no OS heap FixedBufferAllocator Backing for other allocators page_allocator Testing with leak detection GeneralPurposeAllocator Many same-size objects MemoryPool (std.heap)
Practical Example: Dynamic String Builder
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var list = std.ArrayList(u8).init(allocator);
defer list.deinit();
try list.appendSlice("Hello");
try list.appendSlice(", ");
try list.appendSlice("Zig World!");
std.debug.print("{s}\n", .{list.items});
// Output: Hello, Zig World!
}
std.ArrayList is a dynamic array that grows automatically. It uses the allocator you provide and frees its memory when you call deinit(). This is the standard pattern for building strings or collections of unknown length at runtime.
