Skip to editor content
learningzig.orglesson 17 of 25

Pointers And References

Pointers are one of those topics that intimidate newcomers but become intuitive quickly once you see them clearly. In Zig, pointers are explicit and visible — there is no hidden reference counting or implicit boxing. When you pass a pointer, you know it. When you dereference one, you know it. This explicitness is a feature: your code says exactly what it does, and the compiler helps you avoid the classic mistakes.

This lesson covers Zig's pointer types, how to take addresses and dereference them, how to pass values by reference to functions, and the difference between *T, [*]T, and []T.

Taking an Address

Every variable in Zig lives at a memory address. The & operator produces a pointer to that variable. The resulting type is *T — a single-item pointer to a value of type T.

const std = @import("std");

pub fn main() void {
    var score: i32 = 42;
    const ptr: *i32 = &score;

    std.debug.print("score = {}\n", .{score});
    std.debug.print("address = {*}\n", .{ptr});
    std.debug.print("via pointer = {}\n", .{ptr.*});
}

The ptr.* syntax dereferences the pointer — it reads the value at the address. Notice that ptr itself is const (you cannot rebind the pointer to a different address), but the value it points to (score) is still mutable because score was declared with var.

Mutating Through a Pointer

When you pass a pointer to a mutable variable, you can modify the original value through that pointer. This is the foundation of pass-by-reference semantics in Zig.

const std = @import("std");

fn increment(n: *i32) void {
    n.* += 1;
}

fn reset(n: *i32, to: i32) void {
    n.* = to;
}

pub fn main() void {
    var counter: i32 = 0;

    increment(&counter);
    increment(&counter);
    increment(&counter);
    std.debug.print("After 3 increments: {}\n", .{counter});

    reset(&counter, 100);
    std.debug.print("After reset: {}\n", .{counter});
}

The function increment takes a *i32 — a pointer to an integer. Inside the function, n.* refers to the value at that address, and += 1 modifies it in place. The caller passes &counter to hand over the address of their local variable. This pattern is common in Zig wherever you want a function to mutate its argument.

Pointers to Structs

Pointers are especially useful with structs. Rather than copying a large struct on every function call, you pass a pointer to it. Zig gives you a shorthand: when you have a pointer to a struct, you can use .field directly instead of ptr.*.field.

const std = @import("std");

const Player = struct {
    name: []const u8,
    health: i32,
    score: u32,
};

fn takeDamage(player: *Player, amount: i32) void {
    player.health -= amount;
    if (player.health < 0) player.health = 0;
}

fn addScore(player: *Player, points: u32) void {
    player.score += points;
}

pub fn main() void {
    var hero = Player{
        .name = "Aria",
        .health = 100,
        .score = 0,
    };

    takeDamage(&hero, 35);
    addScore(&hero, 200);
    takeDamage(&hero, 80);
    addScore(&hero, 150);

    std.debug.print("Player: {s}\n", .{hero.name});
    std.debug.print("Health: {}\n", .{hero.health});
    std.debug.print("Score: {}\n", .{hero.score});
}

Inside takeDamage, writing player.health is automatically sugar for player.*.health. Zig applies this auto-deref for struct field access when the receiver is a pointer, keeping the code readable without hiding what's happening.

Const Pointers vs Mutable Pointers

Zig distinguishes between a pointer to a mutable value and a pointer to a read-only value. The type *const T means you can read through the pointer but not write through it.

const std = @import("std");

const Config = struct {
    max_connections: u32,
    timeout_ms: u64,
    debug: bool,
};

fn printConfig(cfg: *const Config) void {
    std.debug.print("Max connections: {}\n", .{cfg.max_connections});
    std.debug.print("Timeout: {}ms\n", .{cfg.timeout_ms});
    std.debug.print("Debug mode: {}\n", .{cfg.debug});
}

fn applyDefaults(cfg: *Config) void {
    if (cfg.timeout_ms == 0) cfg.timeout_ms = 5000;
    if (cfg.max_connections == 0) cfg.max_connections = 10;
}

pub fn main() void {
    var config = Config{
        .max_connections = 0,
        .timeout_ms = 0,
        .debug = true,
    };

    applyDefaults(&config);
    printConfig(&config);
}

printConfig takes *const Config — it only needs to read the struct, so it promises not to modify it. applyDefaults takes *Config because it needs to write. The compiler enforces this distinction: if you tried to write cfg.debug = false inside printConfig, it would fail to compile.

Use *const T whenever a function only reads through a pointer. It documents intent and lets the caller pass pointers to const variables safely.

Many-Item Pointers and Slices

Beyond single-item pointers, Zig has two other pointer-like types you will encounter regularly:

  • [*]T — a many-item pointer. It points to an unknown number of T values in contiguous memory, with no length attached. You can do pointer arithmetic on it but you must track the length yourself.
  • []T — a slice. This is a fat pointer: an address plus a length. Slices are the idiomatic way to work with sequences of data in Zig.
