A pointer in Zig is a value that holds the memory address of another value. If you mention the word “pointer” in a room full of web developers, you’ll see terrified faces; but in Zig we handle them much more orderly than in C.
A pointer is nothing more than a memory address. A number that says “the data is at address 0x1234”.
C and C++ distinguish the type of the pointed-to element, but a T* does not express whether there is a single value or a sequence behind it. If you get the length or the lifetime wrong, you can access memory that no longer belongs to the program.
Zig separates several pointer types to express whether they point to one or multiple elements.
Getting the address (&)
First things first: how do I get a pointer? Just like in C, we use the & (ampersand) operator before a variable.
var number: u8 = 10;
// 'ptr' is of type *u8 (Pointer to a u8)
const ptr = &number;
std.debug.print("The address is: {*}\n", .{ptr});Here ptr knows exactly two things:
- Where the data is.
- What type of data it is (
u8).
Single-element pointers (*T)
In Zig, a pointer written as *T (e.g., *i32, *bool) points to EXACTLY ONE element.
This introduces an important restriction: there is no pointer arithmetic for the *T type.
If you have a pointer to an integer ptr, in C you could do ptr + 1 to access “whatever is next in memory”. In Zig, that is a Compile Error.
var x: i32 = 42;
const ptr: *i32 = &x;
// ERROR: binary operator '+' cannot be applied to type '*i32'
// const next = ptr + 1;Zig tells you: “Hey, you told me this points to ONE integer. I have no guarantees that anything valid follows. If you want an array, use a Slice.”
Dereferencing (.*)
To access the value stored at that address (what would be *ptr in C), in Zig we use the suffix syntax .*.
var x: u8 = 10;
const ptr = &x;
ptr.* += 5; // We access the value and add 5 to it
std.debug.print("Value of x: {d}\n", .{x}); // Prints 15Why at the end?
Putting the asterisk at the end (variable.*) improves readability when chaining accesses. Instead of (*(*ptr).field).something, in Zig we read ptr.field.something. Much cleaner.
const and var in pointers
This is where people get confused. There are two levels of immutability:
- Can I change who the pointer points to?
- Can I change the value I’m pointing to?
var health: u8 = 100;
var other_health: u8 = 80;
// 1. Mutable pointer to mutable data (*u8)
var p1: *u8 = &health;
p1.* = 50; // OK
p1 = &other_health; // OK
// 2. Pointer to CONSTANT data (*const u8)
// I can read health, but I cannot change it through this pointer
const p2: *const u8 = &health;
// p2.* = 0; // ERROR: cannot assign to constant
// 3. Immutable pointer (const p)
// The pointer will always point to 'health', cannot be reassigned.
const p3: *u8 = &health;The general rule is: if a function receives an argument to read it (and it is a large struct), pass it as *const T. If it is to modify it, pass it as *T.
Many-item pointers ([*]T)
For interoperability with C or implementing certain low-level structures, there is the many-item pointer, written as [*]T.
This type works exactly like a C pointer.
- It points to one element, but assumes there are more after it.
- Allows arithmetic:
ptr + 1is valid. - Allows indexing:
ptr[5]is valid. - Does not know bounds: if the index exceeds the valid region, you incur illegal behavior.
var array = [_]u8{ 10, 20, 30, 40 };
// Cast to a many-item pointer
// .ptr gives us the "raw" pointer of the array
const many_ptr: [*]u8 = &array;
// C-style access (no bounds safety)
const third_element = many_ptr[2]; // 30
const fourth_element = (many_ptr + 3).*; // 40Avoid using [*]T when you can preserve the length. Its main use is interoperability with C or very low-level data structures. For sequences with a known length, use slices ([]T).
Type comparison
So you never get lost, here is the hierarchy of references in Zig:
| Type | Name | Knows size? | Safe? | Arithmetic | Use |
|---|---|---|---|---|---|
*T | Single-item pointer | Is 1 | Limited checks | No | References to values, parameter passing. |
[]T | Slice | Yes (.len) | Checks bounds in safe modes | No | Sequences, strings, buffers. |
[*]T | Many-item pointer | No | No bounds checking | Yes | Interoperability with C and low-level code. |
Optional pointers
One last important thing that differentiates Zig from C/C++: Pointers in Zig (*T) can never be null.
A variable of type *u8 cannot hold the zero address. This alone does not guarantee the pointer is valid: you must still respect the alignment, provenance, and lifetime of the pointed-to memory.
When a reference might be missing, we use an optional pointer: ?*T. This way, the possible null is reflected in the type and must be handled before dereferencing it.