Many Item Pointers
A many item pointer is a pointer that can move across several values of the same type.
Many Item Pointers
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.
| Type | Has pointer | Has length | Supports indexing | Typical use |
|---|---|---|---|---|
*T |
yes | no | no, only p.* |
one value |
[*]T |
yes | no | yes | low-level sequences |
[]T |
yes | yes | yes, bounds checked | normal 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.