Skip to content

Pointer Arithmetic

Pointer arithmetic means moving a pointer forward or backward through memory.

Pointer arithmetic means moving a pointer forward or backward through memory.

In Zig, pointer arithmetic is not available on every pointer type. A single item pointer points to one value, so it does not support normal indexing or arithmetic. A many item pointer points into a sequence, so it can be moved.

*T    // single item pointer
[*]T  // many item pointer

A *T means “pointer to one T.”

A [*]T means “pointer to many T values starting here.”

Pointer arithmetic belongs to the second case.

Moving Through an Array

Start with an array:

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 p points to the first item of data.

You can access later items with indexes:

p[0] // first item
p[1] // second item
p[2] // third item

This looks like array indexing, but the pointer itself does not know the array length.

That is the dangerous part.

Adding to a Pointer

You can add an integer to a many item pointer:

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

Memory can be pictured like this:

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

p points to data[0]
q points to data[2]

So q[0] reads the same value as data[2].

Pointer Arithmetic Moves by Items

Pointer arithmetic moves by items, not by raw bytes.

For a [*]u8, moving by 1 moves by 1 byte, because u8 is 1 byte.

For a [*]u32, moving by 1 moves by 1 u32, which is usually 4 bytes.

Example:

const std = @import("std");

pub fn main() void {
    var numbers = [_]u32{ 100, 200, 300, 400 };

    const p: [*]u32 = &numbers;
    const q = p + 1;

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

Output:

200

q points to the second u32, not to the second byte.

This is the right behavior. Pointer arithmetic follows the type.

Subtracting from a Pointer

You can also subtract from a many item pointer:

const q = p + 3;
const r = q - 2;

If p points to data[0], then q points to data[3], and r points to data[1].

Example:

const std = @import("std");

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

    const p: [*]i32 = &data;
    const q = p + 3;
    const r = q - 2;

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

Output:

20

The pointer moves through the same sequence.

The rule is simple:

p + n moves forward by n items.

p - n moves backward by n items.

Indexing Is Related to Pointer Arithmetic

These two expressions refer to the same item:

p[2]
(p + 2)[0]

The first form is clearer.

The second form shows what is happening underneath: move the pointer forward, then read the item there.

Example:

const std = @import("std");

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

    const p: [*]u8 = &data;

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

Output:

7
7

Use indexing when you want to read an item at an offset.

Use explicit pointer movement only when that makes the low-level code clearer.

Pointer Arithmetic Has No Length Check

A many item pointer has no length.

This means Zig cannot know whether this is valid:

const x = p[100];

Maybe the memory has 101 items. Maybe it has only 4. The pointer type does not say.

This is why pointer arithmetic must always be controlled by a known bound.

Bad:

const value = p[1000];

Better:

var i: usize = 0;

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

Best in ordinary Zig code:

for (slice) |value| {
    _ = value;
}

A slice carries a length. A many item pointer does not.

Converting to a Slice

When you have a pointer and a length, create a slice as soon as possible.

const slice = p[0..len];

Example:

const std = @import("std");

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

    for (bytes) |byte| {
        std.debug.print("{}\n", .{byte});
    }
}

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

    printBytes(&data, data.len);
}

Output:

10
20
30

The function receives low-level input:

ptr: [*]const u8, len: usize

Then it immediately turns that into a slice:

const bytes = ptr[0..len];

That is usually a good pattern. Do the unsafe-looking boundary work once, then use the safer slice form for the rest of the function.

Walking a Pointer Manually

Sometimes low-level code advances a pointer step by step.

var p = start;
p += 1;
p += 1;

Here is a complete example:

const std = @import("std");

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

    var p: [*]u8 = &data;

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

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

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

Output:

10
20
30

Each p += 1 moves the pointer to the next item.

This style appears in parsers, binary readers, C interop code, and performance-sensitive loops.

But it needs a clear stopping condition. Without a length or sentinel, the pointer does not know when to stop.

