Skip to content

Why Allocations Cost Time

Allocations are one of the most common causes of slow programs.

Reducing Allocations

Allocations are one of the most common causes of slow programs.

An allocation asks an allocator for memory at runtime. In Zig, that usually looks like this:

const buffer = try allocator.alloc(u8, 1024);
defer allocator.free(buffer);

This code is clear. It says exactly where memory comes from and where it is released. But clarity does not make allocation free. If this code runs once, it probably does not matter. If it runs millions of times, it can dominate the program.

Why Allocations Cost Time

An allocator has to do real work.

It may need to:

  • find a free block of memory
  • record metadata
  • maintain internal data structures
  • handle alignment
  • split or merge memory blocks
  • ask the operating system for more pages
  • synchronize between threads

A single allocation can be cheap. Many allocations can become expensive.

This is especially true for small, repeated allocations:

for (items) |item| {
    const temp = try allocator.alloc(u8, 64);
    defer allocator.free(temp);

    process(item, temp);
}

This allocates and frees memory once per item. If items has one million elements, the loop performs one million allocations.

Allocation Frequency Matters

The first question is not only “how much memory do I allocate?”

It is also “how often do I allocate?”

These two programs both use memory, but they behave very differently.

Bad:

for (0..1000000) |_| {
    const buf = try allocator.alloc(u8, 1024);
    defer allocator.free(buf);

    use(buf);
}

Better:

const buf = try allocator.alloc(u8, 1024);
defer allocator.free(buf);

for (0..1000000) |_| {
    use(buf);
}

The second version allocates once and reuses the buffer.

That is usually much faster.

Reuse Buffers

Buffer reuse is one of the simplest allocation optimizations.

Instead of creating temporary memory repeatedly, create it once and pass it around.

fn processMany(allocator: std.mem.Allocator, items: []const Item) !void {
    const scratch = try allocator.alloc(u8, 4096);
    defer allocator.free(scratch);

    for (items) |item| {
        try processOne(item, scratch);
    }
}

The scratch buffer belongs to processMany.

Each call to processOne reuses the same memory.

fn processOne(item: Item, scratch: []u8) !void {
    _ = item;
    _ = scratch;

    // Use scratch memory temporarily.
}

This style makes memory ownership easy to see.

Prefer Stack Memory for Small Fixed Data

If the size is small and known at compile time, stack memory is often better.

Heap allocation:

const temp = try allocator.alloc(u8, 256);
defer allocator.free(temp);

Stack allocation:

var temp: [256]u8 = undefined;

The stack version needs no allocator.

It is simple and fast.

Then pass it as a slice:

try process(temp[0..]);

Use stack memory for small temporary buffers when the size is fixed and safe.

Do not put huge arrays on the stack. Large stack allocations can overflow the stack.

Use ArrayList Carefully

std.ArrayList is useful for dynamic arrays.

var list = std.ArrayList(u32).init(allocator);
defer list.deinit();

try list.append(10);
try list.append(20);

But append may allocate when the list needs more capacity.

If you know the approximate size, reserve memory first:

try list.ensureTotalCapacity(1000);

Then many appends can happen without repeated growth allocations.

for (0..1000) |i| {
    try list.append(@intCast(i));
}

This reduces allocator pressure.

Capacity vs Length

Dynamic containers usually have two important values:

TermMeaning
LengthNumber of items currently stored
CapacityNumber of items that can fit before another allocation

A list with length 3 and capacity 8 contains 3 items, but has room for 5 more.

Appending within capacity is cheap.

Appending beyond capacity may allocate.

This is why reserving capacity matters.

Use clearRetainingCapacity

Sometimes you want to empty a list but keep its memory.

list.clearRetainingCapacity();

This sets the length to zero but keeps the allocated buffer.

That is useful inside loops:

var list = std.ArrayList(u8).init(allocator);
defer list.deinit();

for (inputs) |input| {
    list.clearRetainingCapacity();

    try buildOutput(input, &list);
    try writeOutput(list.items);
}

This avoids allocating a fresh list for every input.

Avoid Building Strings Repeatedly

String building often causes hidden allocation pressure.

Bad pattern:

for (items) |item| {
    var text = std.ArrayList(u8).init(allocator);
    defer text.deinit();

    try text.writer().print("item: {}\n", .{item.id});
    try output(text.items);
}

This creates a new dynamic buffer per item.

Better:

var text = std.ArrayList(u8).init(allocator);
defer text.deinit();

for (items) |item| {
    text.clearRetainingCapacity();

    try text.writer().print("item: {}\n", .{item.id});
    try output(text.items);
}

The buffer is reused.

Use Arena Allocators for Batch Lifetimes

An arena allocator is useful when many allocations share the same lifetime.

Example:

var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();

const arena_allocator = arena.allocator();

