Skip to content

Lifetime Management

Memory lifetime means:

Memory lifetime means:

How long is this memory valid?

This is one of the most important ideas in systems programming.

A program becomes unsafe when it uses memory after that memory is no longer valid.

In Zig, you must think carefully about ownership and lifetime because the language gives you direct control over memory.

A Simple Lifetime

Look at this example:

const std = @import("std");

pub fn main() void {
    var numbers = [_]i32{ 1, 2, 3 };

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

The array numbers exists during the execution of main.

When main ends, the array disappears because it lives on the stack.

Its lifetime is:

from entering main
until leaving main

Using numbers inside main is safe.

Using it after main ends is impossible because the program is already ending.

Returning Stack Memory Is Wrong

A classic lifetime bug is returning memory that belongs to a local variable.

Broken example:

const std = @import("std");

fn makeNumbers() []i32 {
    var numbers = [_]i32{ 1, 2, 3 };

    return &numbers;
}

This function returns a slice pointing to numbers.

But numbers is a local variable. Its lifetime ends when makeNumbers returns.

The returned slice points to dead stack memory.

That is a dangling slice.

The bug is conceptual:

memory died
reference survived

That is never safe.

Heap Memory Can Outlive the Function

Heap allocation changes the lifetime.

const std = @import("std");

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

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

    return numbers;
}

Now the memory comes from the allocator, not the stack.

The memory remains valid after the function returns.

But someone must eventually free it.

The caller becomes the owner:

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

The lifetime becomes:

allocate memory
return ownership to caller
caller eventually frees memory

Ownership and Lifetime

Ownership and lifetime are closely connected.

The owner of memory is responsible for its cleanup.

For example:

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

This scope owns buffer.

The lifetime of buffer ends when allocator.free(buffer) runs.

After that point, using buffer is invalid.

Use-After-Free

A use-after-free bug happens when code uses memory after it has been freed.

Broken example:

const std = @import("std");

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

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

    allocator.free(buffer);

    buffer[0] = 42;
}

This is invalid.

The memory lifetime ended here:

allocator.free(buffer);

Everything after that point is unsafe.

The slice still exists as a value, but the memory behind it no longer belongs to the program.

This is an important distinction:

A pointer or slice value existing does not guarantee the memory is valid.

Lifetime of Arena Allocations

Arena allocators create grouped lifetimes.

Example:

const std = @import("std");

pub fn main() !void {
    var arena = std.heap.ArenaAllocator.init(
        std.heap.page_allocator,
    );
    defer arena.deinit();

    const allocator = arena.allocator();

    const a = try allocator.dupe(u8, "red");
    const b = try allocator.dupe(u8, "blue");

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

The strings a and b stay valid until:

arena.deinit();

After that, all arena allocations become invalid together.

This gives one large shared lifetime:

everything allocated from this arena
dies when the arena dies

That is why arenas work well for temporary grouped data.

Lifetime of Fixed Buffer Allocations

A fixed buffer allocator depends on the backing buffer lifetime.

var memory: [1024]u8 = undefined;

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

All allocations come from memory.

That means:

allocated values stay valid only while memory stays valid

If memory is a local variable, the allocations die when the function returns.

Broken example:

const std = @import("std");

fn makeName() ![]u8 {
    var memory: [64]u8 = undefined;

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

    return try allocator.dupe(u8, "Ada");
}

The returned slice points into memory, but memory dies when the function returns.

That creates a dangling slice again.

Containers Also Have Lifetimes

Containers often own heap memory internally.

Example:

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

The list owns its internal buffer.

The buffer lifetime ends when:

list.deinit();

After deinitialization, using list.items is invalid.

Broken example:

const items = list.items;

list.deinit();

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

items points into memory owned by the list. After deinit, that memory is gone.

The slice survived. The memory did not.

Borrowed Memory vs Owned Memory

Some slices are borrowed.

Borrowed memory means:

you can use it temporarily
you do not own it
you must not free it

Example:

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

This function borrows name.

It does not allocate it. It does not free it. The caller keeps ownership.

Other functions return owned memory:

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

The caller now owns the returned slice and must free it.

Understanding this difference is critical.

A Useful Mental Question

Whenever you see a slice or pointer, ask:

Who owns the memory?
How long does it stay valid?
Who frees it?

These questions prevent many lifetime bugs.

Lifetime Transfer

Ownership can move from one place to another.

Example:

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

Inside the function:

the function temporarily owns the allocation

After returning:

the caller owns the allocation

This is ownership transfer.

The cleanup responsibility moves with it.

defer Helps Express Lifetime

defer matches cleanup to scope lifetime.

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

This says:

buffer is valid for the rest of this scope
free it when the scope ends

This keeps lifetime logic close to allocation logic.

That reduces mistakes.

Nested Lifetimes

Some values depend on other values.

Example:

var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();

const allocator = gpa.allocator();

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

The list depends on the allocator.

So the list must die before the allocator dies.

Because defer runs in reverse order:

list.deinit() runs first
gpa.deinit() runs second

That is correct.

Think of this as lifetime nesting:

allocator lifetime contains list lifetime

The dependency must outlive the thing that depends on it.

A Common Beginner Mistake

Broken code:

fn buildList(
    allocator: std.mem.Allocator,
) ![][]const u8 {
    var list = std.ArrayList([]const u8).init(allocator);
    defer list.deinit();

    try list.append("red");
    try list.append("blue");

    return list.items;
}

This returns list.items, but list.deinit() frees the internal storage before the caller receives it.

Correct approach:

either transfer ownership safely
or keep the container alive

A correct ownership-transfer design often uses:

return try list.toOwnedSlice();

because ownership moves from the container to the caller.

Lifetimes Are a Design Problem

Lifetime management is not merely a cleanup detail.

It affects API design.

A good Zig API makes ownership clear:

Does the function borrow memory?
Does it allocate new memory?
Who owns the result?
Who frees it?
How long is the data valid?

When those answers are visible, the code becomes easier to reason about.

The Core Idea

Lifetime management means ensuring memory stays valid exactly as long as it is needed, and no longer.

Most memory bugs come from one of these mistakes:

using memory after it died
freeing memory too early
never freeing memory
returning references to dead memory
forgetting who owns memory

The rule is:

Every pointer or slice must refer to memory whose lifetime still exists.