A memory allocator is code that gives memory to the rest of a program.
When a program needs space for data, it asks an allocator:
const buffer = try allocator.alloc(u8, 1024);That means: give me 1024 bytes.
Later, when the program is done with that memory, it gives it back:
allocator.free(buffer);Zig makes allocators explicit. A function that needs heap memory usually receives an allocator from the caller. That makes memory behavior visible.
In this project, we will build a small fixed buffer allocator. It will allocate memory from a byte array that we control.
The Goal
We will create a fixed-size memory area:
var memory: [1024]u8 = undefined;Then we will build an allocator that hands out pieces of that array.
Example:
var allocator = SimpleAllocator.init(&memory);
const a = try allocator.alloc(100);
const b = try allocator.alloc(200);
allocator.free(b);
allocator.free(a);This allocator will be simple. It will allocate forward through the buffer. It will support freeing only in reverse order. That makes it a stack allocator.
This is not a general-purpose allocator. It is a teaching allocator.
The Basic Idea
Imagine the buffer as a row of bytes:
[........................................]At the beginning, nothing is used.
The allocator keeps an index called offset:
offset = 0When we allocate 8 bytes, the allocator returns the first 8 bytes and moves the offset forward:
[xxxxxxxx................................]
^
offset = 8When we allocate 12 more bytes:
[xxxxxxxxxxxxxxxxxxxx....................]
^
offset = 20The allocator does not call the operating system. It only slices a buffer that already exists.
Define the Allocator
Start with this struct:
const SimpleAllocator = struct {
buffer: []u8,
offset: usize,
fn init(buffer: []u8) SimpleAllocator {
return .{
.buffer = buffer,
.offset = 0,
};
}
};The allocator stores two fields.
buffer is the memory area.
offset is the next free byte.
Allocate Bytes
Now add an alloc method:
fn alloc(self: *SimpleAllocator, len: usize) ![]u8 {
if (self.offset + len > self.buffer.len) {
return error.OutOfMemory;
}
const start = self.offset;
const end = start + len;
self.offset = end;
return self.buffer[start..end];
}This function checks whether enough memory remains.
If yes, it returns a slice.
If no, it returns error.OutOfMemory.
Try It
Put this in src/main.zig:
const std = @import("std");
const SimpleAllocator = struct {
buffer: []u8,
offset: usize,
fn init(buffer: []u8) SimpleAllocator {
return .{
.buffer = buffer,
.offset = 0,
};
}
fn alloc(self: *SimpleAllocator, len: usize) ![]u8 {
if (self.offset + len > self.buffer.len) {
return error.OutOfMemory;
}
const start = self.offset;
const end = start + len;
self.offset = end;
return self.buffer[start..end];
}
};
pub fn main() !void {
var memory: [64]u8 = undefined;
var allocator = SimpleAllocator.init(&memory);
const a = try allocator.alloc(10);
const b = try allocator.alloc(20);
@memset(a, 1);
@memset(b, 2);
std.debug.print("a.len = {d}\n", .{a.len});
std.debug.print("b.len = {d}\n", .{b.len});
std.debug.print("used = {d}\n", .{allocator.offset});
}Run:
zig build runOutput:
a.len = 10
b.len = 20
used = 30The allocator gave out 30 bytes from a 64-byte buffer.
Why This Allocator Cannot Reuse Memory Yet
Right now, there is no free.
If you allocate 10 bytes and then 20 bytes, the offset moves to 30.
If you no longer need the first 10 bytes, the allocator does not know how to reuse them.
[aaaaaaaaaabbbbbbbbbbbbbbbbbbbb..........]
^
offset = 30A general allocator tracks free regions. That requires more bookkeeping.
For a first allocator, we will use a simpler rule: free must happen in reverse order.
Add Stack-Style Free
A stack allocator works like a stack of plates.
The last allocation must be freed first.
This is allowed:
const a = try allocator.alloc(10);
const b = try allocator.alloc(20);
allocator.free(b);
allocator.free(a);This is not allowed:
const a = try allocator.alloc(10);
const b = try allocator.alloc(20);
allocator.free(a); // wrong
allocator.free(b);To support this, free checks whether the slice being freed is at the end of the used area.
fn free(self: *SimpleAllocator, memory: []u8) void {
const buffer_start = @intFromPtr(self.buffer.ptr);
const memory_start = @intFromPtr(memory.ptr);
const start = memory_start - buffer_start;
const end = start + memory.len;
if (end == self.offset) {
self.offset = start;
}
}If the freed block is the most recent allocation, we move offset backward.
If it is not, we do nothing.
Complete Stack Allocator
const std = @import("std");
const SimpleAllocator = struct {
buffer: []u8,
offset: usize,
fn init(buffer: []u8) SimpleAllocator {
return .{
.buffer = buffer,
.offset = 0,
};
}
fn alloc(self: *SimpleAllocator, len: usize) ![]u8 {
if (self.offset + len > self.buffer.len) {
return error.OutOfMemory;
}
const start = self.offset;
const end = start + len;
self.offset = end;
return self.buffer[start..end];
}
fn free(self: *SimpleAllocator, memory: []u8) void {
const buffer_start = @intFromPtr(self.buffer.ptr);
const memory_start = @intFromPtr(memory.ptr);
const start = memory_start - buffer_start;
const end = start + memory.len;
if (end == self.offset) {
self.offset = start;
}
}
};
pub fn main() !void {
var memory: [64]u8 = undefined;
var allocator = SimpleAllocator.init(&memory);
const a = try allocator.alloc(10);
const b = try allocator.alloc(20);
std.debug.print("after alloc: used = {d}\n", .{allocator.offset});
allocator.free(b);
std.debug.print("after free b: used = {d}\n", .{allocator.offset});
allocator.free(a);
std.debug.print("after free a: used = {d}\n", .{allocator.offset});
}Output:
after alloc: used = 30
after free b: used = 10
after free a: used = 0Now memory can be reused, but only in stack order.
Alignment
Real allocators must care about alignment.
Some values must start at addresses divisible by 2, 4, 8, or more.
For example, an i64 usually needs 8-byte alignment.
This address is aligned to 8:
0x1000This one is not:
0x1003If an allocator returns badly aligned memory, the program may be slower, incorrect, or crash on some targets.
Add an aligned allocation helper:
fn alignForward(value: usize, alignment: usize) usize {
return std.mem.alignForward(usize, value, alignment);
}Then update alloc:
fn allocAligned(self: *SimpleAllocator, len: usize, alignment: usize) ![]u8 {
const start = alignForward(self.offset, alignment);
const end = start + len;
if (end > self.buffer.len) {
return error.OutOfMemory;
}
self.offset = end;
return self.buffer[start..end];
}Now callers can request memory with a required alignment.
Testing Out of Memory
Add this test:
test "allocator returns out of memory" {
var memory: [8]u8 = undefined;
var allocator = SimpleAllocator.init(&memory);
_ = try allocator.alloc(8);
try std.testing.expectError(error.OutOfMemory, allocator.alloc(1));
}The buffer has 8 bytes. After allocating all 8, one more byte should fail.
Testing Stack Free
Add this test:
test "stack free moves offset backward" {
var memory: [64]u8 = undefined;
var allocator = SimpleAllocator.init(&memory);
const a = try allocator.alloc(10);
const b = try allocator.alloc(20);
try std.testing.expectEqual(@as(usize, 30), allocator.offset);
allocator.free(b);
try std.testing.expectEqual(@as(usize, 10), allocator.offset);
allocator.free(a);
try std.testing.expectEqual(@as(usize, 0), allocator.offset);
}Run:
zig build testWhy Zig Uses Allocator Interfaces
Our allocator has custom methods:
allocator.alloc(10)
allocator.free(buffer)Zig’s standard library uses the common type:
std.mem.AllocatorThat allows many data structures to accept any allocator.
For example:
var list = std.ArrayList(u8).init(allocator);The list does not care whether the allocator is a general-purpose allocator, arena allocator, fixed buffer allocator, or custom allocator.
That is the design lesson: pass allocation policy into the code instead of hardcoding it.
What This Allocator Is Good For
A stack allocator is useful when allocations naturally happen in phases.
Example:
start request
allocate temporary buffers
parse data
build response
end request
free all temporary memoryIt is also useful in games, compilers, parsers, and batch processing.
The limitation is strict lifetime order. If memory must be freed in arbitrary order, use a different allocator design.
What You Learned
You built a small allocator from a byte buffer.
You tracked used memory with an offset.
You returned slices into the buffer.
You handled out-of-memory errors.
You added stack-style freeing.
You saw why alignment matters.
You connected the project to Zig’s explicit allocator style.
Allocators look mysterious at first, but the first idea is simple: an allocator owns a region of memory and decides which parts are currently available.