Stack vs heap memory in Zig
Where your data lives in Zig, how the stack and the heap differ, and the use-after-free bug that taught me why it matters.
Note: This is based on Zig 0.15.2. Zig is pre-1.0 and constantly changing.
In Zig you have to choose where your data lives: stack or heap. Do it wrong and you get use-after-free bugs that might not even crash your app, they just silently corrupt your program and have you debugging ghosts forever. No biggie. (This isn't specific to Zig btw, Zig actually makes it harder to mess up than C does, but it's no Rust...)
# Stack and heap
The stack is automatic. Declare a variable, it goes on the stack. Leave the scope, it's gone.
fn example() void {
var buffer: [1024]u8 = undefined; // Stack allocated
// ... use buffer ...
} // Automatically deallocated here
It's fast since it gets preallocated when the process starts, and if the OS can't allocate it the process doesn't start. Simple. That's also why it's limited in size.
The heap is manual. You allocate explicitly, you free explicitly.
fn example(alloc: Allocator) !void {
const buffer = try alloc.alloc(u8, 1024); // Heap allocated
defer alloc.free(buffer); // Must explicitly free
// ... use buffer ...
}
It's slower, although it's not like every allocation is a syscall (those happen per memory page). You allocate and deinit by hand, and the data can live beyond the scope of the function.
# The use-after-free bug
This is what bit me:
fn bad() []const u8 {
var buffer: [10]u8 = undefined; // Stack allocated
const slice = buffer[0..5];
return slice; // DANGER: slice points to stack memory
} // Stack deallocated, slice now points to garbage
It compiles, it runs, it even works (until it doesn't). The slice points to memory that's been deallocated, so the caller gets a slice pointing at garbage. Classic use-after-free bug/vulnerability.
The buffer has to outlive the slice:
fn caller() void {
var buffer: [10]u8 = undefined; // Buffer lives here
const slice = good(&buffer); // Slice points to buffer
// ... use slice ...
} // NOW buffer is deallocated (after we're done)
fn good(buffer: []u8) []const u8 {
return buffer[0..5]; // Safe: buffer lives in caller
}
Data must live at least as long as any reference to it. This is why I think Zig is nice, it makes you pass allocators explicitly, which kinda flags where the bytes are.
So roughly, stack for:
- Local variables, temporary data
- Small, fixed-size buffers
- Data that doesn't outlive the function
(more on how this works for TCP requests in the TigerBeetle example below)
And heap for:
- Long-lived data (persists beyond function scope)
- Dynamically sized data
- Large allocations
# Rust vs Zig here
Rust's borrow checker catches this at compile time:
Rust enforces lifetimes, so you can't return a reference to stack-allocated data. The compiler won't let you.
Zig compiles that code. It trusts you to manage lifetimes correctly, meaning you track by hand where the data was allocated, how long it should live and who owns the deinit, and if you get it wrong you have runtime bugs or exploitable vulnerabilities. The language is simple and gives you a lot of tools to not shoot yourself in the foot, but it doesn't take the gun away from you.
# No heap at all
TigerBeetle, a financial database written in Zig, takes this to the extreme: zero heap allocations at runtime.
Everything is stack-allocated because they know exactly how much memory they need upfront. With a single-threaded event loop and no concurrent operations to manage, plus very domain-specific message framing (128bits per message, 8k messages in a batch), they know beforehand that a payload is going to be around 1MB. When you can bound your memory usage at compile time like that, you can avoid the heap entirely, even when the data comes from user requests. No allocator overhead, no fragmentation, no use-after-free bugs from heap memory, and more performance (which matters a lot for a transactional database).
Most applications will need the heap tho. But it's a fascinating proof that you can build serious systems with just the stack if you design for it.