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 i32It does not mean:
a pointer to an i32, or maybe nothingWhen 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 nullThat 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 nullThis 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.*}); // errormaybe_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) voidThat 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); // errorThat 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 TFor example:
const maybe_name: ?*const User = null;Read it as:
maybe a pointer to a const UserThe ? applies to the pointer. The const applies to the value being pointed to.
So:
?*const Usermeans:
the pointer may be null, and if it is present, it points to a User that should not be modified through this pointerThis is different from:
*const Userwhich means:
a non-null pointer to a const UserNullable 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 elseUse ?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.
?*Tmeans:
a pointer to T, or nullA normal pointer:
*Tmeans:
a pointer to TThis 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.