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.
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:
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.
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:
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.
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.
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.