Skip to content

Optional Pointers

An optional pointer is a pointer that may have no value.

An optional pointer is a pointer that may have no value.

In Zig, a normal pointer must point to something valid. It cannot be null.

var x: i32 = 10;
const p: *i32 = &x;

Here, p points to x.

This is not allowed:

const p: *i32 = null;

A normal pointer says:

I point to a valid i32.

But sometimes a pointer needs to represent absence. For that, Zig uses an optional pointer.

?*T

Read this as:

optional pointer to T

For example:

?*i32

means:

either a pointer to an i32, or null

Why Optional Pointers Exist

Many programs need to express “found” or “not found.”

Suppose we search for a number in a small array. If we find it, we want to return a pointer to the matching item. If we do not find it, we need to return “nothing.”

That is exactly what an optional pointer represents.

fn findFirst(values: []i32, target: i32) ?*i32 {
    for (values) |*value| {
        if (value.* == target) {
            return value;
        }
    }

    return null;
}

The return type is:

?*i32

That means the function may return a pointer to an i32, or it may return null.

The caller must check before using the pointer.

Null Is Explicit

In some languages, every pointer or object reference can be null. This causes many runtime errors because any access may fail unexpectedly.

Zig takes a stricter approach.

A normal pointer cannot be null:

*i32

An optional pointer can be null:

?*i32

This difference is visible in the type.

That means when you see this:

fn update(value: *i32) void

you know value is expected to point to a valid i32.

When you see this:

fn updateMaybe(value: ?*i32) void

you know the function must handle the possibility of null.

The type tells the truth.

Creating an Optional Pointer

You can assign a real pointer to an optional pointer:

var x: i32 = 10;
const maybe: ?*i32 = &x;

You can also assign null:

const maybe: ?*i32 = null;

Both are valid because maybe is optional.

A non-optional pointer cannot hold null.

var x: i32 = 10;

const good: ?*i32 = &x;
const empty: ?*i32 = null;

// const bad: *i32 = null; // error

This is the basic rule:

Use *T when the pointer must exist.

Use ?*T when the pointer may be absent.

Unwrapping an Optional Pointer

You cannot directly dereference an optional pointer.

This is not valid:

const maybe: ?*i32 = &x;

// maybe.* = 20; // error

Why?

Because maybe might be null.

You must unwrap it first.

The most common way is if:

if (maybe) |p| {
    p.* = 20;
}

Inside the if block, p is a normal pointer.

If maybe contains a pointer, the block runs.

If maybe is null, the block does not run.

Full example:

const std = @import("std");

pub fn main() void {
    var x: i32 = 10;
    const maybe: ?*i32 = &x;

    if (maybe) |p| {
        p.* = 20;
    }

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

Output:

x = 20

The optional pointer is checked before use.

Handling the Null Case

Often you need to handle both cases.

if (maybe) |p| {
    std.debug.print("value = {}\n", .{p.*});
} else {
    std.debug.print("no value\n", .{});
}

Complete program:

const std = @import("std");

pub fn main() void {
    const maybe: ?*i32 = null;

    if (maybe) |p| {
        std.debug.print("value = {}\n", .{p.*});
    } else {
        std.debug.print("no value\n", .{});
    }
}

Output:

no value

This is one of Zig’s strengths. The null case is not hidden. The code must say what happens.

Optional Pointer Search Example

Here is a complete search example:

const std = @import("std");

fn findFirst(values: []i32, target: i32) ?*i32 {
    for (values) |*value| {
        if (value.* == target) {
            return value;
        }
    }

    return null;
}

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

    const result = findFirst(numbers[0..], 30);

    if (result) |p| {
        p.* = 99;
    }

    std.debug.print("{any}\n", .{numbers});
}

Output:

{ 10, 20, 99, 40 }

The function returns a pointer to the matching element.

The caller modifies the element through that pointer.

Because the pointer points into the original array, the array changes.

Optional Pointer to Const

An optional pointer can also point to read-only data.

?*const T

For example:

?*const i32

means:

either a pointer to a read-only i32, or null

Example:

fn findFirst(values: []const i32, target: i32) ?*const i32 {
    for (values) |*value| {
        if (value.* == target) {
            return value;
        }
    }

    return null;
}

The function receives:

[]const i32

So it cannot modify the array.

It returns:

?*const i32

So the caller can read the found value, but cannot modify it.

const std = @import("std");

