Zig Allocator Patterns

Choosing the right allocator for each part of your program is a key skill in Zig. Different allocators have different performance profiles, lifetime rules, and debugging capabilities. This topic goes deeper than the introduction, covering pool allocators, custom allocators, allocator composition, and the patterns experienced Zig developers use in production code.

The Allocator Hierarchy

  Page Allocator (OS)
       │
       ├── GeneralPurposeAllocator   ← debug + leak detection
       │        │
       │        └── ArenaAllocator  ← free all at once
       │
       ├── FixedBufferAllocator      ← stack memory, no OS calls
       │
       └── MemoryPool(T)             ← fixed-size object reuse

Arena Allocator — Bulk Free Pattern

An arena allocates freely but only frees everything at once. Perfect for request handling, parsers, and any workflow with a clear start and end:

const std = @import("std");

fn handleRequest(parent_alloc: std.mem.Allocator, request: []const u8) !void {
    // Create an arena that lasts for this request only
    var arena = std.heap.ArenaAllocator.init(parent_alloc);
    defer arena.deinit();  // frees ALL request allocations at once

    const alloc = arena.allocator();

    // Allocate freely — no individual frees needed
    const parsed  = try parseRequest(alloc, request);
    const headers = try buildHeaders(alloc, parsed);
    const body    = try generateBody(alloc, parsed);
    _ = headers;
    _ = body;

    // When this function returns, arena.deinit() frees everything
}

fn parseRequest(alloc: std.mem.Allocator, raw: []const u8) ![]const u8 {
    return try alloc.dupe(u8, raw);
}
fn buildHeaders(alloc: std.mem.Allocator, _: []const u8) ![]u8 {
    return try alloc.alloc(u8, 256);
}
fn generateBody(alloc: std.mem.Allocator, _: []const u8) ![]u8 {
    return try alloc.alloc(u8, 1024);
}
  Request arrives
      │
  arena.init()         ← one allocator created
      │
  parse → alloc 100B  ─┐
  headers → alloc 256B ─┤  all tracked by arena
  body → alloc 1024B  ─┘
      │
  Send response
      │
  arena.deinit()       ← 100+256+1024 bytes freed in one call

MemoryPool — Fixed-Size Object Reuse

A memory pool pre-allocates slots for objects of one specific type. Allocation and deallocation become nearly instant — no searching for free space, just mark a slot used or free:

const std = @import("std");

const Particle = struct {
    x: f32,
    y: f32,
    vx: f32,
    vy: f32,
    life: f32,
};

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    var pool = std.heap.MemoryPool(Particle).init(gpa.allocator());
    defer pool.deinit();

    // Allocate particles from the pool
    const p1 = try pool.create();
    const p2 = try pool.create();
    const p3 = try pool.create();

    p1.* = .{ .x = 0, .y = 0, .vx = 1, .vy = 0.5, .life = 1.0 };
    p2.* = .{ .x = 5, .y = 2, .vx = -1, .vy = 1,  .life = 0.8 };
    p3.* = .{ .x = 3, .y = 7, .vx = 0,  .vy = -1, .life = 0.5 };

    // Return a dead particle to the pool for reuse
    pool.destroy(p2);

    const p4 = try pool.create();  // reuses p2's slot
    p4.* = .{ .x = 1, .y = 1, .vx = 0.5, .vy = 0.5, .life = 1.0 };

    std.debug.print("p1: ({d},{d})\n", .{p1.x, p1.y});
    std.debug.print("p4: ({d},{d})\n", .{p4.x, p4.y});

    pool.destroy(p1);
    pool.destroy(p3);
    pool.destroy(p4);
}
  Pool slots:
  [ slot 0 = p1 ][ slot 1 = p2 ][ slot 2 = p3 ]
  destroy(p2):
  [ slot 0 = p1 ][ slot 1 = FREE ][ slot 2 = p3 ]
  create() → reuses slot 1:
  [ slot 0 = p1 ][ slot 1 = p4 ][ slot 2 = p3 ]

Stacked Allocators — Composition

Allocators compose — wrap one inside another for layered behavior:

pub fn main() !void {
    // Layer 1: page allocator (talks to OS)
    const page_alloc = std.heap.page_allocator;

    // Layer 2: GPA wraps page_alloc for leak detection
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    // Layer 3: arena wraps GPA for bulk-free
    var arena = std.heap.ArenaAllocator.init(gpa.allocator());
    defer arena.deinit();

    // Use the arena for this scope's allocations
    const alloc = arena.allocator();
    const data = try alloc.alloc(u8, 512);
    _ = data;

    // arena.deinit() → GPA.free() → page_alloc.free() → OS
    _ = page_alloc;
}
  Allocation chain:
  arena.alloc(512)
       │
  gpa.alloc(512)        ← tracks for leak detection
       │
  page_alloc.alloc(512) ← requests from OS
       │
  OS grants memory page

Logging Allocator — Debugging Allocations

var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();

// Wrap with logging to print every alloc/free
var log_alloc = std.heap.loggingAllocator(gpa.allocator());
const alloc = log_alloc.allocator();

const buf = try alloc.alloc(u8, 100);  // prints: alloc 100 bytes
defer alloc.free(buf);                  // prints: free 100 bytes

Failing Allocator — Testing Out-of-Memory Paths

// Test what your code does when allocation fails
var fail_alloc = std.testing.FailingAllocator.init(
    std.testing.allocator,
    .{ .fail_index = 2 },  // fail on the 3rd allocation (index 2)
);
const alloc = fail_alloc.allocator();

const a = try alloc.alloc(u8, 10);  // succeeds (index 0)
const b = try alloc.alloc(u8, 20);  // succeeds (index 1)
_ = a; _ = b;
const c = alloc.alloc(u8, 30) catch |err| blk: {
    std.debug.print("Got expected error: {}\n", .{err});
    break :blk null;
};
_ = c;

Allocator Selection Guide

  Situation                              Best Allocator
  ──────────────────────────────────────────────────────────
  General development / debugging       GeneralPurposeAllocator
  Many small request-scoped allocs      ArenaAllocator
  Many objects of one type              MemoryPool(T)
  Embedded / no OS heap                 FixedBufferAllocator
  Backing store for other allocators    page_allocator
  Debugging allocation behavior         loggingAllocator
  Testing OOM error paths               FailingAllocator
  CLI tools, short-lived programs       page_allocator direct

The Allocator Interface — Writing Your Own

// Every allocator implements this interface:
pub const Allocator = struct {
    ptr:    *anyopaque,
    vtable: *const VTable,

    pub const VTable = struct {
        alloc:   *const fn(ctx, n, align, ret_addr) ?[*]u8,
        resize:  *const fn(ctx, buf, new_n, ret_addr) bool,
        free:    *const fn(ctx, buf, ret_addr) void,
    };
};

Any struct that implements these three operations — alloc, resize, free — becomes a valid Zig allocator. You can write an allocator that logs to a file, rounds sizes to power-of-two, tracks peak usage, or enforces a budget. The interface is small and the barrier to implementation is low.

Leave a Comment

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