Arena Allocators & Memory Management Patterns

Systems Programming intermediate Building with AI ~22 min read

Overview

Arena allocators (also called region-based allocators or bump allocators) are a memory management strategy where allocations are made sequentially from a contiguous block of memory, and all allocations are freed at once by resetting the arena. Individual free() calls are not supported—instead, you free everything when the arena’s lifetime ends. This trades the flexibility of per-object deallocation for dramatically simpler and faster allocation.

In systems programming, arena allocators are a key tool for managing memory in request-scoped or phase-scoped workloads. A database query allocates many temporary structures (parse tree, query plan, intermediate results) during execution, then discards all of them when the query completes. An arena allocator makes this pattern nearly free—allocation is a pointer bump (increment a counter), and deallocation is a single pointer reset.

In a Zig-based storage engine, arena allocators are used extensively for request-scoped memory: processing a write batch, executing a query, or building an index segment. Zig’s allocator-first design makes arenas a natural fit—functions accept an Allocator parameter, so swapping between a general-purpose allocator and an arena requires no code changes. This composability is what makes Zig’s memory management model so effective.

Core Concepts

Bump Allocation

The simplest arena allocation strategy: maintain a pointer to the next free byte in a pre-allocated buffer. To allocate N bytes, return the current pointer and advance it by N (with alignment padding). Allocation is O(1)—a single pointer addition. There is no free list, no coalescing, no fragmentation. The trade-off is that individual allocations cannot be freed; only the entire arena can be reset.

Lifetime-Scoped Memory

Arena allocators work when all allocations share a common lifetime—they are all created and destroyed together. Database queries, HTTP request handlers, compiler passes, and game frames are classic examples. If some allocations must outlive the arena, they must be explicitly copied to longer-lived storage before the arena resets.

Allocator Composability

In Zig, allocators implement the std.mem.Allocator interface. An arena allocator wraps a backing allocator (which provides the raw memory pages). You can stack allocators: a logging allocator wraps an arena which wraps the page allocator. Each layer adds behavior without changing the allocation interface. This is the Decorator pattern applied to memory management.

Pool Allocators

A pool allocator pre-allocates a fixed number of same-sized blocks. Allocation pops a block from a free list; deallocation pushes it back. Pools are ideal for objects with uniform size and high churn—connection objects, tree nodes, packet buffers. Unlike arenas, pools support individual deallocation, but only for fixed-size objects.

How It Works

Basic Arena Implementation

A minimal arena allocator is surprisingly simple. It maintains a buffer, a current offset, and grows by requesting new pages from a backing allocator when the buffer is exhausted:

const std = @import("std");

const Arena = struct {
    buffer: []u8,
    offset: usize,
    backing: std.mem.Allocator,

    pub fn init(backing: std.mem.Allocator, size: usize) !Arena {
        const buffer = try backing.alloc(u8, size);
        return Arena{
            .buffer = buffer,
            .offset = 0,
            .backing = backing,
        };
    }

    pub fn alloc(self: *Arena, comptime T: type, n: usize) ![]T {
        const byte_count = n * @sizeOf(T);
        const alignment = @alignOf(T);

        // Align the current offset
        const aligned_offset = std.mem.alignForward(usize, self.offset, alignment);
        if (aligned_offset + byte_count > self.buffer.len) {
            return error.OutOfMemory;
        }

        const result = @as([*]T, @ptrCast(@alignCast(self.buffer.ptr + aligned_offset)));
        self.offset = aligned_offset + byte_count;
        return result[0..n];
    }

    pub fn reset(self: *Arena) void {
        self.offset = 0;  // All allocations freed in O(1)
    }

    pub fn deinit(self: *Arena) void {
        self.backing.free(self.buffer);
    }
};

Zig’s Standard Library ArenaAllocator

Zig’s standard library provides std.heap.ArenaAllocator, which handles growing automatically by chaining multiple memory pages. It implements the std.mem.Allocator interface, so it works with any code that accepts an allocator:

const std = @import("std");

pub fn processQuery(backing_allocator: std.mem.Allocator, query: []const u8) ![]u8 {
    // Create an arena for all temporary allocations during query processing
    var arena = std.heap.ArenaAllocator.init(backing_allocator);
    defer arena.deinit();  // Frees ALL arena memory at once
    const alloc = arena.allocator();

    // All these allocations use the arena — no individual frees needed
    const tokens = try tokenize(alloc, query);
    const ast = try parse(alloc, tokens);
    const plan = try optimize(alloc, ast);
    const results = try execute(alloc, plan);

    // Copy the final result to the caller's allocator before arena is freed
    return try backing_allocator.dupe(u8, results);
}

