Skip to content

Ownership Rules

Allocation creates a responsibility.

Allocation creates a responsibility.

The program that owns allocated memory must release it. Zig does not guess who owns memory. Ownership must be visible from the code.

A common rule is simple:

The function that allocates memory frees it in the same scope.

const std = @import("std");

pub fn main() !void {
    const allocator = std.heap.page_allocator;

    const bytes = try allocator.alloc(u8, 128);
    defer allocator.free(bytes);

    @memset(bytes, 0);

    std.debug.print("{d}\n", .{bytes.len});
}

The allocation and free are close together:

const bytes = try allocator.alloc(u8, 128);
defer allocator.free(bytes);

This is the easiest ownership pattern to read.

Sometimes a function allocates memory and returns it. Then ownership moves to the caller.

const std = @import("std");

fn makeBuffer(allocator: std.mem.Allocator, len: usize) ![]u8 {
    const bytes = try allocator.alloc(u8, len);
    @memset(bytes, 0);
    return bytes;
}

pub fn main() !void {
    const allocator = std.heap.page_allocator;

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

    std.debug.print("{d}\n", .{buffer.len});
}

makeBuffer allocates the memory. main frees it.

The function signature gives the clue:

fn makeBuffer(allocator: std.mem.Allocator, len: usize) ![]u8

A returned slice may point to allocated memory. The caller must know whether it owns that memory.

This should be stated by convention, by name, and by documentation.

Do not hide ownership transfer.

For example, this function name is clear:

fn allocMessage(allocator: std.mem.Allocator) ![]u8

The word alloc tells the caller that memory is probably returned and must be freed.

This name is less clear:

fn message(allocator: std.mem.Allocator) ![]u8

The type is the same, but the ownership rule is harder to see.

Borrowing is different.

A borrowed slice points to memory owned by someone else. The borrower may read it, or sometimes modify it, but must not free it.

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

This function does not allocate. It does not free. It only uses the slice while it runs.

The caller keeps ownership.

A slice does not say whether it owns memory. Both of these have the same type:

[]u8

One may be owned heap memory. Another may be a slice of a stack array. Another may point into a larger buffer.

Ownership is a program rule, not a separate slice type.

That is why Zig code should keep ownership simple.

A common mistake is returning a slice to local memory:

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

buffer lives only while bad runs. After the function returns, the slice points to invalid memory.

Allocate when the result must outlive the function:

fn good(allocator: std.mem.Allocator) ![]u8 {
    const buffer = try allocator.alloc(u8, 3);
    buffer[0] = 'z';
    buffer[1] = 'i';
    buffer[2] = 'g';
    return buffer;
}

Then the caller frees it:

const s = try good(allocator);
defer allocator.free(s);

Another common mistake is freeing memory too early:

const bytes = try allocator.alloc(u8, 10);
allocator.free(bytes);

bytes[0] = 1; // wrong

After free, the slice must not be used.

Double free is also wrong:

const bytes = try allocator.alloc(u8, 10);

allocator.free(bytes);
allocator.free(bytes); // wrong

The program must release allocated memory exactly once.

Containers follow the same rule.

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

try list.appendSlice("zig");

The list owns its internal buffer. deinit releases it.

But list.items is only a view into the list’s memory:

const items = list.items;

Do not free items directly. The list owns it.

The owner releases memory. Borrowers do not.

This rule applies to structs too.

const Buffer = struct {
    allocator: std.mem.Allocator,
    data: []u8,

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

    fn deinit(self: *Buffer) void {
        self.allocator.free(self.data);
        self.data = &[_]u8{};
    }
};

The struct stores the allocator because it owns data.

A caller uses it like this:

var b = try Buffer.init(allocator, 1024);
defer b.deinit();

This is a common Zig pattern:

init
deinit

init acquires resources. deinit releases them.

Ownership rules should be boring. The simpler they are, the fewer memory bugs the program has.

Exercises:

Exercise 12-21. Write a function named allocZeros that returns an allocated slice filled with zero bytes. Free it in the caller.

Exercise 12-22. Write a function that borrows a []const u8 and prints it without allocating.

Exercise 12-23. Explain why returning a slice to a local array is invalid.

Exercise 12-24. Write a small struct that owns an allocated buffer and releases it in deinit.