Skip to content

Slice Lifetimes

A slice is a view into memory.

A slice is a view into memory.

It does not own the memory. It only points to memory that belongs to something else.

That means a slice is valid only while the original memory is still valid.

var numbers = [_]i32{ 10, 20, 30, 40 };

const part = numbers[1..3];

Here, part points into numbers.

numbers: 10, 20, 30, 40
part:        20, 30

The slice part is safe because numbers is still alive.

The Core Rule

The core rule is simple:

A slice must not live longer than the memory it points to.

This is one of the most important rules in Zig.

A slice contains a pointer and a length. The pointer says where the first element is. The length says how many elements are in the view.

slice = pointer + length

But the slice does not say who owns the memory. It does not keep the memory alive. It does not free the memory. It only refers to memory.

A Safe Slice

This is safe:

const std = @import("std");

fn printAll(values: []const i32) void {
    for (values) |value| {
        std.debug.print("{}\n", .{value});
    }
}

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

    printAll(numbers[0..]);
}

The array numbers lives inside main.

The slice numbers[0..] is passed to printAll.

While printAll runs, numbers still exists. The slice is valid.

An Unsafe Slice

This is wrong:

fn bad() []const i32 {
    const numbers = [_]i32{ 1, 2, 3 };
    return numbers[0..];
}

The function creates a local array:

const numbers = [_]i32{ 1, 2, 3 };

Then it returns a slice into that array:

return numbers[0..];

But numbers belongs to the function call. When bad returns, the local array is gone.

The caller receives a slice pointing to memory that is no longer valid.

That kind of slice is called a dangling slice.

Dangling Slices

A dangling slice is a slice that points to invalid memory.

For example:

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

The returned slice points into buffer.

But buffer is a local array. It stops being valid when the function returns.

The result may appear to work in a small test, but the program is wrong. The memory may later be reused for something else.

In low-level programming, this kind of bug is serious because it can corrupt data or crash the program.

Why Zig Cannot Always Save You

Zig catches many errors, but it does not turn memory management into magic.

A slice is a simple value. It can be copied, stored, returned, and passed to other functions.

Zig often cannot prove how long every piece of memory should live. So the programmer must understand ownership and lifetime.

This is not a weakness of Zig. It is part of its design. Zig gives you direct control, and direct control requires clear rules.

Safe Source: String Literals

A string literal is usually safe to return because it is stored in static memory.

fn languageName() []const u8 {
    return "Zig";
}

This is valid.

The string "Zig" is not a local array that disappears when the function returns. It is stored as constant program data.

So this is safe:

const name = languageName();

The returned slice points to static read-only memory.

Safe Source: Caller-Owned Memory

A common pattern is to let the caller provide the memory.

fn writeName(buffer: []u8) []u8 {
    buffer[0] = 'Z';
    buffer[1] = 'i';
    buffer[2] = 'g';

    return buffer[0..3];
}

Usage:

const std = @import("std");

pub fn main() void {
    var buffer = [_]u8{0} ** 16;

    const name = writeName(buffer[0..]);

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

This is safe because buffer belongs to main.

The function writeName only writes into memory provided by the caller. The returned slice points into that same caller-owned buffer.

This pattern is common in Zig because it makes ownership clear.

The caller decides where the memory comes from. The function decides what part of that memory it used.

Safe Source: Allocated Memory

Another safe source is heap-allocated memory.

const std = @import("std");

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

This returns a slice to allocated memory.

The memory remains valid after the function returns because it was allocated from the allocator, not created as a local array.

But the caller must later free it:

const std = @import("std");

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

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

    buffer[0] = 42;
}

The returned slice is valid until this line runs:

defer allocator.free(buffer);

More exactly, it is valid until the deferred call runs at the end of the scope.

After memory is freed, any slice pointing to it becomes invalid.

Unsafe After Free

This is wrong:

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

allocator.free(buffer);

buffer[0] = 42; // wrong

After allocator.free(buffer), the memory no longer belongs to your program.

The slice variable still exists, but the memory it points to is no longer valid.

This is another kind of dangling slice.

The slice value did not disappear. The memory did.

That distinction matters.

Returning Part of an Allocation

You can return a slice into allocated memory.

fn makeMessage(allocator: std.mem.Allocator) ![]u8 {
    const buffer = try allocator.alloc(u8, 16);

    buffer[0] = 'h';
    buffer[1] = 'i';

    return buffer[0..2];
}

This returns only the first two bytes.

But now there is a problem: how does the caller free the original allocation?

The allocator needs the exact slice that was allocated:

allocator.free(buffer);

If the caller only receives buffer[0..2], but the original allocation had length 16, freeing may be wrong.

A better design is to return the full allocation and separately tell the caller how many bytes are used, or allocate exactly the required length.

Example with exact length:

fn makeMessage(allocator: std.mem.Allocator) ![]u8 {
    const buffer = try allocator.alloc(u8, 2);

    buffer[0] = 'h';
    buffer[1] = 'i';

    return buffer;
}

Now the returned slice is the allocation, so the caller can free it correctly.

const msg = try makeMessage(allocator);
defer allocator.free(msg);

Borrowing vs Owning

A slice parameter usually means borrowing.

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

This function borrows the message. It does not own it. It should not free it.