You allocate many objects from the arena:

const a = try arena_allocator.alloc(u8, 100);
const b = try arena_allocator.alloc(u32, 50);

Then release everything at once when the arena is destroyed.

This is useful for:

  • parsing one file
  • handling one request
  • compiling one module
  • loading one level in a game
  • building temporary data for one operation

Arena allocation avoids many individual frees.

The tradeoff is that individual objects are not freed separately.

Reset an Arena

For repeated batch work, you can reset an arena between batches.

var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();

for (requests) |request| {
    _ = arena.reset(.retain_capacity);

    const aa = arena.allocator();

    try handleRequest(aa, request);
}

This keeps memory available for reuse while clearing the previous batch.

That can be much faster than allocating and freeing every object independently.

Use Fixed Buffers

A fixed buffer allocator uses memory you provide.

var memory: [4096]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&memory);

const allocator = fba.allocator();

Now allocations come from memory.

const buf = try allocator.alloc(u8, 128);

This is useful when you want:

  • no heap usage
  • predictable memory limits
  • temporary allocation inside a known buffer
  • embedded-friendly behavior

When the fixed buffer is full, allocation fails.

That failure is explicit.

Avoid Per-Element Heap Allocation

This is a common mistake.

Bad:

const User = struct {
    name: []u8,
};

for (users) |*user| {
    user.name = try allocator.dupe(u8, "anonymous");
}

This allocates separately for every name.

Sometimes that is necessary. Often, it is better to store data in one larger buffer or arena.

Better for batch data:

var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();

const aa = arena.allocator();

for (users) |*user| {
    user.name = try aa.dupe(u8, "anonymous");
}

Now all names are released together.

Store Inline Data When Reasonable

Sometimes a pointer causes an allocation that could be avoided.

Allocation-based design:

const SmallName = struct {
    bytes: []u8,
};

Inline design:

const SmallName = struct {
    len: u8,
    bytes: [32]u8,
};

The inline version can store short names without heap allocation.

This is useful when:

  • most values are small
  • maximum size is known
  • fixed memory cost is acceptable

The tradeoff is wasted space for short values.

Use Slices Instead of Copies

A slice is a view into existing memory.

Copying:

const copy = try allocator.dupe(u8, input);

Borrowing:

const view = input[start..end];

The slice does not allocate.

It points into the original memory.

This is excellent for parsers and text processing. Instead of copying every token, store slices into the original source buffer.

const Token = struct {
    text: []const u8,
};

This keeps tokenization cheap.

Beware Slice Lifetimes

Slices avoid allocation, but they depend on the original memory staying alive.

Bad:

fn makeName() []const u8 {
    var buffer = [_]u8{ 'z', 'i', 'g' };
    return buffer[0..];
}

The slice points to stack memory that disappears when the function returns.

That is invalid.

Allocation reduction must not break lifetimes.

Measure Allocations

You can often find allocation problems by counting them.

Questions to ask:

  • How many allocations happen per request?
  • How many allocations happen per file?
  • How many allocations happen per frame?
  • Which function allocates most often?
  • Are temporary allocations reused?
  • Are containers reserving capacity?

Zig helps because allocator use is explicit in function signatures.

If a function takes an allocator, it may allocate.

That is visible at the call site.

Allocation-Free APIs

Some APIs should avoid allocation entirely.

Instead of returning a newly allocated result:

fn formatUser(allocator: std.mem.Allocator, user: User) ![]u8 {
    return try std.fmt.allocPrint(allocator, "user: {}", .{user.id});
}

you can write into a caller-provided buffer:

fn formatUser(buffer: []u8, user: User) ![]u8 {
    return try std.fmt.bufPrint(buffer, "user: {}", .{user.id});
}

The caller controls memory.

var buffer: [128]u8 = undefined;
const text = try formatUser(buffer[0..], user);

This avoids heap allocation.

It also makes failure explicit if the buffer is too small.

API Design Matters

A performance-friendly Zig API often lets the caller choose memory strategy.

Good pattern:

fn parse(allocator: std.mem.Allocator, input: []const u8) !Document {
    // caller decides allocator
}

Even better when possible:

fn parseInto(output: *Document, scratch: []u8, input: []const u8) !void {
    // caller provides storage
}

The right design depends on the use case.

Simple APIs are easier to use.

Explicit storage APIs are faster and more predictable.

Mental Model

Reducing allocations means moving memory decisions outward.

Instead of allocating deep inside small functions, prefer:

  • caller-provided buffers
  • reused containers
  • reserved capacity
  • stack memory for small fixed buffers
  • arenas for batch lifetimes
  • slices instead of copies
  • fixed buffers for bounded memory

Allocations are not bad. Unnecessary repeated allocations are bad.

In Zig, memory is visible. Use that visibility to make allocation frequency low, predictable, and easy to control.