Skip to content

Building Allocation-Friendly APIs

An allocation-friendly API makes memory behavior clear to the caller.

An allocation-friendly API makes memory behavior clear to the caller.

In Zig, this usually means the function does not secretly allocate. If a function needs heap memory, it receives an allocator. If it returns owned memory, the caller knows they must free it.

This style makes code easier to test, easier to reuse, and easier to optimize.

Start with the Caller

When designing an API, begin with this question:

Who should control memory?

In Zig, the answer is often:

The caller should control memory.

That is why many functions look like this:

fn makeMessage(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
    return try std.fmt.allocPrint(allocator, "hello, {s}", .{name});
}

The function needs heap memory, so it receives an allocator.

The returned []u8 is owned by the caller:

const message = try makeMessage(allocator, "Ada");
defer allocator.free(message);

This makes the memory contract visible.

Do Not Hide Allocation

Avoid this style:

fn makeMessage(name: []const u8) ![]u8 {
    return try std.fmt.allocPrint(std.heap.page_allocator, "hello, {s}", .{name});
}

This function secretly chooses std.heap.page_allocator.

That makes the function less flexible.

The caller cannot use a testing allocator. The caller cannot use an arena. The caller cannot apply a memory limit. The caller cannot easily track allocations.

Better:

fn makeMessage(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
    return try std.fmt.allocPrint(allocator, "hello, {s}", .{name});
}

Now the caller chooses the memory policy.

Use Borrowing When You Can

Not every function needs to allocate.

This function only reads the input:

fn printName(name: []const u8) void {
    std.debug.print("{s}\n", .{name});
}

It borrows name.

It should not receive an allocator because it does not need one.

A clean API should not ask for more power than it uses.

Bad:

fn printName(allocator: std.mem.Allocator, name: []const u8) void {
    _ = allocator;
    std.debug.print("{s}\n", .{name});
}

The allocator parameter creates noise. It suggests allocation may happen, but it does not.

Only accept an allocator when the function may allocate.

Prefer Caller-Provided Buffers for Small Work

Sometimes a function can write into memory provided by the caller.

This avoids allocation entirely.

Example:

const std = @import("std");

fn writeGreeting(buffer: []u8, name: []const u8) ![]u8 {
    return try std.fmt.bufPrint(buffer, "hello, {s}", .{name});
}

pub fn main() !void {
    var buffer: [128]u8 = undefined;

    const message = try writeGreeting(&buffer, "Ada");

    std.debug.print("{s}\n", .{message});
}

The function does not allocate. It writes into buffer.

The return value is a slice pointing into the caller’s buffer.

That means the caller owns the memory and controls its lifetime.

This style is useful for formatting, parsing small values, and performance-sensitive code.

Make Ownership Clear in Names

Names can help signal ownership.

Common Zig naming patterns include:

init creates a value that may own resources
deinit releases resources
toOwnedSlice transfers ownership to the caller
dupe creates an owned copy
clone often means creating an owned copy

For example:

const User = struct {
    name: []u8,

    pub fn init(allocator: std.mem.Allocator, name: []const u8) !User {
        const owned_name = try allocator.dupe(u8, name);
        return User{ .name = owned_name };
    }

    pub fn deinit(self: User, allocator: std.mem.Allocator) void {
        allocator.free(self.name);
    }
};

The API tells a story:

User.init allocates.
User.deinit frees.
User owns name.

That is allocation-friendly design.

Pair init with deinit

When a type owns memory, give it a cleanup method.

const Buffer = struct {
    data: []u8,

    pub fn init(allocator: std.mem.Allocator, size: usize) !Buffer {
        const data = try allocator.alloc(u8, size);
        return Buffer{ .data = data };
    }

    pub fn deinit(self: Buffer, allocator: std.mem.Allocator) void {
        allocator.free(self.data);
    }
};

Usage:

const buffer = try Buffer.init(allocator, 1024);
defer buffer.deinit(allocator);

This is a standard Zig lifecycle.

The caller can see both sides:

allocation happens in init
cleanup happens in deinit

Use errdefer in Initializers

If an initializer performs more than one allocation, use errdefer.

const User = struct {
    name: []u8,
    email: []u8,

    pub fn init(
        allocator: std.mem.Allocator,
        name: []const u8,
        email: []const u8,
    ) !User {
        const owned_name = try allocator.dupe(u8, name);
        errdefer allocator.free(owned_name);

        const owned_email = try allocator.dupe(u8, email);
        errdefer allocator.free(owned_email);

        return User{
            .name = owned_name,
            .email = owned_email,
        };
    }

    pub fn deinit(self: User, allocator: std.mem.Allocator) void {
        allocator.free(self.name);
        allocator.free(self.email);
    }
};

