Skip to content

Lifetimes

A pointer is useful only while the value it points to still exists.

A pointer is useful only while the value it points to still exists.

This is the lifetime of the value: the part of the program during which its storage is valid.

const std = @import("std");

pub fn main() void {
    var x: i32 = 10;
    const p = &x;

    std.debug.print("{d}\n", .{p.*});
}

Here x exists until the end of main. The pointer p is valid during that time.

A local variable belongs to the block where it is declared.

{
    var x: i32 = 10;
    const p = &x;

    std.debug.print("{d}\n", .{p.*});
}

// x is gone here

After the block ends, x no longer exists. A pointer to x must not be used outside that block.

This is wrong:

fn bad() *i32 {
    var x: i32 = 10;
    return &x;
}

The function returns the address of a local variable. But x belongs to the call to bad. When bad returns, x is gone. The returned pointer would point to storage that no longer holds a valid i32.

A pointer may outlive the name used to create it only if the storage itself still exists.

const std = @import("std");

var global_count: i32 = 0;

fn getCount() *i32 {
    return &global_count;
}

pub fn main() void {
    const p = getCount();
    p.* += 1;

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

This is valid. global_count has static storage. It exists for the whole program.

Heap allocation is another way to create storage with a longer lifetime.

const std = @import("std");

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

    const p = try allocator.create(i32);
    defer allocator.destroy(p);

    p.* = 42;

    std.debug.print("{d}\n", .{p.*});
}

The value pointed to by p exists until allocator.destroy(p) is called. The pointer must not be used after that.

allocator.destroy(p);

// p.* is invalid here

The pointer value still exists as a bit pattern, but it no longer refers to live storage.

Slices have lifetimes too. A slice refers to storage owned by something else.

fn firstThree() []i32 {
    var items = [_]i32{ 1, 2, 3 };
    return items[0..];
}

This has the same problem as returning &x. The slice points into a local array. When the function returns, the array is gone.

A safe function can ask the caller to provide the storage.

fn fill(items: []i32) void {
    for (items) |*item| {
        item.* = 0;
    }
}

The caller owns the array or slice. The function only uses it during the call.

pub fn main() void {
    var items = [_]i32{ 1, 2, 3 };
    fill(items[0..]);
}

This is the usual Zig style. The owner of the storage is clear.

A function may also allocate and return storage, but then ownership must be explicit.

const std = @import("std");

fn makeNumbers(allocator: std.mem.Allocator) ![]i32 {
    const items = try allocator.alloc(i32, 3);

    items[0] = 1;
    items[1] = 2;
    items[2] = 3;

    return items;
}

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

    const numbers = try makeNumbers(allocator);
    defer allocator.free(numbers);

    std.debug.print("{d}\n", .{numbers[0]});
}

The function makeNumbers allocates a slice and returns it. The caller must free it. The lifetime of the slice ends when allocator.free(numbers) is called.

This convention is important. Zig does not use a garbage collector. The program must have a clear owner for allocated memory.

The same rule applies to structs that contain slices or pointers.

const Buffer = struct {
    data: []u8,
};

A Buffer does not own memory just because it has a slice field. The slice points somewhere. The program must still know who owns that memory and when it is freed.

const Buffer = struct {
    data: []u8,

    fn clear(self: *Buffer) void {
        for (self.data) |*b| {
            b.* = 0;
        }
    }
};

This method uses the slice. It does not allocate or free it.

A common bug is storing a pointer to temporary storage.

const Holder = struct {
    value: *i32,
};

fn makeHolder() Holder {
    var x: i32 = 10;
    return Holder{ .value = &x };
}

The returned Holder contains a bad pointer. The struct survived, but the value it points to did not.

The fix is to store the value itself, or allocate storage whose lifetime is long enough.

const Holder = struct {
    value: i32,
};

fn makeHolder() Holder {
    return Holder{ .value = 10 };
}

This version has no pointer and no lifetime problem.

Pointers are for sharing access to storage, not for avoiding ordinary values. If a value can be copied plainly, copy it. Use a pointer when identity, mutation, or large size matters.

defer is often used to make lifetimes visible.

const p = try allocator.create(i32);
defer allocator.destroy(p);

The allocation and its cleanup appear together. The pointer is valid between those two points.

For slices:

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

The slice is valid until the deferred free runs.

A pointer may be valid, invalid, or optional. These are different ideas.

A valid pointer points to live storage.

const p: *i32 = &x;

An optional pointer may be absent.

const maybe: ?*i32 = null;

An invalid pointer is a bug. Zig does not use null to represent every invalid pointer. A non-optional pointer should point to real storage of the right type, alignment, and lifetime.

The practical rules are these:

// Do not return pointers to local variables.
// Do not return slices into local arrays.
// Do not use pointers after freeing their storage.
// Keep allocation and cleanup close together.
// Prefer values when no shared storage is needed.

Exercise 5-25. Write a function that fills a caller-provided slice with zeroes.

Exercise 5-26. Try to return a slice into a local array. Read the compiler error or observe why the code is invalid.

Exercise 5-27. Allocate one i32, assign it a value, print it, and free it.

Exercise 5-28. Define a struct with a slice field. Write a function that uses the slice but does not free it.