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:
[*]TRead it as:
many item pointer to TFor example:
[*]u8means:
many item pointer to u8A 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:
[*]u8This says:
there may be many u8 values starting at this addressBut 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:
[]TA 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
20The 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 modesWith 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:
30Why 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:
30Pointer 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: usizeThen 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 = 10This 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:
[*]u8This allows writing through the pointer.
Immutable:
[*]const u8This 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 u8This means:
many item pointer to const u8, ending with sentinel value 0This is common for C strings.
C strings are usually sequences of bytes ending with 0.
For example:
h e l l o 0Zig 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 u8The 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.