An Allocator in Zig is an interface that decides how to request, resize, and free dynamic memory. If you come from C, C++, or Rust, you’re probably used to having a default global allocator available.
In Zig there is no default allocator by convention. If you link libc you can use std.heap.c_allocator, but reusable APIs typically receive an Allocator chosen by the caller.
Zig starts from a premise: memory allocation is a dependency that should be made visible.
If a function needs to allocate dynamic memory, it must ask for permission. And the way to ask for permission is by receiving a parameter of type Allocator.
The problem with the global allocator
In almost all languages, there is an invisible “Global Heap”.
- In C:
mallocuses the default allocator from libc. - In Python/JS: The Garbage Collector requests memory from the system when it wants.
- In Rust:
Box::newuses the Global Allocator (although Rust is gradually allowing changes).
This has serious problems for systems programming:
- Hidden cost: the signature does not indicate whether an operation can allocate, reallocate, or fail due to lack of memory. A
mallocdoes not necessarily make a system call every time, but it is not free either. - Lack of control: What if you want a library to use a specific memory region (e.g., fast SRAM on a chip)? If the library uses
mallocinternally, you cannot change it. - Less clear ownership: if everything uses the same allocator, it’s harder to determine who should free each result.
The explicit allocator
In Zig, an Allocator is an interface that allows:
- Giving you a piece of memory (
alloc). - Resizing that piece (
resize). - Freeing that piece (
free).
If you write a function that needs memory, your signature changes like this:
// In C (hidden malloc)
// char* duplicate(const char* input);
// In Zig (explicit Allocator)
fn duplicate(allocator: Allocator, input: []const u8) ![]u8 {
// Use the allocator that was PASSED to us to request memory
const memory = try allocator.alloc(u8, input.len);
@memcpy(memory, input);
return memory;
}What this design provides
At first it seems verbose (you have to pass the parameter everywhere), but the advantages are immense:
- You see which APIs can allocate memory: an
allocatorparameter warns of that possibility and the!reflects that allocation can fail. - You choose the strategy: you can provide a general-purpose allocator, one based on a fixed buffer, or one with a very short lifecycle. The function only uses the interface it receives.
- Facilitates portability: the same algorithm can work with different memory sources, as long as the rest of its dependencies are also compatible with the target.
This is a design convention, not a compiler restriction. A function without an allocator parameter could still use page_allocator, c_allocator or another global source internally; its documentation should indicate this.
Basic operations: create, alloc, destroy, free
The std.mem.Allocator interface has standard methods that we will use constantly. It is important to distinguish between creating an object and creating an array.
A single object (create and destroy)
We use create when we want a pointer to a single instance (*T). Equivalent to new in C++.
const allocator = ...; // Assume we already have one
// Allocate memory for an i32
const ptr = try allocator.create(i32);
ptr.* = 42;
// Free memory
allocator.destroy(ptr);Multiple elements (alloc and free)
We use alloc when we want a Slice ([]T). Equivalent to malloc(n * sizeof(T)) in C.
// Allocate space for 100 bytes
const buffer = try allocator.alloc(u8, 100);
defer allocator.free(buffer); // Important: use free for slices
// Use the buffer
buffer[0] = 0xFF;Practical tip:
- If you used
create, free withdestroy. - If you used
alloc, free withfree.
Complete example
Let’s see what a real program that concatenates two strings looks like. Notice how the allocator travels from main to the logic.
const std = @import("std");
const Allocator = std.mem.Allocator;
// This function does NOT know where the memory comes from.
// It only knows it needs an Allocator to work.
fn joinText(allocator: Allocator, a: []const u8, b: []const u8) ![]u8 {
const total_len = a.len + b.len;
// 1. Request memory
const result = try allocator.alloc(u8, total_len);
// Note: We do NOT put defer free here, because we want to return the result
// If we fail to copy, we would use errdefer.
// 2. Copy data
@memcpy(result[0..a.len], a);
@memcpy(result[a.len..], b);
return result;
}
pub fn main() !void {
// 1. Choose our memory strategy (we'll see this in the next article)
var debug_allocator = std.heap.DebugAllocator(.{}){};
defer _ = debug_allocator.deinit();
const allocator = debug_allocator.allocator();
// 2. Call the function injecting the dependency
const greeting = try joinText(allocator, "Hello ", "Zig");
// 3. We own the returned memory, we must free it
defer allocator.free(greeting);
std.debug.print("{s}\n", .{greeting});
}