Zig Pointers
A pointer stores the memory address of another value. Instead of holding a number or a string directly, a pointer holds the location in memory where that value lives. Pointers let you access large data efficiently, modify values from inside functions, and build dynamic data structures.
The Address Model
Memory (simplified): Address │ Value ────────┼────── 0x1000 │ 42 ← variable x lives here 0x1004 │ ... 0x1008 │ 0x1000 ← pointer p stores address of x 0x100C │ ...
var x: i32 = 42; const p = &x; // p holds the address of x x → value 42 &x → address 0x1000 (example) p → holds 0x1000 p.* → reads value at 0x1000 → 42
Creating and Using Pointers
var number: i32 = 10;
const ptr = &number; // ptr has type *i32
std.debug.print("Value: {d}\n", .{number}); // 10
std.debug.print("Address: {}\n", .{ptr}); // memory address
std.debug.print("Via ptr: {d}\n", .{ptr.*}); // 10
ptr.* = 99; // write through pointer
std.debug.print("Now: {d}\n", .{number}); // 99
The & operator takes the address of a variable. The .* operator (called dereferencing) reads or writes the value at that address. Modifying ptr.* changes number directly — they point to the same memory.
Pointer Types
*T → single-item pointer (points to one T) [*]T → many-item pointer (points to an array of T, unknown length) []T → slice (pointer + length, the safe way to handle arrays) *const T → read-only pointer (cannot modify through this pointer) *volatile T → pointer to hardware-mapped memory (do not optimize away reads/writes)
Choosing a pointer type: One value? → *T An array? → []T (slice — always prefer this) C interop? → [*]T or [*:0]T Cannot modify? → *const T Hardware register? → *volatile T
Why Functions Use Pointers
Zig passes function arguments by value — the function gets a copy. To modify the original, pass a pointer:
fn doubleValue(n: *i32) void {
n.* = n.* * 2;
}
var score: i32 = 50;
doubleValue(&score);
std.debug.print("Score: {d}\n", .{score}); // 100
Without pointer: score=50 → [copy 50] → doubleValue → result 100 → copy discarded score is still 50 With pointer: score=50 → [address of score] → doubleValue → score.* = 100 score is now 100
Const Pointers
const message = "hello"; const ptr: *const u8 = &message[0]; // ptr.* → 'h' (reading allowed) // ptr.* = 'H' → COMPILE ERROR (const pointer, cannot write)
A *const T pointer lets you read the value but not modify it. Use const pointers to signal that a function will not change the data it receives — this is a common pattern for read-only parameters.
Optional Pointers
In C, a NULL pointer represents "no value" — but nothing stops you from accidentally dereferencing it. Zig makes null explicit through optional pointers:
var x: i32 = 42;
var maybe_ptr: ?*i32 = null;
maybe_ptr = &x; // now it points somewhere
if (maybe_ptr) |ptr| {
std.debug.print("Value: {d}\n", .{ptr.*}); // 42
} else {
std.debug.print("Pointer is null\n", .{});
}
maybe_ptr = ?*i32
|
Has a value?
|
yes → ptr → dereference safely
no → null branch, no crash
You cannot dereference an optional pointer without first checking it is not null. The compiler enforces this, eliminating the null dereference crashes common in C programs.
Pointer to Struct
const Player = struct { name: []const u8, health: i32 };
fn heal(p: *Player, amount: i32) void {
p.health += amount;
// Note: p.health is shorthand for p.*.health
// Zig auto-dereferences struct fields through pointers
}
var hero = Player{ .name = "Asha", .health = 70 };
heal(&hero, 30);
std.debug.print("{s} health: {d}\n", .{hero.name, hero.health}); // 100
When a pointer points to a struct, you access fields directly with ptr.field rather than ptr.*.field. Zig inserts the dereference automatically for struct field access.
Pointer Arithmetic
Moving a pointer forward or backward through memory — pointer arithmetic — is possible in Zig but requires care:
var arr = [_]i32{ 10, 20, 30, 40 };
const first: [*]i32 = &arr; // many-item pointer
const second = first + 1; // advance by one element
std.debug.print("{d}\n", .{second[0]}); // 20
arr: [ 10 | 20 | 30 | 40 ]
↑
first (address of arr[0])
↑
first+1 (address of arr[1])
Pointer arithmetic on [*]T pointers does not include bounds checking. Use slices ([]T) whenever possible — they carry their length and Zig checks bounds automatically.
Align — Pointer Alignment
Every type has an alignment requirement — the memory address must be a multiple of a certain number. Zig tracks alignment in the type system:
const aligned: *align(16) u8 = ...; // pointer is 16-byte aligned
Most of the time, Zig handles alignment automatically. You specify alignment explicitly when working with SIMD instructions, DMA buffers, or hardware that requires specific alignment guarantees.
Practical Example: Swap Two Values
const std = @import("std");
fn swap(a: *i32, b: *i32) void {
const temp = a.*;
a.* = b.*;
b.* = temp;
}
pub fn main() void {
var x: i32 = 100;
var y: i32 = 200;
std.debug.print("Before: x={d}, y={d}\n", .{x, y});
swap(&x, &y);
std.debug.print("After: x={d}, y={d}\n", .{x, y});
}
Output:
Before: x=100, y=200 After: x=200, y=100
Without pointers, the function would swap copies and the original variables would be unchanged. Passing addresses lets the function reach into the caller's memory and modify the actual variables.
