Skip to content

Allocation Failure Handling

Allocation can fail.

Allocation can fail.

This is one of the most important facts in Zig memory management. When your program asks for heap memory, the request may not succeed. The system may be out of memory. A fixed buffer may be full. A custom allocator may reject the request because of a memory limit.

Zig does not hide this.

When you allocate memory, the result is usually an error union:

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

The call may return a slice of bytes, or it may return an error.

Allocation Returns an Error

The common allocation error is:

error.OutOfMemory

You usually see it through try:

fn makeBuffer(allocator: std.mem.Allocator) ![]u8 {
    return try allocator.alloc(u8, 1024);
}

The return type is:

![]u8

That means:

either an error
or a []u8 slice

If allocation fails, try returns the error from makeBuffer.

If allocation succeeds, try unwraps the slice and gives it to the rest of the function.

Why Zig Forces You to Notice

Some languages treat allocation failure as something rare or fatal. Zig treats it as part of the function contract.

That makes APIs clearer.

This function can fail:

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

The ![]u8 return type tells the caller:

This function may fail.
One possible reason is allocation failure.

The caller must then decide what to do.

Simple Propagation with try

The most common beginner pattern is to propagate the error.

const std = @import("std");

fn makeBuffer(allocator: std.mem.Allocator, size: usize) ![]u8 {
    return try allocator.alloc(u8, size);
}

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

    const allocator = gpa.allocator();

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

    buffer[0] = 42;
}

Here, makeBuffer can fail. main can also fail because its return type is:

!void

So main is allowed to return an error.

This is clean for small programs and command-line tools.

Handling the Error with catch

Sometimes you do not want to propagate the error. You want to handle it directly.

Use catch.

const buffer = allocator.alloc(u8, 1024) catch |err| {
    std.debug.print("allocation failed: {}\n", .{err});
    return err;
};

This code says:

Try to allocate memory.
If it fails, print the error and return it.

You can also choose a fallback behavior.

const buffer = allocator.alloc(u8, 1024) catch {
    std.debug.print("not enough memory\n", .{});
    return;
};

This works only inside a function where returning without a value is valid.

Handling OutOfMemory Specifically

You may want to handle only OutOfMemory.

const buffer = allocator.alloc(u8, 1024) catch |err| switch (err) {
    error.OutOfMemory => {
        std.debug.print("not enough memory\n", .{});
        return err;
    },
};

This looks verbose, but it is explicit.

You know exactly which error is being handled.

For allocation, the error set is often small, but the same pattern applies to larger error sets.

Cleanup After Partial Success

Allocation failure becomes more interesting when a function allocates several things.

Consider this function:

const std = @import("std");

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

fn makeUser(
    allocator: std.mem.Allocator,
    name: []const u8,
    email: []const u8,
) !User {
    const name_copy = try allocator.dupe(u8, name);
    const email_copy = try allocator.dupe(u8, email);

    return User{
        .name = name_copy,
        .email = email_copy,
    };
}

This has a bug.

If name_copy succeeds but email_copy fails, the function returns an error and loses the first allocation. That leaks memory.

We need cleanup for partial success.

Use errdefer

errdefer runs only when the function exits with an error.

const std = @import("std");

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

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

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

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

    return User{
        .name = name_copy,
        .email = email_copy,
    };
}

Now read the function carefully.

If name_copy succeeds, errdefer allocator.free(name_copy) is registered.

If email_copy fails, the function exits with an error, so the errdefer runs and frees name_copy.

If both allocations succeed, the function returns User. The errdefer statements do not run because the function did not return an error.

At that point, the returned User owns both allocations.

The caller must clean up:

const user = try makeUser(allocator, "Ada", "[email protected]");
defer user.deinit(allocator);

This is a core Zig pattern.

defer vs errdefer

Use defer for cleanup that should always happen when leaving the scope.

Use errdefer for cleanup that should happen only if the function fails.

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

This frees buffer when the scope exits, whether the function succeeds or fails.

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

This frees buffer only if the function exits with an error.

Use errdefer when ownership will be transferred on success.

Ownership Transfer on Success

In makeUser, ownership transfers to the caller when the function succeeds.

That is why defer would be wrong inside makeUser.

fn brokenMakeName(allocator: std.mem.Allocator) ![]u8 {
    const name = try allocator.dupe(u8, "Ada");
    defer allocator.free(name);

    return name;
}

This returns a slice, but defer frees it before the caller can use it.

That creates a dangling slice.

Correct version:

fn makeName(allocator: std.mem.Allocator) ![]u8 {
    const name = try allocator.dupe(u8, "Ada");
    errdefer allocator.free(name);

    return name;
}

In this simple function, the errdefer is not strictly necessary because there are no later fallible operations after the allocation. But it shows the idea.

If the function allocates and then does more work that may fail, use errdefer.

Fallible Initialization Pattern

Many Zig types follow this shape:

const Thing = struct {
    data: []u8,

    pub fn init(allocator: std.mem.Allocator) !Thing {
        const data = try allocator.alloc(u8, 1024);
        errdefer allocator.free(data);

        return Thing{ .data = data };
    }

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

This gives the type a clear lifecycle:

init may allocate and fail
successful init returns owned resources
deinit releases those resources

The caller uses it like this:

const thing = try Thing.init(allocator);
defer thing.deinit(allocator);

This pattern scales well as structs become more complex.

Avoid Panic for Normal Allocation Failure

A panic is a crash.

You can panic on allocation failure:

const buffer = allocator.alloc(u8, 1024) catch @panic("out of memory");

But this should not be your default habit.

Use try when the caller can handle or report the error.

Use catch when you have a local recovery strategy.

Use panic only when allocation failure means the program cannot reasonably continue.

For many command-line tools, returning an error from main is good enough.

Fixed Buffer Allocator Makes Failure Easy to Test

A fixed buffer allocator is useful for testing allocation failure.

const std = @import("std");

test "allocation can fail" {
    var memory: [8]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&memory);

    const allocator = fba.allocator();

    const result = allocator.alloc(u8, 100);

    try std.testing.expectError(error.OutOfMemory, result);
}

The buffer has only 8 bytes. The request asks for 100 bytes. The allocation must fail.

This kind of test is useful because it proves your code handles memory failure instead of assuming success.

The Core Idea

Allocation failure is normal in Zig’s type system.

An allocation returns either memory or an error. Use try to propagate the error. Use catch to handle it. Use errdefer to clean up partial work when a function fails after some allocations have already succeeded.

The rule is:

Every fallible allocation path must either transfer ownership safely or clean up what it already owns.