Skip to lesson

learningzig.org / intermediate / 17-pointers-and-references · lesson 10 of 25

TL;DR

Master Zig pointers — take addresses with &, dereference with .*, understand *T vs [*]T vs []T, and pass data by reference for efficient code.

Key concepts

  • zig pointers
  • zig references
  • zig dereference
  • zig pointer types
  • zig pass by reference

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.

What You'll Learn

By the end of this lesson you will write functions that reach back into a caller's own variables and change them — a counter that a function bumps, a struct that a function updates in place — and you will be able to look at any signature and say which of its parameters can be written to and which cannot. The build task at the end asks for exactly that: three functions, three different pointer types, chosen because of what each one is allowed to do.

You arrive here able to declare a struct and hold a fixed collection of values (Arrays And Structs) and to model a value that might be absent with ?T (Optionals And Unions); what is new is that a value can now be reached from somewhere other than the name it was declared under. This lesson has no capstone milestone attached — it is here because the two lessons after it, on allocators and on the memory the analyzer works over, are unreadable without it.

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.

zig
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.

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.

Two functions below differ by one character in their signature. Predict both printed values before you run it.

Predict

One function takes an i32, the other takes a *i32, and both are called on the same variable. What are the two printed values?

zig
const std = @import("std");

fn bumpValue(n: i32) void {
  var local = n;
  local += 10;
}

fn bumpPointer(n: *i32) void {
  n.* += 10;
}

pub fn main() void {
  var total: i32 = 5;

  bumpValue(total);
  std.debug.print("after bumpValue: {}\n", .{total});

  bumpPointer(&total);
  std.debug.print("after bumpPointer: {}\n", .{total});
}

Continue learning

The lines print 5 and then 15. Zig passes arguments by value, so bumpValue gets a copy and its arithmetic vanishes when the call returns. bumpPointer gets the address of total, and n.* += 10 writes through that address to the original variable. Nothing is implicit: the & at the call site, the * in the parameter type, and the .* in the body all mark the mutation on the page.

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.

zig
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.

Now assemble a mutation yourself. The lines below belong to a swap function that exchanges two values through pointers — the pieces are shuffled, and one order is right for a reason worth naming.

Arrange the code

