Lesson 6 — Heap vs Stack & Escape Analysis

Where your bytes actually live, who decides, and how to read the compiler's mind · the layer underneath the OOM work
← Lesson 5 · Lesson 7: desk-vs-cabinet simulator → · Reference: escape cheatsheet →

The one win. By the end you can look at a function, predict whether each value lives on the stack (free) or the heap (GC-managed), and confirm it with go build -gcflags=-m. That skill is how you spot a per-request allocation that quietly pressures the GC — the thing that becomes an OOM at scale.

1 · The two places a value can live

 StackHeap
Owned byOne goroutine, one function frameThe whole process
Freed whenFunction returns — pointer pops, instantLater, by the garbage collector
Cost to allocateBump a pointer. Nearly freeFind space, track it, GC scans it later
Shows in allocs/opNoYes — this is what you profile

Each goroutine gets its own small stack (starts tiny, grows as needed). The heap is shared and collected. Stack allocation is always faster than heap allocation, no matter how good the GC is — because the decision is made at compile time and the memory is reclaimed for free when the frame pops.[2]

2 · Correctness doesn't care. You do.

Go FAQ: “From a correctness standpoint, you don't need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.”[1]

This is the mental unlock. In C, stack vs heap is your job and getting it wrong is a dangling pointer. In Go, the compiler guarantees safety either way — you can return &localVar and it just works. The location is a pure performance concern. The compiler picks it for you using escape analysis.

3 · The rule the compiler actually applies

The whole rule, in one sentence: a value goes on the stack if the compiler can prove it does not outlive the function that created it. If it can't prove that, the value escapes to the heap — because the alternative is a dangling pointer.

Note what the rule is not about. It is not about the new keyword. It is not about pointers vs values. It is not about structs vs primitives. It is purely about proven lifetime. Taking an address (&x) only makes a value a candidate for the heap — escape analysis then tries to prove it can stay on the stack anyway.[1]

What forces a value to escape

TriggerWhy the compiler can't keep it on the stack
Return a pointer to a localThe pointer outlives the frame by definition
Store a pointer into a heap object, global, slice, map, or channelThe container may outlive the frame; the compiler must assume it does
Pass a value into an interface{} (e.g. fmt.Println)Dynamic dispatch / reflection hides the lifetime; compiler can't prove it doesn't leak
Captured by a closure or goroutine that escapesThe captured variable must live as long as the closure does
Size unknown at compile time (e.g. make with a runtime length, growing append)The stack frame has a fixed size; a runtime-sized allocation can't fit predictably
Value too large for the stackWould blow the frame; heap is used instead

Every one of these is why the OOM in lesson 3 happened: reading a whole file into []byte is a runtime-sized, returned allocation. It must live on the heap, and its size is the size of the file. Stream instead and no single frame ever holds the whole thing.

4 · The tool: make the compiler show its work

# -m prints escape-analysis + inlining decisions. Two -m for more detail.
go build -gcflags=-m ./...
go build -gcflags='-m -m' ./path   # louder; beyond 2 it's overwhelming

You'll see lines like moved to heap: u, &u escapes to heap, or the good news: u does not escape. This flag is the ground truth — not your intuition, not this lesson. Verify against it (the lesson 5 discipline).[2]

5 · Practice — predict the allocation

For each snippet, decide where the marked value lives before you reveal. This is retrieval practice: the guess is the point, even when it's wrong. Say it out loud, then click.

Q1 — where does x live?
func sum(a, b int) int {
    x := a + b
    return x        // the value is copied out
}
STACK
x is a plain int, its address is never taken, and only its value leaves the function. Nothing outlives the frame. No allocation at all.
Q2 — where does u live?
func newUser(name string) *User {
    u := User{Name: name}
    return &u       // address returned to caller
}
HEAP
The returned pointer outlives the frame, so u must escape or the caller would hold a dangling pointer. -m prints moved to heap: u. This is the normal, correct pattern for constructors — heap here is fine, not a bug.
Q3 — where does v live?
func logIt(v int) {
    fmt.Println(v)  // v is boxed into interface{}
}
HEAP
The surprising one. Println takes ...interface{}. Boxing v into an interface plus reflection means the compiler can't prove it doesn't leak, so -m reports v escapes to heap. This is why fmt logging in a hot loop shows up in allocs/op.
Q4 — where does the backing array of s live?
func build(n int) []int {
    s := make([]int, 0, 4)
    for i := 0; i < n; i++ {
        s = append(s, i)   // grows past 4, n unknown
    }
    return s
}
HEAP
Two independent reasons: the slice is returned (outlives the frame), and it grows to a runtime-unknown size. Either alone forces the heap. -m: make([]int, 0, 4) escapes to heap.
Q5 — where does the int that p points to live?
func compute() int {
    p := new(int)   // 'new' — surely heap?
    *p = 42
    return *p       // only the value leaves; p never does
}
STACK
Kills the biggest myth: new does not mean heap. The pointer p never leaves the frame, so escape analysis keeps the value on the stack. -m prints new(int) does not escape. Allocation decisions are about lifetime, never about which keyword you typed.

6 · Myths to unlearn

MythReality
new(T) allocates on the heapNo — it may stay on the stack (Q5)
Pointers are heap, values are stackNo — a returned value can be heap (Q4); an addressed local can be stack (Q5)
Passing by pointer is always cheaperNot if it forces an escape a value copy would have avoided
Heap is slow, so avoid all allocationHeap is fine; per-request heap in a hot path is the problem. Measure with allocs/op

Primary source

Read Bill Kennedy, “Language Mechanics On Escape Analysis” (Ardan Labs) — the clearest deep-dive, walking real -m output line by line: ardanlabs.com/blog/2017/05/language-mechanics-on-escape-analysis.html. Then skim the Go FAQ answer[1] for the official framing.

I'm your teacher — ask me followups. Good next questions: “Why did this line in my handler escape?”, “Does sync.Pool help here?”, “How do I read -gcflags='-m -m'?”, or “Show me the escape in the media service download path.” Paste real -m output and we'll decode it together.