Zig pointer types comparison

Why Zig Cares Deeply About Pointers

Zig is a language designed for clarity in low-level code. One of its most deliberate design decisions is making pointer semantics explicit and unambiguous in the type system. There is no decay from arrays to pointers (like in C), no implicit reference types (like C++ references), and no hidden fat-pointer boxing.

Instead, Zig gives you a small taxonomy of pointer types, each encoding specific knowledge about what it points to — one thing or many, bounded or unbounded, nullable or guaranteed non-null. The compiler rejects misuse at compile time.

Key Insight In Zig, the type of a pointer tells you everything about the shape of the memory it describes. There is no ambiguity about whether a *u8 points to one byte or a hundred.

Let's walk through each pointer type from the most restrictive (and safest) to the most permissive.

Single-Item Pointer *T

The simplest pointer. A *T points to exactly one value of type T. You can dereference it with .* to read or (if not const) write the value. There is no arithmetic — you cannot step forward or backward through memory using a *T.

zigconst std = @import("std");

fn double(x: *i32) void {
    x.* *= 2;          // dereference and mutate
}

pub fn main() void {
    var value: i32 = 21;
    double(&value);      // pass address-of
    // value is now 42
}

The address-of operator &x produces a *T when x is a single variable, struct field, or array element. It does not automatically produce a many-item pointer.

Pointer arithmetic *T does not support index syntax ptr[n] or arithmetic like ptr + 1. The compiler will reject it. If you need arithmetic, you want [*]T.

Many-Item Pointer [*]T

A [*]T is Zig's equivalent of a raw C pointer. It points into an array of T values and supports indexing and pointer arithmetic. However, it carries no length information whatsoever — the programmer must track bounds manually, and any out-of-bounds access is undefined behaviour.

zigconst buf: [4]u32 = .{ 10, 20, 30, 40 };
const p: [*]const u32 = &buf;   // array coerces to [*]const u32

const a = p[0];              // 10  — indexed access
const b = p[1];              // 20
const q = p + 2;             // advance pointer by 2 elements
// q[0] == 30, q[1] == 40
// q[2] is undefined behaviour — we have no length!

Many-item pointers are typically received from C interop or from working directly with allocators. In pure Zig code, you almost always prefer a slice instead.

Danger Zone [*]T gives up Zig's usual safety guarantees. In debug / safe release modes, most out-of-bounds accesses via slices are caught at runtime — but [*]T is the exception. Treat it like a raw C pointer: you own the safety.

Slices []T

A slice is a fat pointer: an address paired with a length. The type []T is exactly equivalent to the struct struct { ptr: [*]T, len: usize } under the hood. It is Zig's workhorse for passing arrays around without copying.

zigfn sum(nums: []const u64) u64 {
    var total: u64 = 0;
    for (nums) |n| total += n;
    return total;
}

pub fn main() void {
    const data = [_]u64{ 1, 2, 3, 4, 5 };

    const s = sum(&data);       // array coerces to slice
    const t = sum(data[1..4]);  // slice of elements 1,2,3

    // Access fields directly
    const full: []const u64 = &data;
    // full.len == 5
    // full.ptr is the [*]const u64
}

String literals as slices

Zig string literals have type *const [N:0]u8 (a pointer to a sentinel-terminated array). They coerce to []const u8 for convenience, which is the idiomatic string type in Zig.

zigconst greeting: []const u8 = "hello";
// greeting.len == 5  (no null byte counted)
// greeting.ptr   → points into read-only data section

Sentinel-Terminated Pointer [*:0]T

A sentinel-terminated pointer is like a many-item pointer but with a known terminator value. The canonical form is [*:0]u8 — a null-terminated C string. The sentinel value can be any comptime-known value of type T, not just zero.

zigconst std = @import("std");
const c   = @cImport({ @cInclude("string.h"); });

pub fn main() void {
    // String literal coerces to [*:0]const u8 for C interop
    const msg: [*:0]const u8 = "hello, world";
    const len = c.strlen(msg);    // pass directly to C

    // Convert sentinel pointer → slice (length discovered at runtime)
    const slice = std.mem.span(msg);
    // slice is []const u8 with len == 12
}

Sentinel-terminated arrays are written [N:0]T and pointer-to-sentinel-array as *[N:0]T. Zig propagates the sentinel through slicing operations so correctness is maintained at compile time where possible.

C Interop Rule When calling C functions that take char *, use [*:0]u8 or [*:0]const u8. Zig automatically coerces string literals to this type. Prefer []const u8 everywhere inside pure Zig code and convert at the boundary with std.mem.span.

Optional Pointers ?*T

In Zig, pointers are non-nullable by default. A plain *T is guaranteed not to be null — the compiler enforces this. To express a possibly-absent pointer you use the optional wrapper: ?*T.

A crucial implementation detail: Zig compiles ?*T to exactly the same size as *T (a single pointer-width integer), using the all-zeros bit pattern to represent null. There is no extra tag byte.

zigconst Node = struct {
    value:  i32,
    next:   ?*Node,    // nullable — may be absent
};

fn findFirst(head: ?*Node, target: i32) ?*Node {
    var cursor = head;
    while (cursor) |node| {          // unwrap optional in while
        if (node.value == target) return node;
        cursor = node.next;
    }
    return null;
}

You can also use orelse for default values, orelse unreachable to assert non-null (crashing in debug), or if (ptr) |p| { ... } for conditional unwrapping.

Const vs Var Pointers

Every pointer type in Zig can be qualified with const, which controls mutability of the pointee (not the pointer variable itself). This orthogonal dimension applies to all pointer flavours:

zigvar x: u32 = 10;

const p1: *u32       = &x;    // mutable pointer to mutable u32
const p2: *const u32 = &x;    // mutable pointer to const u32 (read-only view)

p1.* = 20;      // ✓  allowed
p2.* = 30;      // ✗  compile error: cannot assign through const pointer

// Slice equivalents:
const rw: []u32       = ...;   // read + write elements
const ro: []const u32 = ...;   // read-only elements
Best Practice Default to const on pointer pointees whenever the function doesn't need to mutate the data. This enables the compiler to pass the pointer to functions that require *const T and communicates intent to readers.

Volatile Pointers

The volatile qualifier instructs the compiler that every load and store through this pointer has observable side effects that must not be reordered or elided. This is essential for memory-mapped I/O in embedded and kernel programming.

zig// MMIO register on a hypothetical microcontroller
const UART_TX: *volatile u32 = @ptrFromInt(0x4000_4400);

fn sendByte(byte: u8) void {
    // Without volatile, the compiler might eliminate this write
    // if it thinks nobody reads UART_TX. volatile prevents that.
    UART_TX.* = byte;
}

volatile is not a threading primitive — it does not imply any memory ordering or atomicity. For shared-state concurrency, use std.atomic operations instead.

Full Pointer Type Comparison

Type Points to Has length Arithmetic Nullable Typical use
*T Exactly one T Pass/mutate single values, struct fields
[*]T Array of T (unbounded) C interop, low-level allocator internals
[]T Array of T (bounded) General arrays, strings, buffers in Zig
[*:S]T Sentinel-terminated array ✗ (implicit) C strings, null-terminated buffers
?*T One T or null Optional single values, linked list nodes
?[]T Slice or null Optional buffers / nullable slices
*volatile T Exactly one T (no elision) MMIO registers, hardware control
*const T One T (read-only) Read-only function parameters
[]const T Array of T (read-only) Read-only slices, string arguments