These lines form a function that swaps the two values its pointers refer to, plus the call that uses it. Reorder them so the swap actually works: the signature first, then the three-step exchange in the only order that does not lose a value, then the closing brace and the call — then say which of the three exchange lines you could not have moved without losing a value, and what it would have been overwritten by.

  1. const temp = a.*;
  2. }
  3. fn swap(a: *i32, b: *i32) void {
  4. b.* = temp;
  5. swap(&left, &right);
  6. a.* = b.*;
Continue learning

The order is dictated by what each line destroys. a.* = b.* overwrites whatever a pointed at, so const temp = a.* has to run first or that value is lost — swap those two lines and both variables end up holding b's value. The final b.* = temp puts the saved value in the other slot. And the call passes &left and &right rather than left and right, because swap needs somewhere to write, not two copies to read.

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.

zig
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.

That last sentence has a mirror image that catches people out: a pointer to a const variable is not accepted where a mutable pointer is expected. The next program falls into it. Commit a hypothesis before you fix it.

Debug

This program should apply a discount to a price and print the result. It refuses to build, and the error points at the call, not at the function. Say why before you change anything, then fix it so the discounted price prints.

zig
const std = @import("std");

fn applyDiscount(price: *u32, percent: u32) void {
  price.* = price.* - (price.* * percent) / 100;
}

pub fn main() void {
  const price: u32 = 200;

  applyDiscount(&price, 25);

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

Expected output:

Discounted price: 150
Continue learning

The bug is at the declaration, not in the function. & on a const variable produces a *const u32, and Zig coerces mutable-to-const but never const-to-mutable — so the call fails with error: expected type *u32, found *const u32. Changing the declaration to var price: u32 = 200; makes &price a *u32 and the write legal. Notice the error surfaces at the call site, where a promise that the value never changes meets a function that intends to change it.

That *T / *const T split is not unique to Zig, and it is worth being precise about how much of it carries over to the language it most resembles here.

Transfer

Zig encodes whether a reference may be written through in the pointer type itself: *T can write, *const T cannot, and the compiler rejects the wrong write before the program runs. Rust draws the same line with &mut T and &T. Which statement names what the two languages actually share, rather than a surface resemblance?

Continue learning

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.
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.

zig
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.

The ? in ?*Node is not new syntax — you met it in Optionals and Unions, before pointers existed. Close the code and place it.

Recall

Without scrolling up: back in Optionals and Unions you wrote ?usize for an index that might be absent, and unwrapped it with if (x) |v|. This lesson writes ?*Node and unwraps it with while (current) |node|. What is the relationship between those two, and what does the capture hold in the pointer case?

Continue learning

They are one construct, not two. ? wraps any type — ?i32 is an integer or null, ?*Node is a pointer or null — and the same if/while capture unwraps both, binding the non-null payload. In the pointer case that payload is a *Node, which is why node.value reads through it. Read these types outside-in: ?*Node is an optional pointer, while *?Node would be a pointer to an optional — a different type entirely.

Pointers to Functions and to Unknown Types

Every pointer so far has addressed data of a type the compiler knows. Two remaining pointer forms relax one of those two things each, and together they are what makes a plug-in interface possible in Zig. You will meet both again in Interfaces And Vtables, which builds its whole pattern out of them.

A function pointer has the type *const fn (Args) Return — the fn keyword, the full signature, behind a *const. It holds the address of a function, so the call it performs is decided while the program runs rather than while it compiles.

zig
const std = @import("std");

fn double(n: i32) i32 {
    return n * 2;
}

fn negate(n: i32) i32 {
    return -n;
}

pub fn main() void {
    // The type spells out the whole signature: one i32 in, one i32 out.
    // It is *const because the code being pointed at is never rewritten —
    // only which function this variable selects can change.
    var op: *const fn (i32) i32 = double;
    std.debug.print("double(21) = {}\n", .{op(21)});

    op = negate; // same signature, so the same pointer type accepts it
    std.debug.print("negate(21) = {}\n", .{op(21)});
}

The *const is not decoration. Writing var op: fn (i32) i32 = double; — the bare fn type, no pointer — fails to build with error: variable of type 'fn (i32) i32' must be const or comptime, and the compiler's own note tells you the fix: "use *const fn (i32) i32 for a function pointer type". A bare fn type names one specific function known at compile time; the pointer type names a slot that any function of that shape can fill at run time. Only the second can be reassigned, which is the whole reason to reach for it.

Because it is an ordinary pointer, it is one machine word wide: @sizeOf(*const fn (i32) i32) is 8 on a 64-bit target, whatever the signature says.

The second form goes the other way and forgets the type. *anyopaque is a pointer to something whose type the compiler no longer tracks — an address with no shape. Any single-item pointer coerces to it without a cast, exactly the way any *T coerces to *const T: you are giving up knowledge, and giving up knowledge is always safe.

Getting the type back is the part that is not safe, and it takes two builtins:

  • @ptrCast reinterprets a pointer as a pointer to a different type.
  • @alignCast asserts that the address satisfies the alignment the destination type requires.
zig
const std = @import("std");

const Counter = struct {
    total: i32 = 0,
};

// This function's parameter says nothing about Counter. It could be called
// with the address of anything — which is what makes it usable as one entry
// in a table of interchangeable functions.
fn addToCounter(ctx: *anyopaque, amount: i32) void {
    const self: *Counter = @ptrCast(@alignCast(ctx));
    self.total += amount;
}

pub fn main() void {
    var c = Counter{};

    // Erasing the type needs no cast at all: *Counter coerces to *anyopaque.
    const erased: *anyopaque = &c;

    addToCounter(erased, 30);
    addToCounter(erased, 12);

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

Why both builtins, when only one of them changes the type? Not because the pointers themselves differ in size or alignment — every pointer here is one 8-byte machine word. The difference is in what each type promises about the address it holds. A *anyopaque addresses something of unknown type, so the most the compiler can assume about that address is alignment 1: it might point anywhere. A *Counter promises an address aligned to 4, because that is what a Counter requires. Reinterpreting a 1-aligned pointer as a 4-aligned one would be a promise @ptrCast is not willing to make on its own. Drop the @alignCast and the build stops with error: @ptrCast increases pointer alignment, followed by a note naming both alignments and one telling you to use @alignCast to assert pointer alignment. Written together, the pair reads as one move: @alignCast supplies the alignment promise, @ptrCast then changes the type.

What neither builtin does is check. @alignCast asserts an alignment; @ptrCast asserts a type; and once a pointer has been through *anyopaque there is nothing left in the value for either to verify against. Cast the address of a Logger to a *Counter and the program compiles, runs, exits zero, and writes into whichever bytes of the Logger happen to lie where Counter.total would have been. That is why the recovered type has to come from a discipline you impose — one function paired with the one type it was written for — rather than from anything the compiler can enforce for you.

zig
const std = @import("std");

const Counter = struct { total: i32 = 0 };
const Logger = struct { lines: u32 = 0 };

fn bump(ctx: *anyopaque) void {
    const self: *Counter = @ptrCast(@alignCast(ctx));
    self.total += 1;
}

pub fn main() void {
    var l = Logger{ .lines = 7 };

    // A Logger, handed to a function that will treat it as a Counter.
    // Nothing rejects this. Watch what happens to lines.
    bump(&l);

    std.debug.print("logger lines = {}\n", .{l.lines});
}

That program prints logger lines = 8. No error, no crash — bump incremented the four bytes at the front of the Logger, which is where it expected Counter.total to be, and those bytes were lines.

Put the two forms together and you have the shape the later lesson builds on: a function pointer whose first parameter is *anyopaque, so that one variable can hold any implementation's function, and each implementation recovers its own type on the way in.

zig
const std = @import("std");

const Counter = struct {
    total: i32 = 0,

    fn add(ctx: *anyopaque, amount: i32) void {
        const self: *Counter = @ptrCast(@alignCast(ctx));
        self.total += amount;
    }
};

// One named type for "any function that adds an amount to some object".
const Adder = *const fn (ctx: *anyopaque, amount: i32) void;

pub fn main() void {
    var c = Counter{};

    const op: Adder = Counter.add;
    op(&c, 30);
    op(&c, 12);

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

Adder mentions no concrete type at all, yet calling it moves a real Counter. A struct holding several such pointers is a vtable, and that is precisely how Interfaces And Vtables opens.

Try It Yourself

Reading about pointers is not the same as reaching for one by intent. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one mutates a scalar through a pointer, one mutates a struct through a pointer, one reads through a *const pointer without writing. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each one until every check passes and it prints All checks passed.

There is no assert to import: the starter defines its own check helper, which prints the failure and returns an error. Because an error returned from main ends the program with a non-zero exit status, the first failing check stops everything.

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each one behaves correctly. Run it as-is to see which check fails first, decide which pointer type that function needs, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

zig
const std = @import("std");

const Stats = struct {
  count: u32,
  total: i64,
  max: i32,
};

// TODO 1: add amount to the value the pointer refers to. The caller must see
//   the change, so write THROUGH the pointer.
//   var n: i32 = 5; addTo(&n, 7);  ->  n is 12
fn addTo(target: *i32, amount: i32) void {
  _ = target;
  _ = amount;
}

// TODO 2: fold one value into a running Stats: bump count by 1, add value to
//   total, and raise max if value is bigger than the current max.
//   Remember the auto-deref shorthand: s.count works, no s.*.count needed.
fn record(s: *Stats, value: i32) void {
  _ = s;
  _ = value;
}

// TODO 3: return the mean of a Stats as an f64, or 0 when count is zero.
//   The parameter is *const Stats — you may read through it but not write.
//   Converting an integer to a float needs @floatFromInt.
fn mean(s: *const Stats) f64 {
  _ = s;
  return 0;
}

// A tiny stand-in for an assert: print what went wrong and return an error.
fn check(ok: bool, comptime message: []const u8) !void {
  if (!ok) {
      std.debug.print("CHECK FAILED: {s}\n", .{message});
      return error.CheckFailed;
  }
}

// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
  var n: i32 = 5;
  addTo(&n, 7);
  try check(n == 12, "addTo(&n, 7) should leave the caller's n at 12, not 5");
  addTo(&n, -2);
  try check(n == 10, "addTo should also handle a negative amount: n should be 10");

  var stats = Stats{ .count = 0, .total = 0, .max = -1000 };
  record(&stats, 40);
  record(&stats, 10);
  record(&stats, 70);
  try check(stats.count == 3, "record should bump count once per call: count should be 3");
  try check(stats.total == 120, "record should accumulate total: 40 + 10 + 70 is 120");
  try check(stats.max == 70, "record should keep the largest value seen: max should be 70");

  try check(@abs(mean(&stats) - 40.0) < 0.0001, "mean should be 120 / 3, which is 40");

  const empty = Stats{ .count = 0, .total = 0, .max = -1000 };
  try check(mean(&empty) == 0, "mean of an empty Stats should be 0, not a division by zero");

  std.debug.print("All checks passed.\n", .{});
  std.debug.print("n = {}\n", .{n});
  std.debug.print("count={} total={} max={} mean={d:.2}\n", .{ stats.count, stats.total, stats.max, mean(&stats) });
}

Expected output:

All checks passed.
n = 10
count=3 total=120 max=70 mean=40.00
Continue learning

Once it passes, try two variations and predict each before running:

  1. Drop the dereference. In addTo, change target.* += amount; to _ = amount; and leave the pointer untouched. Predict which check fails before running. It builds fine — nothing forces you to use a pointer you were handed — but n stays at 5, so the first check fails with addTo(&n, 7) should leave the caller's n at 12, not 5. Receiving an address is not the same as writing to it.
  2. Try to write through the const pointer. In mean, add s.count = 0; as the first line. Predict whether this fails at build time or at run time. It never runs: s is a *const Stats, so the compiler rejects the assignment with error: cannot assign to constant. That single keyword in the signature is enforced, not documentation.

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, the next question is where that memory comes from. Next, you'll learn how Zig handles allocation -- passing allocators explicitly, choosing the right one, and pairing every allocation with cleanup.

Ready to try it? Continue in your learning studio to practice these ideas, save your progress, and experiment with code.