Odin · stack & heap

Where a value lives, four levels deep

Every value sits in one of two regions, and the region decides how long it lives. A local lives in the current call's frame — it appears when the proc is entered and vanishes the instant the proc returns. A value from new(T) lives in a separate pool whose lifetime you control; it stays until you hand it back. Pick a moment and watch which storage is alive.

Real addresses, captured from a compiled Odin program. The exact numbers differ every run, so one representative run is shown; the comparisons (reused / not-equal / far-apart) are deterministic and verified.
the current call frame
a local — appears on entry, gone on return
the heap pool
new(T) — stays until you free it
alive in the frame alive on the heap reclaimed / reused

Two comparisons (verified)

stack slot reused
across two calls  = true

two new(int) give
the same address = false

same storage    = false
far apart (>1MB) = true

What you're seeing

Level 1 was where things live. Level 2 is why you'd ever want the heap: some data has to outlive the procedure that made it. The stack can't do that — its values die at the closing brace. The heap is the only region where a value can survive the return.

A make_counter proc allocates a Counter and returns it. The only question is where the Counter lives — in the proc's frame, or on the heap. One choice means the caller gets something real; the other means the caller gets a handle to storage that no longer exists. Step through both:

make_counter :: proc() -> ^Counter (new)

...returning &local instead (the frame)

The mechanism: c := new(Counter) puts the Counter on the heap, then returns the ^Counter that names it. When the frame pops, the pointer variable is gone but the Counter it pointed at is not — it lives in the heap, untouched by the pop. So the caller reads it back, and can still write through the pointer after the maker returned: the survived value isn't a frozen copy, it's live storage you now own.

The counterfactual: if the value never needs to escape — you compute it, use it, and you're done before the proc returns — don't reach for the heap. A plain local is faster (no allocation, no free) and cleans itself up for free. You allocate precisely when the data must outlive the scope that created it: returned to a caller, stored somewhere longer-lived, or handed off. No outliving needed, no heap needed.

Why the alternatives are worse

return the address of a local — point into a frame that's about to vanish
local := 7
return &local
frame pops ✕
caller holds → ?
the address survives; the storage it names does not. a dangling pointer.

Keep it on the stack and return its address — the value dies at the closing brace, so the caller is left holding an address into reclaimed storage. Odin catches this exact shape at compile time (Level 3), because it's never what you want.

Return the value by copy (proc() -> Counter, no pointer) — this is fine when the caller only needs the contents, but it hands back a snapshot: a fresh, independent Counter. If two parts of the program must see and mutate the same Counter, copies give each its own — the writes never meet. When you need one shared, long-lived object, the copy is the wrong tool.

Make the value a package-level global so it never goes out of scope — now its lifetime is the whole program, there's exactly one of it forever, and every proc can touch it. That's not "long-lived," it's "eternal and singular." The heap gives you many, each living exactly as long as you decide — which is the actual requirement.

Level 2 sold you on the heap. Level 3 is the bill: the lifetime is now yours. The stack freed itself; the heap won't. Odin has no garbage collector — every new is a promise to free, and every make a promise to delete.

The idiom that keeps the promise: defer. Write the hand-back on the line right after the ask, and defer runs it at scope exit — so the two read as a pair and the free can't get lost in a long body:

ask and hand-back, paired
c := new(Counter) // ask
defer free(c) // hand back at scope exit
c.n = 42 // ...use it for the whole scope...

buf := make([]int, 4) // ask (slice-backed storage)
defer delete(buf) // hand back at scope exit
Verified output of that scope: counter (heap, will be freed on exit): 42 · slice (heap, will be deleted on exit): 0 1 4 9 · reached end of scope; both defers fire now

Forget the hand-back, and nothing tells you. Drop the free and the program still runs and prints correctly — the OS reclaims the whole address space when the process exits, so a leak is silent:

c := new(Counter)
c.n = 99
fmt.println("ran fine, n =", c.n)   // prints: ran fine, n = 99
// ...never freed. no crash, no warning. leaked until the process exits.

That silence is the danger. Run that allocation once per frame for ten hours and the process bloats with nothing pointing at the culprit. (Lesson 08's tracking allocator is what turns this silence into a file:line report.)

The sharper bill — a lifetime that ends too early. A leak wastes memory; the opposite mistake corrupts it. The classic is the dangling pointer into a popped frame: a local's storage is reclaimed the moment its proc returns, so any pointer that escaped it now names storage that belongs to someone else. Odin guards the most obvious shape — returning &local directly — at compile time:

broken :: proc() -> ^int {
    local := 7
    return &local   // address of a local — its frame is about to pop
}

Error: It is unsafe to return the address of a local variable ('&local')
from a procedure, as it uses the current stack frame's memory

But that guard catches one shape, not lifetimes in general. Let the address escape indirectly — written through an out-pointer — and it compiles with no error and no warning:

escape :: proc(out: ^^int) {
    local := 7
    out^ = &local   // the address escapes upward; the frame still pops
}                   // builds clean — the targeted check doesn't see this

After escape(&p) returns, p points into a frame that's gone — a dangling pointer the language let through. The heap exists precisely so escaping data has somewhere to live that survives the pop; reach for new, not a clever way to keep a local's address.

a pointer carries no "stack or heap" flag An address is just a number — there's no bit on it saying which region it points at, and no way to ask whether the thing at the other end is still alive. The compiler's escape check is a targeted static guard on one syntactic shape, not a runtime tag and not a general lifetime tracker. Whether a pointer still names live storage is knowledge you have to keep — which is the whole reason "the lifetime is yours" is the bill, and why defer free pays it on time.

The last level isn't a new mechanism — it's what "survives the return" turns into once a program has depth. The stack's discipline and the heap's freedom compose into the way data and ownership move through a whole codebase.

The emergent payoff — data flows up, lifetime stays pinned. Because a heap value is untouched by frame pops, a deep proc can build something and hand it all the way up the call chain: make_counter returns to its caller, which returns to its caller, and the Counter rides along intact the whole way. The stack is the LIFO scaffold that calls rise and fall on; the heap is the off-scaffold storage that doesn't fall with them. Every "build it here, use it there" — a parser returning a syntax tree, a loader returning loaded assets, a frame returning its command list — is exactly this shape, and it only works because the heap value's lifetime is decoupled from the frame that created it.

And ownership stays legible. The flip side of "the lifetime is yours" is that you can put the free wherever the lifetime actually ends — and defer lets you write it next to the allocation while it still runs at the right scope exit. So even as values flow far from where they were born, the question "who frees this, and when?" stays answerable by reading one scope, not by tracing the value across the program. The discipline is local even when the data isn't.

the frontier this opens onto The heap's freedom costs a per-allocation tax (bookkeeping, and a free you owe) and the dangling/leak footguns above. So the next lessons chase "heap flexibility at stack-like cost": lesson 08's context lets you swap which allocator new and make use for a whole region without touching a single call site, and lesson 09's arena hands back a big run of allocations in one stroke — a free that's as cheap as a frame pop. This level is the floor they're built on.

That's the arc: L1 two regions, two lifetimes — a local dies at the return, a new(T) value persists in a separate pool → L2 that persistence is the whole point: data that must outlive its maker has to be on the heap → L3 the bill is that the lifetime is yours — defer free to pay it, or leak silently / dangle when it ends too early → L4 "survives the return" composes into data flowing up the call chain with ownership you can still read off one scope.

probes reproduce with odin run · addresses, comparisons, outputs & the L3 error are real compiler output (claims/lessons/07d-stack-and-heap)