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.

Leave a Comment

Your email address will not be published. Required fields are marked *