If the second allocation fails, the first allocation is cleaned up.

On success, ownership moves to the returned User.

Avoid Returning Borrowed Temporary Memory

This is broken:

fn badGreeting(name: []const u8) ![]const u8 {
    var buffer: [128]u8 = undefined;
    return try std.fmt.bufPrint(&buffer, "hello, {s}", .{name});
}

The returned slice points into buffer.

But buffer is local to the function. It dies when the function returns.

Correct version with caller-provided buffer:

fn writeGreeting(buffer: []u8, name: []const u8) ![]u8 {
    return try std.fmt.bufPrint(buffer, "hello, {s}", .{name});
}

Correct version with owned allocation:

fn allocGreeting(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
    return try std.fmt.allocPrint(allocator, "hello, {s}", .{name});
}

These two functions have different contracts.

One borrows caller memory. The other returns owned memory.

Separate Borrowing APIs from Owning APIs

A good design may provide both forms.

fn formatGreeting(buffer: []u8, name: []const u8) ![]u8 {
    return try std.fmt.bufPrint(buffer, "hello, {s}", .{name});
}

fn allocGreeting(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
    return try std.fmt.allocPrint(allocator, "hello, {s}", .{name});
}

The caller chooses.

For small or hot code paths:

var buffer: [128]u8 = undefined;
const greeting = try formatGreeting(&buffer, "Ada");

For convenience when dynamic memory is acceptable:

const greeting = try allocGreeting(allocator, "Ada");
defer allocator.free(greeting);

This is a strong API pattern.

Document Returned Ownership

When a function returns a slice, make its ownership clear.

These return types look similar:

fn getName(user: User) []const u8
fn makeName(allocator: std.mem.Allocator) ![]u8

But their ownership is different.

getName probably returns borrowed memory:

fn getName(user: User) []const u8 {
    return user.name;
}

The caller must not free it.

makeName probably returns owned memory:

fn makeName(allocator: std.mem.Allocator) ![]u8 {
    return try allocator.dupe(u8, "Ada");
}

The caller must free it.

The type alone does not always tell the whole story. Use naming, comments, and consistent patterns.

Avoid Global Allocators by Default

A global allocator can make code convenient, but it hides memory policy.

Avoid this as a default style:

var global_allocator: std.mem.Allocator = undefined;

Prefer explicit parameters:

fn loadFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
    // allocate using caller-provided allocator
}

Explicit allocators make code more reusable.

The same function can be used with:

testing allocator
arena allocator
fixed buffer allocator
general purpose allocator
custom limiting allocator

That flexibility is one of Zig’s strengths.

Design for Tests

Allocation-friendly APIs are easier to test.

Example:

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

test "build message" {
    const allocator = std.testing.allocator;

    const message = try buildMessage(allocator, 42);
    defer allocator.free(message);

    try std.testing.expectEqualStrings("user-42", message);
}

Because the function accepts an allocator, the test can use std.testing.allocator.

If the function leaks memory, the test can report it.

Design for Memory Limits

The same API can work with a fixed buffer allocator.

test "build message with limited memory" {
    var memory: [64]u8 = undefined;

    var fba = std.heap.FixedBufferAllocator.init(&memory);
    const allocator = fba.allocator();

    const message = try buildMessage(allocator, 42);

    try std.testing.expectEqualStrings("user-42", message);
}

This tests that the function works within a small memory budget.

If the function secretly used std.heap.page_allocator, this test would not prove that.

A Practical API Checklist

Before finalizing a Zig API, ask:

Does this function allocate?
If yes, does it accept an allocator?
Who owns returned memory?
Who frees returned memory?
Can the caller avoid allocation by providing a buffer?
Does the type need deinit?
Does the initializer clean up correctly on partial failure?
Can this be tested with std.testing.allocator?
Can this run with a fixed buffer allocator or arena?

These questions lead to better Zig APIs.

The Core Idea

Allocation-friendly APIs make memory visible without making code messy.

Use borrowed slices when no allocation is needed. Accept an allocator when allocation is needed. Return owned memory only when ownership transfer is clear. Pair init with deinit. Use caller-provided buffers when you want to avoid allocation.

The rule is:

The caller should be able to see and control the memory policy.