Fixed-Buffer Allocator for Stack-Based Arenas

When you know the maximum allocation size at compile time, use a stack-allocated buffer with std.heap.FixedBufferAllocator—zero heap allocations at all:

pub fn formatKey(prefix: []const u8, id: u64) ![128]u8 {
    var buf: [4096]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&buf);
    const alloc = fba.allocator();

    // All allocations come from the stack buffer
    var list = std.ArrayList(u8).init(alloc);
    try list.appendSlice(prefix);
    try list.append(':');

    var num_buf: [20]u8 = undefined;
    const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{id}) catch unreachable;
    try list.appendSlice(num_str);

    var result: [128]u8 = undefined;
    @memcpy(result[0..list.items.len], list.items);
    return result;
    // fba goes out of scope — stack memory is automatically reclaimed
}

Pool Allocator Pattern

For fixed-size objects with individual allocation/deallocation semantics, a pool allocator maintains a free list. This is common for connection pools, tree nodes, and packet buffers:

fn Pool(comptime T: type) type {
    return struct {
        const Self = @This();
        const Node = struct {
            data: T,
            next: ?*Node,
        };

        free_list: ?*Node,
        backing: std.mem.Allocator,

        pub fn init(backing: std.mem.Allocator) Self {
            return .{ .free_list = null, .backing = backing };
        }

        pub fn acquire(self: *Self) !*T {
            if (self.free_list) |node| {
                self.free_list = node.next;
                return &node.data;
            }
            // Free list empty — allocate a new node
            const node = try self.backing.create(Node);
            return &node.data;
        }

        pub fn release(self: *Self, ptr: *T) void {
            const node = @fieldParentPtr(Node, "data", ptr);
            node.next = self.free_list;
            self.free_list = node;
        }
    };
}

// Usage
var conn_pool = Pool(Connection).init(allocator);
const conn = try conn_pool.acquire();
defer conn_pool.release(conn);
try conn.execute(query);

Practical Applications

Write Batch Processing in a Storage Engine

When the database processes a write batch (multiple key-value operations grouped as one atomic unit), it allocates temporary structures for encoding keys, building index entries, and constructing the WAL entry. An arena allocator scopes all these temporaries to the batch’s lifetime. When the batch commits, the arena resets—thousands of small allocations freed with a single pointer reset, zero fragmentation, zero overhead.

Query Execution Arenas

Each query execution gets its own arena. The parser allocates AST nodes, the planner allocates plan operators, and the executor allocates intermediate result buffers— all from the same arena. When the query completes, the final result is copied to the caller’s allocator, and the entire arena is discarded. This pattern prevents query memory from leaking across requests and makes memory accounting per-query straightforward.

Compaction Arenas

During LSM-tree compaction, the database reads multiple sorted runs, merges them, and writes a new sorted run. The merge process allocates buffers for reading, writing, and comparison. A compaction-scoped arena holds all these buffers. When compaction finishes, the arena frees everything regardless of how many intermediate structures were created.

Implementation Patterns

Scratch Allocator Pattern

For very short-lived allocations within a function, use a “scratch” arena that resets at the end of each iteration. This avoids growing the arena indefinitely in a loop:

pub fn processMessages(allocator: std.mem.Allocator, messages: []const Message) !void {
    var scratch = std.heap.ArenaAllocator.init(allocator);
    defer scratch.deinit();

    for (messages) |msg| {
        // Reset scratch arena each iteration — previous iteration's memory is freed
        _ = scratch.reset(.retain_capacity);
        const scratch_alloc = scratch.allocator();

        const decoded = try decode(scratch_alloc, msg.payload);
        const transformed = try transform(scratch_alloc, decoded);
        try persist(transformed);  // writes to durable storage, doesn't need scratch memory
    }
}

Two-Phase Allocation

Build results in an arena, then copy only the parts you need to a long-lived allocator. This separates the “working memory” from the “result memory”:

pub fn buildIndex(result_alloc: std.mem.Allocator, entries: []const Entry) !*Index {
    // Phase 1: Build in temporary arena
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const tmp = arena.allocator();

    var builder = try IndexBuilder.init(tmp);
    for (entries) |entry| {
        try builder.add(entry);
    }

    // Phase 2: Freeze into compact, long-lived representation
    return try builder.freeze(result_alloc);
}

Allocator Choice Matrix

Choosing the right allocator depends on the allocation pattern:

Common Pitfalls

Further Reading