Skip to content

Nullable Pointers

A pointer stores the address of a value in memory.

A pointer stores the address of a value in memory.

In Zig, a normal pointer is expected to point to something valid. It is not supposed to be null.

const ptr: *i32 = &value;

The type *i32 means:

a pointer to an i32

It does not mean:

a pointer to an i32, or maybe nothing

When a pointer may be missing, you must write that in the type:

const maybe_ptr: ?*i32 = null;

The type ?*i32 means:

either a pointer to an i32, or null

That is a nullable pointer.

Why Zig Separates Pointers from Nullable Pointers

In C, a pointer can usually be NULL.

int *ptr = NULL;

That flexibility is convenient, but it also creates a common bug: code receives a pointer and forgets to check whether it is null.

Zig makes the difference visible.

*i32   // must point to an i32
?*i32  // may point to an i32, or may be null

This matters because the type tells the truth.

If a function receives *User, the caller must provide a real pointer.

If a function receives ?*User, the caller may provide null, and the function must handle it.

A Simple Nullable Pointer

Here is a small example:

const std = @import("std");

pub fn main() void {
    var number: i32 = 42;

    const maybe_ptr: ?*i32 = &number;

    if (maybe_ptr) |ptr| {
        std.debug.print("value: {}\n", .{ptr.*});
    } else {
        std.debug.print("no pointer\n", .{});
    }
}

This line creates a normal integer:

var number: i32 = 42;

This line creates an optional pointer to that integer:

const maybe_ptr: ?*i32 = &number;

The value is not null. It contains the address of number.

Before using it, we unwrap it:

if (maybe_ptr) |ptr| {
    std.debug.print("value: {}\n", .{ptr.*});
}

Inside the block, ptr is a normal *i32.

To read the value pointed to by ptr, use:

ptr.*

This is called dereferencing.

The Null Case

A nullable pointer can also contain null.

const std = @import("std");

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

    if (maybe_ptr) |ptr| {
        std.debug.print("value: {}\n", .{ptr.*});
    } else {
        std.debug.print("no pointer\n", .{});
    }
}

In this program, the else block runs.

The important part is that Zig does not allow this:

const maybe_ptr: ?*i32 = null;
std.debug.print("value: {}\n", .{maybe_ptr.*}); // error

maybe_ptr is optional. It might be null. You must unwrap it before dereferencing it.

Nullable Pointer Parameters

Nullable pointers are often used in function parameters.

Suppose a function can optionally receive a logger:

const std = @import("std");

const Logger = struct {
    prefix: []const u8,

    fn log(self: *Logger, message: []const u8) void {
        std.debug.print("{s}: {s}\n", .{ self.prefix, message });
    }
};

fn runTask(logger: ?*Logger) void {
    if (logger) |log| {
        log.log("starting task");
    }

    // task work here

    if (logger) |log| {
        log.log("finished task");
    }
}

pub fn main() void {
    var logger = Logger{ .prefix = "app" };

    runTask(&logger);
    runTask(null);
}

The function type says the logger is optional:

fn runTask(logger: ?*Logger) void

That is clearer than passing a fake logger, a special flag, or an invalid pointer.

Normal Pointers Are Non-Null

A normal pointer should not be used for a missing value.

fn printNumber(ptr: *const i32) void {
    std.debug.print("{}\n", .{ptr.*});
}

This function expects a real pointer.

Calling it with null is not allowed:

printNumber(null); // error

That is useful. The compiler protects the function from a whole class of null pointer bugs.

If the function should accept no pointer, say so explicitly:

fn printNumber(ptr: ?*const i32) void {
    if (ptr) |p| {
        std.debug.print("{}\n", .{p.*});
    } else {
        std.debug.print("no number\n", .{});
    }
}

The type tells callers what is allowed.

Optional Pointer to Const

You will often see this form:

?*const T

For example:

const maybe_name: ?*const User = null;

Read it as:

maybe a pointer to a const User

The ? applies to the pointer. The const applies to the value being pointed to.

So:

?*const User

means:

the pointer may be null, and if it is present, it points to a User that should not be modified through this pointer

This is different from:

*const User

which means:

a non-null pointer to a const User

Nullable Pointers and Mutation

If the pointer is present and points to mutable data, you can change the value through it.

const std = @import("std");

fn increment(ptr: ?*i32) void {
    if (ptr) |p| {
        p.* += 1;
    }
}

pub fn main() void {
    var count: i32 = 10;

    increment(&count);
    increment(null);

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

After increment(&count), count becomes 11.

After increment(null), nothing happens.

Inside the function, this line unwraps the optional pointer:

if (ptr) |p| {

Then this line modifies the original integer:

p.* += 1;

Nullable Pointers vs Optional Values

These two types are different:

?i32
?*i32

?i32 means:

maybe an i32 value

?*i32 means:

maybe a pointer to an i32 value somewhere else

Use ?i32 when the optional value itself is small and easy to copy.

Use ?*T when you need to refer to an existing object, avoid copying, or allow mutation through the pointer.

Example:

const maybe_age: ?u8 = 30;

This stores the value directly.

const maybe_user: ?*User = &user;

This stores an address pointing to an existing User.

Nullable Pointers and C Interop

Nullable pointers are especially important when working with C libraries.

Many C APIs use null pointers to mean “not provided,” “not found,” or “end of list.”

In Zig, the binding should express that.

A C function might look like this:

User *find_user(int id);

If it can return NULL, the Zig type should be treated as optional:

const user: ?*User = find_user(42);

Then Zig forces you to check:

if (user) |u| {
    // use u
} else {
    // not found
}

This is one of Zig’s strengths. It can work with C, but it can still make nullability explicit in Zig code.

Do Not Use .? Carelessly

You can force unwrap a nullable pointer with .?.

const ptr = maybe_ptr.?;
ptr.* = 123;

This means:

If maybe_ptr is present, use it.
If maybe_ptr is null, panic.

This is acceptable when null would mean a programming bug.

For example, after a previous check:

if (maybe_ptr == null) {
    unreachable;
}

const ptr = maybe_ptr.?;

But it should not be the default habit.

This is risky:

fn printUser(user: ?*User) void {
    std.debug.print("{s}\n", .{user.?.name});
}

If user can be null in normal use, this function can crash. Write the null case instead.

fn printUser(user: ?*User) void {
    if (user) |u| {
        std.debug.print("{s}\n", .{u.name});
    } else {
        std.debug.print("no user\n", .{});
    }
}

A Practical Pattern: Optional Parent Pointer

Nullable pointers are common in linked data structures.

For example, a tree node may have a parent, but the root node has no parent.

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

A child node can point to its parent:

var root = Node{
    .value = 1,
    .parent = null,
};

var child = Node{
    .value = 2,
    .parent = &root,
};

The root has no parent, so it uses null.

The child has a parent, so it stores &root.

When walking upward through the tree, unwrap the pointer:

if (child.parent) |parent| {
    std.debug.print("parent value: {}\n", .{parent.value});
}

This is a natural use of nullable pointers because absence is part of the data model.

The Main Idea

A nullable pointer is an optional pointer.

?*T

means:

a pointer to T, or null

A normal pointer:

*T

means:

a pointer to T

This distinction is important. Zig does not make every pointer nullable by default. If a pointer may be missing, the type must say so.

That gives you clearer APIs, fewer null pointer bugs, and code that states its assumptions directly.