Skip to content

Many Item Pointers

A many item pointer is a pointer that can move across several values of the same type.

A many item pointer is a pointer that can move across several values of the same type.

Its type looks like this:

[*]T

Read it as:

many item pointer to T

For example:

[*]u8

means:

many item pointer to u8

A single item pointer, *T, points to one value.

A many item pointer, [*]T, points to the first value in a sequence.

The important difference is that a many item pointer supports indexing and pointer arithmetic, but it does not store a length.

Why Many Item Pointers Exist

Many item pointers are useful when working close to C, operating systems, buffers, and low-level memory.

C often represents arrays as pointers:

unsigned char *buffer;

The pointer tells you where the buffer starts, but it does not tell you how long the buffer is.

Zig can represent that idea with:

[*]u8

This says:

there may be many u8 values starting at this address

But Zig still does not know how many.

That is why many item pointers are lower-level than slices.

Many Item Pointer vs Slice

A slice is usually safer and more convenient:

[]T

A slice contains both a pointer and a length.

A many item pointer contains only a pointer.

TypeHas pointerHas lengthSupports indexingTypical use
*Tyesnono, only p.*one value
[*]Tyesnoyeslow-level sequences
[]Tyesyesyes, bounds checkednormal buffers

For most Zig code, prefer slices.

Use many item pointers when you specifically need raw pointer-like behavior.

Creating a Many Item Pointer

You can get a many item pointer from an array by taking the address of its first element:

pub fn main() void {
    var data = [_]u8{ 10, 20, 30, 40 };

    const p: [*]u8 = &data;

    _ = p;
}

Here, data is an array of four u8 values.

The pointer p points to the first item of data.

You can index it:

const first = p[0];
const second = p[1];

Full example:

const std = @import("std");

