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 →
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.
| Stack | Heap | |
|---|---|---|
| Owned by | One goroutine, one function frame | The whole process |
| Freed when | Function returns — pointer pops, instant | Later, by the garbage collector |
| Cost to allocate | Bump a pointer. Nearly free | Find space, track it, GC scans it later |
Shows in allocs/op | No | Yes — 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]
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.
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]
| Trigger | Why the compiler can't keep it on the stack |
|---|---|
| Return a pointer to a local | The pointer outlives the frame by definition |
| Store a pointer into a heap object, global, slice, map, or channel | The 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 escapes | The 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 stack | Would 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.
# -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]
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.
x live?func sum(a, b int) int {
x := a + b
return x // the value is copied out
}
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.
u live?func newUser(name string) *User {
u := User{Name: name}
return &u // address returned to caller
}
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.
v live?func logIt(v int) {
fmt.Println(v) // v is boxed into interface{}
}
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.
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
}
-m:
make([]int, 0, 4) escapes to heap.
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
}
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.
| Myth | Reality |
|---|---|
new(T) allocates on the heap | No — it may stay on the stack (Q5) |
| Pointers are heap, values are stack | No — a returned value can be heap (Q4); an addressed local can be stack (Q5) |
| Passing by pointer is always cheaper | Not if it forces an escape a value copy would have avoided |
| Heap is slow, so avoid all allocation | Heap is fine; per-request heap in a hot path is the problem. Measure with allocs/op |
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.
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.