Posted on ::

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 crash your app, just they just silently corrupt your program and make you debug ghosts for ever. No biggy. (This is not intrinsic to Zig btw, Zig actually makes it easier for this not to happen compared to C, but it's no Rust...)

Stack memory

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, if OS can allocate it, process doesn't start. Simple. That's why it is also limited in size.

Heap memory

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 is slower, it's not like every allocation is a syscall, since those calls are memory page based. You need to allocate and de init manually, and it can leave beyond the scope of the function.

The use-after-free bug

Here's 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

This compiles, it runs, it even works (until it doesn't). But the slice points to memory that's been deallocated. The caller gets a slice pointing to garbage. This is a classic Use-after-free bug/vulnerability.

The buffer must 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
}

The key rule: data must live at least as long as any references to it. This is why I think Zig is nice. It explicitly makes you pass allocators to kinda flag where are the bytes.

When to use each

Stack:

  • Local variables, temporary data
  • Small, fixed-size buffers
  • Data that doesn't outlive the function

More about how this can be used for TCP request on tigerbeetle example.

Heap:

  • Long-lived data (persists beyond function scope)
  • Dynamically sized data
  • Large allocations

Why Rust doesn't have this problem

Rust's borrow checker catches this at compile time:

fn bad() -> &str {
    let s = String::from("hello");
    &s  // Compiler error: s is dropped, can't return reference
}

Rust enforces lifetimes. You can't return a reference to stack-allocated data. The compiler won't let you.

Zig reality

Zig compiles this code. It trusts you to manage lifetimes correctly.

You have to track manually where the data was allocated, how long should it leave and who owns the deinit.

Do it wrong and you have runtime bugs or exploitable vulnerabilities.

The language is simple and gives you a loot of tools to not shoot yourself in the foot, the it does not take the gun away from you.

The extreme: 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 thread event loop and no concurrent operations to manage, along with a very domain specific message framing (128bits per message, and 8k messages in a batch) they are able to know before hand that their payload is going to be around 1MB.

When you can bound your memory usage at compile time, you can avoid the heap entirely, even if your data comes from user request. No allocator overhead, no fragmentation, no use-after-free bugs from heap memory and more performance (which is crucial for a transactional database)

For most applications you will need the heap. But it's a fascinating proof that you can build serious systems with just the stack if you design for it.

Table of Contents