A dangling pointer is a pointer that refers to memory that is no longer valid.
The pointer still contains an address, but the value at that address no longer belongs to the thing the pointer thinks it points to.
This is one of the most serious bugs in low-level programming.
A dangling pointer can cause wrong results, crashes, memory corruption, or security problems.
A Simple Dangling Pointer
This function is wrong:
fn badPointer() *i32 {
var x: i32 = 123;
return &x;
}The variable x is local to badPointer.
It lives only while badPointer is running.
When the function returns, x is gone.
So this return value is invalid:
return &x;The function returns the address of dead stack memory.
The caller receives a pointer, but that pointer no longer points to a valid i32.
Why This Is Dangerous
Memory may still contain the old bytes for a short time.
That makes dangling pointer bugs confusing.
For example, this may appear to work sometimes:
const p = badPointer();
std.debug.print("{}\n", .{p.*});You might see:
123But that does not mean the code is correct.
The stack memory used by x may not have been overwritten yet. Later, another function call may reuse the same stack space. Then the pointer may read a different value.
A dangling pointer is invalid even if it appears to work once.
Returning a Pointer to Caller-Owned Memory
This version is valid:
fn chooseFirst(a: *i32, b: *i32) *i32 {
_ = b;
return a;
}
pub fn main() void {
var x: i32 = 10;
var y: i32 = 20;
const p = chooseFirst(&x, &y);
p.* = 99;
_ = y;
}The function returns a, but a points to x, which lives in main.
When chooseFirst returns, x still exists.
So the returned pointer is valid.
The important question is not “did this pointer come from a function?”
The important question is:
Does the memory behind the pointer still exist?
Dangling Slices
Slices can dangle too.
A slice is a pointer plus a length. If the pointer inside the slice becomes invalid, the whole slice is invalid.
This is wrong:
fn badSlice() []u8 {
var data = [_]u8{ 1, 2, 3 };
return data[0..];
}The array data is local to badSlice.
The returned slice points into data.
When badSlice returns, data is gone.
The slice now points to invalid stack memory.
A slice is not safe just because it has a length. The length only says how many items are visible. It does not make the memory live longer.
Returning a Slice to Caller-Owned Memory
This version is valid:
fn firstTwo(buffer: []u8) []u8 {
return buffer[0..2];
}
pub fn main() void {
var data = [_]u8{ 10, 20, 30, 40 };
const result = firstTwo(data[0..]);
_ = result;
}The function returns a slice into buffer.
The original memory is owned by main.
After firstTwo returns, data still exists.
So result is valid while data is valid.
This is a common Zig pattern: the caller provides memory, and the function returns a view into that memory.
Dangling Heap Pointers
Dangling pointers are not only about stack memory.
They can also happen with heap memory.
This is wrong:
const buffer = try allocator.alloc(u8, 16);
allocator.free(buffer);
buffer[0] = 42;After this line:
allocator.free(buffer);the memory no longer belongs to your program.
The slice buffer still exists as a value, but it points to freed memory.
Using it after free is a use-after-free bug.
Use-After-Free
A use-after-free bug happens when code uses memory after releasing it.
Example:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const buffer = try allocator.alloc(u8, 4);
allocator.free(buffer);
buffer[0] = 1; // wrong
}The bug is here:
buffer[0] = 1;The memory has already been freed.
The pointer or slice was not erased automatically. It still contains an address. But the address is no longer valid for use.
Double Free
Another related bug is freeing the same memory twice.
const buffer = try allocator.alloc(u8, 16);
allocator.free(buffer);
allocator.free(buffer); // wrongAfter the first free, ownership has ended.
The second free tries to release memory that is no longer owned.
That can corrupt allocator metadata or crash the program.
The rule is:
Free heap memory exactly once.
Ownership Prevents Dangling Pointers
Dangling pointer bugs often come from unclear ownership.
When you see a pointer or slice, ask:
Who owns this memory?
How long does it live?
Who is allowed to free it?
Can this pointer outlive the owner?
Example:
fn fill(buffer: []u8) void {
for (buffer) |*byte| {
byte.* = 0;
}
}This function does not own buffer.
It only borrows it temporarily.
It should not free it.
It should not store the slice somewhere that outlives the caller.
The caller owns the memory.
Borrowing Must Be Temporary
When a function receives a pointer or slice, it usually borrows memory.
fn increment(value: *i32) void {
value.* += 1;
}This function borrows an i32 and modifies it.
The borrow is only needed during the function call.
This is valid:
pub fn main() void {
var x: i32 = 10;
increment(&x);
}The pointer does not escape. It is only used during the call.
This is much safer than storing the pointer for later use.
Storing Pointers Can Be Risky
A struct can store a pointer:
const Holder = struct {
value: *i32,
};This is valid, but now the struct depends on memory owned somewhere else.
Example:
pub fn main() void {
var x: i32 = 10;
const holder = Holder{ .value = &x };
holder.value.* = 20;
}This is fine because holder and x are both used inside main.
But this would be dangerous if holder escaped while x died.
When a struct stores a pointer, its lifetime depends on the lifetime of the pointed-to memory.
That relationship is not automatically enforced by Zig in all cases. You must design the API carefully.
A Dangerous Global Pointer
Global or long-lived pointers are especially risky.
var saved: ?*i32 = null;
fn savePointer(p: *i32) void {
saved = p;
}Now consider:
fn bad() void {
var x: i32 = 10;
savePointer(&x);
}After bad returns, saved points to dead stack memory.
The pointer escaped from the function.
This is a common source of dangling pointers.
A useful rule:
Do not store a pointer unless you know the pointed-to memory will live at least as long as the stored pointer.
Safer Pattern: Store Values Instead of Pointers
Sometimes the simplest fix is to store a copy of the value.
Instead of:
const Holder = struct {
value: *i32,
};use:
const Holder = struct {
value: i32,
};Now the struct owns its own value.
It does not depend on external memory.
This is not always possible or efficient, but it is often the simplest design.
Use pointers when you need sharing, mutation, large values, or external memory.
Use values when ownership should be simple.
Safer Pattern: Caller Provides Storage
Instead of returning a pointer to local memory, let the caller provide the memory.
Wrong:
fn makeNumber() *i32 {
var x: i32 = 42;
return &x;
}Better:
fn writeNumber(out: *i32) void {
out.* = 42;
}Use it like this:
pub fn main() void {
var x: i32 = undefined;
writeNumber(&x);
_ = x;
}The caller owns x.
The function only writes into it.
No dangling pointer is created.
Safer Pattern: Allocate and Return Ownership
If the data must outlive the function, allocate it and make ownership clear.
fn makeNumber(allocator: std.mem.Allocator) !*i32 {
const p = try allocator.create(i32);
p.* = 42;
return p;
}The caller must later destroy it:
const p = try makeNumber(allocator);
defer allocator.destroy(p);Full example:
const std = @import("std");
fn makeNumber(allocator: std.mem.Allocator) !*i32 {
const p = try allocator.create(i32);
p.* = 42;
return p;
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const p = try makeNumber(allocator);
defer allocator.destroy(p);
std.debug.print("{}\n", .{p.*});
}The pointer is valid because the memory comes from the heap.
The ownership rule is also clear: the caller receives the pointer and must destroy it.
Safer Pattern: Return a Value
If the value is small, just return it.
fn makeNumber() i32 {
return 42;
}This is simpler than returning a pointer.
Use the simplest ownership model that works.
For small values, returning by value is usually better.
Null Does Not Fix Dangling Pointers
Optional pointers are useful, but they do not automatically prevent dangling pointers.
This can still be wrong:
var saved: ?*i32 = null;
fn bad() void {
var x: i32 = 10;
saved = &x;
}The pointer is optional, but when it is not null, it may still point to dead memory.
null means “no pointer.”
It does not mean “safe pointer.”
You still need correct lifetime rules.
Setting a Pointer to Null After Free
Sometimes after freeing memory, code sets an optional pointer to null.
var maybe_buffer: ?[]u8 = try allocator.alloc(u8, 16);
if (maybe_buffer) |buffer| {
allocator.free(buffer);
maybe_buffer = null;
}This can help avoid accidental reuse through that optional variable.
But it only fixes that one variable.
Any other pointer or slice to the same memory would still be dangling.
So this is a helpful habit in some designs, but it does not replace ownership discipline.
Common Mistake: Returning Views Into Temporary Buffers
This is a common bug in text processing:
fn makeMessage() []const u8 {
var buffer: [64]u8 = undefined;
const message = buffer[0..5];
return message;
}The returned slice points into buffer, which dies at the end of the function.
Better options:
Return a string literal if the text is static:
fn makeMessage() []const u8 {
return "hello";
}Let the caller provide the buffer:
fn writeMessage(buffer: []u8) []u8 {
buffer[0] = 'h';
buffer[1] = 'e';
buffer[2] = 'l';
buffer[3] = 'l';
buffer[4] = 'o';
return buffer[0..5];
}Or allocate:
fn makeMessage(allocator: std.mem.Allocator) ![]u8 {
const message = try allocator.alloc(u8, 5);
@memcpy(message, "hello");
return message;
}In the allocation version, the caller must free the returned slice.
Common Mistake: Keeping a Slice After Its Owner Changes
Some containers may reallocate their internal memory.
For example, a dynamic array can move its buffer when it grows.
Conceptually:
const old_items = list.items;
try list.append(123);
// old_items may no longer be validIf append causes the list to allocate a new larger buffer, the old slice may point to freed memory.
The exact details depend on the container, but the general rule matters:
Do not keep pointers or slices into a container across operations that may reallocate or invalidate them.
When using a container, read its API carefully.
A Checklist for Pointer Lifetime
Before returning or storing a pointer, check these questions:
Does the pointed-to memory live long enough?
Is the memory stack memory that will disappear soon?
Is the memory heap memory that might be freed?
Could a container reallocate and move the memory?
Who owns the memory?
Who is responsible for cleanup?
Could two parts of the program free the same memory?
These questions prevent many bugs.
The Main Idea
A dangling pointer points to memory that is no longer valid.
It can come from returning a pointer to a local variable, returning a slice to a local array, using heap memory after freeing it, freeing the same memory twice, or keeping a pointer into a container after the container moves its storage.
The core rule is:
A pointer or slice must not outlive the memory it refers to.
When in doubt, make ownership explicit. Return values for small data. Let callers provide buffers. Allocate only when necessary, and make the caller’s cleanup responsibility clear.