const std = @import("std");

fn sumSlice(values: []const i32) i64 {
    var total: i64 = 0;
    for (values) |v| total += v;
    return total;
}

fn scaleSlice(values: []i32, factor: i32) void {
    for (values) |*v| v.* *= factor;
}

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

    std.debug.print("Sum before: {}\n", .{sumSlice(&numbers)});

    scaleSlice(&numbers, 3);

    std.debug.print("Sum after scaling by 3: {}\n", .{sumSlice(&numbers)});
    std.debug.print("First element: {}\n", .{numbers[0]});
}

In scaleSlice, the loop uses |*v| — a pointer capture. Each v is a *i32 pointing to the current element, so v.* *= factor modifies the array in place. This is the standard Zig idiom for mutating elements during iteration.

Passing &numbers where a []i32 is expected works because Zig automatically coerces a pointer to an array into a slice, attaching the array's compile-time length.

Optional Pointers

Pointers in Zig are non-nullable by default. If you need a pointer that might be absent, wrap it in an optional: ?*T. The compiler will not let you dereference an optional pointer without first checking it, making null-pointer bugs a compile error rather than a runtime crash.

const std = @import("std");

const Node = struct {
    value: i32,
    next: ?*Node,
};

fn listSum(head: ?*const Node) i32 {
    var total: i32 = 0;
    var current = head;
    while (current) |node| {
        total += node.value;
        current = node.next;
    }
    return total;
}

pub fn main() void {
    var c = Node{ .value = 30, .next = null };
    var b = Node{ .value = 20, .next = &c };
    var a = Node{ .value = 10, .next = &b };

    const sum = listSum(&a);
    std.debug.print("List sum: {}\n", .{sum});
}

The linked list stores ?*Node for .next — a nullable pointer. The while (current) |node| unwraps the optional each iteration, binding the non-null pointer to node. When current is null, the loop exits. Without ?, you would need a sentinel value or a separate flag to signal the end of the list.

Try It Yourself

Apply what you have learned by writing a small statistics function that works on a mutable slice. The function should compute the min, max, and sum, then normalise all values so the maximum becomes 100.

const std = @import("std");

const Stats = struct {
    min: i32,
    max: i32,
    sum: i64,
};

fn analyze(values: []const i32) Stats {
    var result = Stats{ .min = values[0], .max = values[0], .sum = 0 };
    for (values) |v| {
        if (v < result.min) result.min = v;
        if (v > result.max) result.max = v;
        result.sum += v;
    }
    return result;
}

fn normalise(values: []i32, target_max: i32) void {
    var current_max: i32 = values[0];
    for (values) |v| if (v > current_max) { current_max = v; };
    if (current_max == 0) return;
    for (values) |*v| {
        v.* = @divTrunc(v.* * target_max, current_max);
    }
}

pub fn main() void {
    var data = [_]i32{ 15, 40, 25, 60, 10, 55 };

    const before = analyze(&data);
    std.debug.print("Before — min:{} max:{} sum:{}\n", .{
        before.min, before.max, before.sum,
    });

    normalise(&data, 100);

    const after = analyze(&data);
    std.debug.print("After  — min:{} max:{} sum:{}\n", .{
        after.min, after.max, after.sum,
    });
}

Try extending this: add a function that takes a *Stats and fills in an average field, then print it from main.

Key Takeaways

  • &variable produces a pointer (*T) to that variable; ptr.* dereferences it to read or write the value.
  • Functions take *T to mutate a caller's variable, and *const T to read without modifying — the compiler enforces the distinction.
  • Zig auto-derefs struct field access through a pointer: ptr.field is shorthand for ptr.*.field.
  • Slices ([]T) are fat pointers carrying both address and length; they are the idiomatic way to pass sequences of data.
  • Use the |*v| capture in for loops to get a pointer to each element and mutate in place.
  • Pointers are non-nullable by default. Use ?*T for optional pointers and the compiler will force you to handle the null case.
  • Pass large structs by pointer to avoid copying; pass by value when the struct is small or you want an independent copy.

Pro Tip: When you see *const T in a function signature, it is a strong signal that the function is pure with respect to that argument — it reads data but does not change it. Prefer *const T over *T wherever mutation is not needed. This makes your function signatures self-documenting and opens the door for the compiler to make additional optimisations.

Next Steps

Now that you understand how pointers reference raw memory, you're ready to tackle one of the most common data types built on top of them: strings. In Zig, strings are just byte slices -- and working with them effectively requires the techniques you'll learn next.

Next lesson

String Handling

Learn Zig string handling — work with []const u8 byte slices, compare and search strings, iterate bytes, and build strings with std.fmt.

25 min