An allocated slice returned from a function often means ownership is transferred to the caller.

fn duplicate(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
    const output = try allocator.alloc(u8, input.len);
    @memcpy(output, input);
    return output;
}

Usage:

const copy = try duplicate(allocator, "hello");
defer allocator.free(copy);

Here, the caller receives a new allocation and becomes responsible for freeing it.

This is a common Zig convention:

slice parameter: borrowed memory
allocated slice return: caller owns memory

There are exceptions, but this rule is a good starting point.

Keeping a Slice in a Struct

A struct can store a slice.

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

This does not store the name bytes directly. It stores a slice pointing to name bytes somewhere else.

This is safe if the pointed-to memory lives long enough.

Safe:

const user = User{
    .name = "Alice",
};

The string literal lives for the whole program.

Also safe:

const name = [_]u8{ 'A', 'l', 'i', 'c', 'e' };
const user = User{
    .name = name[0..],
};

This is safe as long as user does not outlive name.

Unsafe pattern:

fn makeUser() User {
    const name = [_]u8{ 'A', 'l', 'i', 'c', 'e' };

    return User{
        .name = name[0..],
    };
}

This returns a struct containing a dangling slice.

The local array name disappears when the function returns.

Fixing the Struct Example

There are several correct designs.

Use a string literal:

fn makeUser() User {
    return User{
        .name = "Alice",
    };
}

Use caller-owned memory:

fn makeUser(name: []const u8) User {
    return User{
        .name = name,
    };
}

Use allocated memory and define ownership clearly:

const User = struct {
    name: []u8,

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

Then create the user by allocating a copy of the name:

fn makeUser(allocator: std.mem.Allocator, name: []const u8) !User {
    const copy = try allocator.alloc(u8, name.len);
    @memcpy(copy, name);

    return User{
        .name = copy,
    };
}

Usage:

const user = try makeUser(allocator, "Alice");
defer user.deinit(allocator);

Now the struct owns the memory, and it provides a deinit method to release it.

Temporary Slices

Be careful with slices created from temporary values.

A safe example:

const values = [_]i32{ 1, 2, 3 };
const slice = values[0..];

The array has a name, and it lives long enough.

A risky pattern is building data inside a small scope and using a slice outside that scope.

var slice: []const i32 = undefined;

{
    const values = [_]i32{ 1, 2, 3 };
    slice = values[0..];
}

_ = slice; // wrong

The array values exists only inside the block. After the block ends, slice points to invalid memory.

The scope where storage is created matters.

Slices and defer

defer is often used with allocations.

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

This means the buffer is valid until the current scope exits.

Any slice into that buffer is also valid only until the current scope exits.

const first_half = buffer[0..512];
const second_half = buffer[512..1024];

Both slices become invalid when allocator.free(buffer) runs.

You should not return first_half or second_half from the function unless you also transfer ownership in a clear and correct way.

A Function Should Document Lifetime Through Its Shape

Zig does not have lifetime annotations like some languages. Instead, you design function signatures that make ownership clear.

This function borrows:

fn parse(input: []const u8) void {
    _ = input;
}

The caller owns input.

This function fills caller-owned output:

fn formatNumber(buffer: []u8, value: i32) ![]u8 {
    return std.fmt.bufPrint(buffer, "{}", .{value});
}

The returned slice points into buffer.

This function allocates:

fn formatNumberAlloc(allocator: std.mem.Allocator, value: i32) ![]u8 {
    return try std.fmt.allocPrint(allocator, "{}", .{value});
}

The caller owns the returned slice and must free it.

These three shapes are different:

fn parse(input: []const u8) void
fn formatNumber(buffer: []u8, value: i32) ![]u8
fn formatNumberAlloc(allocator: std.mem.Allocator, value: i32) ![]u8

Each one tells a different lifetime story.

Common Lifetime Patterns

Borrow input:

fn countLines(text: []const u8) usize {
    var count: usize = 0;

    for (text) |byte| {
        if (byte == '\n') {
            count += 1;
        }
    }

    return count;
}

Fill caller-provided output:

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

Return static data:

fn fileExtension() []const u8 {
    return ".zig";
}

Return allocated data:

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

Each pattern is valid. The important part is that the memory source is clear.

Complete Example

const std = @import("std");

fn firstWord(text: []const u8) []const u8 {
    for (text, 0..) |byte, index| {
        if (byte == ' ') {
            return text[0..index];
        }
    }

    return text;
}

fn makeCopy(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
    const copy = try allocator.alloc(u8, text.len);
    @memcpy(copy, text);
    return copy;
}

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

    const text = "hello zig";
    const word = firstWord(text);

    std.debug.print("first word: {s}\n", .{word});

    const copy = try makeCopy(allocator, word);
    defer allocator.free(copy);

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

The function firstWord returns a slice into text. It does not allocate.

The function makeCopy allocates new memory and copies the text.

The caller must free the result of makeCopy.

Summary

A slice is valid only while the memory it points to is valid.

Do not return a slice to a local array.

Do not use a slice after its allocation has been freed.

Be careful when storing slices in structs.

Use []const T for borrowed read-only input. Use []T for borrowed mutable input. Return allocated slices only when ownership is clear.

When reading Zig code, always ask: who owns this memory, and how long does it live?