fn findFirst(values: []const i32, target: i32) ?*const i32 {
    for (values) |*value| {
        if (value.* == target) {
            return value;
        }
    }

    return null;
}

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

    const result = findFirst(numbers[0..], 30);

    if (result) |p| {
        std.debug.print("found = {}\n", .{p.*});
    }
}

Output:

found = 30

Use ?*const T when the pointer may be missing and the value should only be read.

Use ?*T when the pointer may be missing and the value may be modified.

Optional Pointer as a Field

Optional pointers are common in data structures.

For example, a linked list node may point to the next node. The last node has no next node.

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

The field:

next: ?*Node

means:

this node may point to another node, or it may be the last node

Example:

const std = @import("std");

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

pub fn main() void {
    var third = Node{ .value = 30, .next = null };
    var second = Node{ .value = 20, .next = &third };
    var first = Node{ .value = 10, .next = &second };

    var current: ?*Node = &first;

    while (current) |node| {
        std.debug.print("{}\n", .{node.value});
        current = node.next;
    }
}

Output:

10
20
30

The loop continues while current contains a pointer. It stops when current becomes null.

This is a natural use of optional pointers.

Optional Pointers and Function Parameters

A function parameter should use a normal pointer when the pointer is required.

fn reset(value: *i32) void {
    value.* = 0;
}

This function should not accept null. It needs a real i32.

Use an optional pointer only when absence is meaningful.

fn resetMaybe(value: ?*i32) void {
    if (value) |p| {
        p.* = 0;
    }
}

This function says:

If a value is provided, reset it. If not, do nothing.

Do not use optional pointers just because they seem flexible. They make every caller and every function body deal with null.

A stricter type is usually better.

Optional Pointers and Ownership

An optional pointer does not own memory.

This is still only a reference:

?*T

It means:

maybe points to T

It does not mean:

owns T

For example:

var x: i32 = 10;
const maybe: ?*i32 = &x;

The optional pointer points to x, but it does not own x. It must not outlive x.

The same lifetime rule applies:

A pointer is valid only while the memory it points to is valid.

Optionality does not change that.

Optional Pointer vs Optional Value

These two types are different:

?i32
?*i32

?i32 means:

either an i32 value, or null

?*i32 means:

either a pointer to an i32 somewhere else, or null

Example with optional value:

fn maybeNumber(ok: bool) ?i32 {
    if (ok) return 42;
    return null;
}

This returns the number itself.

Example with optional pointer:

fn maybePointer(ok: bool, value: *i32) ?*i32 {
    if (ok) return value;
    return null;
}

This returns a pointer to a number owned elsewhere.

Use an optional value when you want to return data directly.

Use an optional pointer when you want to refer to existing data.

Optional Pointer vs Empty Slice

Sometimes beginners use null when an empty slice would be better.

For sequences, prefer an empty slice when “no items” is a valid sequence.

[]const u8

can represent both:

some bytes
zero bytes

An empty slice has length 0.

const empty = bytes[0..0];

Use an optional slice only when there is a real difference between:

no slice was provided

and:

a slice was provided, but it is empty

The same idea applies to pointers.

Use null only when absence means something.

Optional Pointer Syntax Summary

SyntaxMeaning
*Tpointer to mutable T, cannot be null
*const Tpointer to read-only T, cannot be null
?*Tpointer to mutable T, or null
?*const Tpointer to read-only T, or null
nullno value
if (maybe) |p|unwrap optional pointer

Common Mistake: Dereferencing Before Checking

This is wrong:

const maybe: ?*i32 = null;

// maybe.* = 10; // error

You must check first:

if (maybe) |p| {
    p.* = 10;
}

This is not just syntax. It is a safety rule.

The program must prove that the pointer exists before using it.

Common Mistake: Using Optional Pointers Too Often

Optional pointers are useful, but they should not be the default.

This is weak API design:

fn draw(image: ?*Image) void

If draw cannot do anything useful without an image, then the parameter should be:

fn draw(image: *Image) void

Now the caller must provide a real image.

Use optional pointers only when null is a real, expected state.

The Main Idea

A normal pointer must point to a valid value.

An optional pointer may point to a valid value, or it may be null.

This is the difference:

*T   // must exist
?*T  // may be absent

Before using an optional pointer, unwrap it with if, orelse, or another optional-handling form.

Optional pointers make absence explicit. They help you write APIs where null is visible in the type instead of hidden as a runtime surprise.