Using a Sentinel to Stop

Some sequences end with a sentinel value.

A common example is a C string, which ends with byte 0.

Zig can represent this with a sentinel-terminated pointer:

[*:0]const u8

This means:

many item pointer to const u8, ending with 0

Example:

const std = @import("std");

fn printCString(s: [*:0]const u8) void {
    var p = s;

    while (p[0] != 0) : (p += 1) {
        std.debug.print("{c}", .{p[0]});
    }

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

pub fn main() void {
    printCString("zig");
}

Output:

zig

The loop stops when it reaches the sentinel byte 0.

Without the sentinel, this loop would not know where the string ends.

Pointer Difference

In low-level code, you may want to know how far two pointers are apart.

A simple way is to track the index yourself:

var i: usize = 0;

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

This is usually clearer than trying to compute distances from raw addresses.

When you need raw addresses, Zig provides builtins such as @intFromPtr, but beginners should avoid using addresses as integers unless there is a specific low-level reason.

Example:

const addr = @intFromPtr(p);

This gives you the pointer address as an integer. That is useful for debugging, allocators, kernels, embedded code, and special systems work.

For normal application code, prefer indexes and slices.

Pointer Arithmetic and Alignment

Pointer arithmetic respects the pointer’s element type, but you still must make sure the pointer itself is valid and properly aligned for that type.

A [*]u32 should point to memory that is valid for u32.

This is different from a sequence of arbitrary bytes.

If you have raw bytes:

[]u8

do not casually treat them as:

[*]u32

The address may not be aligned for u32, and the bytes may not represent valid u32 values in the way you expect.

This becomes important when reading binary formats, network packets, memory-mapped files, and C data structures.

For beginners, the practical rule is:

Do pointer arithmetic using the correct type for the memory you actually have.

Common Mistake: Moving Past the End

This is the classic pointer arithmetic bug:

var data = [_]u8{ 1, 2, 3 };
const p: [*]u8 = &data;

const bad = p + 10;
_ = bad[0];

The pointer moves far past the array.

The compiler cannot use the many item pointer alone to know this is wrong.

A safer version keeps the length close:

var data = [_]u8{ 1, 2, 3 };
const slice = data[0..];

for (slice) |value| {
    _ = value;
}

Use raw pointer arithmetic only when a slice cannot express what you need.

Common Mistake: Losing the Original Pointer

Sometimes code advances a pointer and later needs the original start address.

Bad:

var p = start;

// p moves many times
p += 1;
p += 1;
p += 1;

// original start is lost

Better:

const start_ptr = start;
var p = start;

// p can move
p += 1;

// start_ptr still points to the beginning

This matters when you need to free memory, compute how much was consumed, or return a slice from the original range.

As a rule, keep the original pointer or slice if ownership or bounds matter.

Slice Indexing Is Usually Better

Many pointer arithmetic examples can be written more safely with slices.

Pointer style:

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;
}

Slice style:

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

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

    return total;
}

The slice version is clearer. It says “I need a sequence of i32 values.” The pointer version says “I need a raw starting address and a separate length.”

Use the slice version by default.

When Pointer Arithmetic Is Appropriate

Pointer arithmetic is useful when:

You are working with C APIs.

You are parsing raw memory.

You are writing allocators.

You are writing embedded or kernel code.

You are implementing low-level data structures.

You are walking a sentinel-terminated sequence.

You are building a safer abstraction on top of raw memory.

It is usually not needed for everyday Zig code.

The Main Idea

Pointer arithmetic lets you move a many item pointer through memory.

p + n moves forward by n items.

p - n moves backward by n items.

p[i] reads the item at offset i.

The pointer does not know the length of the sequence, so pointer arithmetic must always be guarded by a known length, a sentinel, or another clear boundary.

For normal code, use slices. For low-level code, use pointer arithmetic carefully and keep the bounds visible.