pub fn main() void {
    var data = [_]u8{ 10, 20, 30, 40 };

    const p: [*]u8 = &data;

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

Output:

10
20

The pointer itself does not know that the array has 4 values. You must know that from somewhere else.

Indexing a Many Item Pointer

A many item pointer can be indexed like an array:

p[0]
p[1]
p[2]

But there is no length check based on the pointer itself.

This is dangerous:

const value = p[1000];

Zig cannot know whether index 1000 is valid, because p does not carry a length.

If the original memory has only 4 items, then p[1000] is invalid.

This is the central risk of many item pointers.

A slice would be safer:

const s = data[0..];
const value = s[1000]; // bounds check in safe modes

With a slice, Zig knows the length.

With a many item pointer, Zig does not.

Pointer Arithmetic

Many item pointers support pointer arithmetic.

You can move the pointer forward:

const q = p + 2;

If p points to data[0], then q points to data[2].

Example:

const std = @import("std");

pub fn main() void {
    var data = [_]u8{ 10, 20, 30, 40 };

    const p: [*]u8 = &data;
    const q = p + 2;

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

Output:

30

Why 30?

Because q points to the third item.

data:  10  20  30  40
index:  0   1   2   3

p points here:
       10

q = p + 2 points here:
               30

Pointer arithmetic moves by elements, not by raw bytes.

If p is a [*]u8, then p + 1 moves by 1 byte because u8 is 1 byte.

If p is a [*]u32, then p + 1 moves by 4 bytes because u32 is 4 bytes.

The arithmetic is based on the pointed-to type.

Converting a Many Item Pointer to a Slice

A many item pointer has no length, but you can make a slice if you know the length.

const s = p[0..4];

This creates a slice of 4 items starting at p.

Example:

const std = @import("std");

pub fn main() void {
    var data = [_]u8{ 10, 20, 30, 40 };

    const p: [*]u8 = &data;
    const s = p[0..4];

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

Output:

{ 10, 20, 30, 40 }

This is common when calling low-level APIs.

You may receive:

ptr: [*]u8
len: usize

Then you create:

const slice = ptr[0..len];

Now you can use safer slice operations.

Passing Many Item Pointers with Lengths

Since a many item pointer does not know its length, functions usually pass a length separately.

fn sum(ptr: [*]const i32, len: usize) i32 {
    var total: i32 = 0;
    var i: usize = 0;

    while (i < len) : (i += 1) {
        total += ptr[i];
    }

    return total;
}

Full example:

const std = @import("std");

fn sum(ptr: [*]const i32, len: usize) i32 {
    var total: i32 = 0;
    var i: usize = 0;

    while (i < len) : (i += 1) {
        total += ptr[i];
    }

    return total;
}

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

    const result = sum(&data, data.len);

    std.debug.print("sum = {}\n", .{result});
}

Output:

sum = 10

This works, but a slice version is cleaner:

fn sum(values: []const i32) i32 {
    var total: i32 = 0;

    for (values) |value| {
        total += value;
    }

    return total;
}

Prefer the slice version unless you have a reason to use raw pointer style.

Const Many Item Pointers

A many item pointer can point to mutable or immutable data.

Mutable:

[*]u8

This allows writing through the pointer.

Immutable:

[*]const u8

This allows reading, but not writing.

Example:

const std = @import("std");

fn printBytes(ptr: [*]const u8, len: usize) void {
    var i: usize = 0;

    while (i < len) : (i += 1) {
        std.debug.print("{}\n", .{ptr[i]});
    }
}

pub fn main() void {
    const data = [_]u8{ 5, 6, 7 };

    printBytes(&data, data.len);
}

The function receives [*]const u8, so it promises not to modify the bytes.

A function that modifies the bytes would use:

fn clearBytes(ptr: [*]u8, len: usize) void {
    var i: usize = 0;

    while (i < len) : (i += 1) {
        ptr[i] = 0;
    }
}

A Practical Example: Fill a Buffer

Here is a function that fills a buffer through a many item pointer:

fn fill(ptr: [*]u8, len: usize, value: u8) void {
    var i: usize = 0;

    while (i < len) : (i += 1) {
        ptr[i] = value;
    }
}

Use it like this:

const std = @import("std");

fn fill(ptr: [*]u8, len: usize, value: u8) void {
    var i: usize = 0;

    while (i < len) : (i += 1) {
        ptr[i] = value;
    }
}

pub fn main() void {
    var data = [_]u8{ 1, 2, 3, 4 };

    fill(&data, data.len, 9);

    std.debug.print("{any}\n", .{data});
}

Output:

{ 9, 9, 9, 9 }

Again, this is valid, but the slice version is better for normal Zig:

fn fill(buffer: []u8, value: u8) void {
    for (buffer) |*byte| {
        byte.* = value;
    }
}

The slice carries its length. The function signature becomes simpler.

Sentinel-Terminated Many Item Pointers

Zig also has sentinel-terminated many item pointers.

They look like this:

[*:0]const u8

This means:

many item pointer to const u8, ending with sentinel value 0

This is common for C strings.

C strings are usually sequences of bytes ending with 0.

For example:

h e l l o 0

Zig can express that with a sentinel pointer.

Example:

const std = @import("std");

pub fn main() void {
    const message: [*:0]const u8 = "hello";

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

A normal string literal in Zig has a zero byte at the end, so it can be used as a sentinel-terminated pointer.

This matters when calling C functions that expect strings ending in 0.

Many Item Pointers and C Interop

Many item pointers often appear when calling C libraries.

Suppose a C function expects:

void process(unsigned char *data, size_t len);

In Zig, this maps naturally to something like:

extern fn process(data: [*]u8, len: usize) void;

If the C function does not modify the data, it might be:

extern fn process(data: [*]const u8, len: usize) void;

For C strings:

void puts(const char *s);

Zig may represent the string pointer as:

[*:0]const u8

The sentinel tells Zig and the reader that the sequence ends with 0.

When to Use Many Item Pointers

Use many item pointers when:

You are calling C code.

You are writing very low-level code.

You have a pointer and a separate length from an external API.

You need pointer arithmetic.

You are implementing abstractions that will later expose safer types.

Avoid many item pointers when:

A slice would work.

You want bounds checks.

You want the length to travel with the data.

You are writing normal application logic.

In most Zig programs, []T appears more often than [*]T.

Common Mistake: Forgetting the Length

This function is unsafe as an API design:

fn printAll(ptr: [*]const u8) void {
    var i: usize = 0;

    while (true) : (i += 1) {
        std.debug.print("{}\n", .{ptr[i]});
    }
}

The function has no idea when to stop.

Unless the pointer is sentinel-terminated, a many item pointer needs a length.

Better:

fn printAll(ptr: [*]const u8, len: usize) void {
    var i: usize = 0;

    while (i < len) : (i += 1) {
        std.debug.print("{}\n", .{ptr[i]});
    }
}

Best for normal Zig:

fn printAll(bytes: []const u8) void {
    for (bytes) |byte| {
        std.debug.print("{}\n", .{byte});
    }
}

The slice version makes invalid use harder.

Common Mistake: Using Pointer Arithmetic Without a Clear Bound

This kind of code is risky:

var p: [*]u8 = some_pointer;

p += 1;
p += 1;
p += 1;

It may be valid, but only if you know the pointer still points inside valid memory.

Pointer arithmetic should always be tied to a known range.

For example:

var i: usize = 0;

while (i < len) : (i += 1) {
    const value = ptr[i];
    _ = value;
}

This is clearer because len gives a boundary.

The Main Idea

A many item pointer points to the start of a sequence, but it does not know the sequence length.

That makes it powerful and dangerous.

Use it when you need low-level pointer behavior, especially for C interop or manual memory work.

For ordinary Zig code, prefer slices.

A slice gives you the same basic ability to access a sequence, but it also carries the length. That one extra piece of information makes the code much safer and easier to understand.