📖 Analogy
Imagine a workshop where every tool you grab during a job is supposed to go back in the drawer the moment the job ends. The foreman watches each tool and asks one question: “Will anyone still need this after the job is over?” If the answer is provably no, the tool stays at your bench (the stack) and is cleared instantly when you finish. If the tool might be handed to someone else, mailed out, or stored for later, it can’t be cleared with your bench — it goes to the shared storeroom (the heap) for the janitor to reclaim eventually. That foreman is escape analysis, and the question is always the same: does this value outlive its function?
The compiler’s lifetime question
Go gives you no control over stack vs heap — and that’s deliberate. At compile time, for every value the compiler performs escape analysis: it tries to prove the value cannot be referenced after its function returns. If the proof succeeds, the value lives on the stack and costs nothing to reclaim. If the compiler can’t prove it, the value escapes to the heap and becomes the garbage collector’s problem.
graph TD
V["value created in func f"] --> Q{"can the compiler prove<br/>no reference survives f?"}
Q -->|"proven safe"| S["stack — free to reclaim"]
Q -->|"cannot prove / does survive"| H["heap — GC reclaims later"]Note the asymmetry: escape analysis is conservative. When in doubt, it heap-allocates. A value escaping doesn’t mean it will be used later — only that the compiler couldn’t rule it out.
Seeing the decisions: -gcflags=-m
The decisions are invisible at runtime, but the compiler will tell you at build time. Pass -m and it prints what stays and what escapes:
$ go build -gcflags=-m ./...
# command-line-arguments
./main.go:8:9: &User{...} escapes to heap
./main.go:14:13: ... argument does not escape
./main.go:14:14: u escapes to heap
./main.go:20:6: moved to heap: buf
Read it like this:
escapes to heap/moved to heap— this value is heap-allocated.does not escape— it stayed on the stack. Good.- Add a second
-m(-gcflags='-m -m') for the compiler’s reasoning — which assignment or call forced the escape.
A common surprise: fmt.Println(x) makes x escape, because Println takes ...interface{} and the compiler must take x’s address to box it into an interface.
The patterns that escape
A handful of constructs reliably push a value to the heap. This runnable program exercises them, then uses MemStats to confirm the heap traffic is real — even though the escape decision itself happened at compile time:
package main
import (
"fmt"
"runtime"
)
type Big struct{ data [64]int }
// 1. Returning a pointer to a local → escapes.
func makePtr() *Big { return &Big{} }
// 2. Stack-only: created, used, discarded. Does NOT escape.
func sumLocal() int {
var b Big // stays on the stack
for i := range b.data {
b.data[i] = i
}
s := 0
for _, v := range b.data {
s += v
}
return s
}
func mallocs() uint64 {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return m.Mallocs
}
func main() {
// Stack path: 100k calls, near-zero heap growth.
a := mallocs()
total := 0
for i := 0; i < 100_000; i++ {
total += sumLocal()
}
b := mallocs()
fmt.Printf("sumLocal: ~%d heap objects (stays on stack)\n", b-a)
// Heap path: each makePtr allocates.
c := mallocs()
keep := make([]*Big, 0, 100_000)
for i := 0; i < 100_000; i++ {
keep = append(keep, makePtr())
}
d := mallocs()
fmt.Printf("makePtr: ~%d heap objects (escaped)\n", d-c)
_ = total
_ = keep
}
The first loop barely moves Mallocs; the second adds ~100,000. Same-looking code, opposite allocation behavior — and -gcflags=-m would have told you which is which before you ran it.
Common escape triggers
| Pattern | Why it escapes |
|---|---|
return &local | Pointer outlives the frame |
Assign to an interface{} | Interface holds a pointer; value must persist |
| Capture in a closure that escapes | Closure may run after the call returns |
| Send on a channel | Receiver may read it after the sender returns |
| Store in a heap object (slice of pointers, map, struct field reached via pointer) | Reachable from the heap |
| Value too large for the stack | Big arrays/buffers are heap-allocated |
| Passed to a function the compiler can’t see/inline (sometimes) | Lifetime can’t be bounded |
🐹 Interfaces and fmt are the usual suspects
The single most common “why did this escape?” answer is interfaces. Storing a concrete value in an interface{} (or any interface) generally needs its address, so the value escapes. That’s why fmt.Print*, encoding/json, log, and anything taking ...any show up all over -m output. It’s rarely worth contorting code to avoid — but in a hot loop, swapping a logging call or an interface parameter for a concrete type can erase a surprising amount of allocation. See interfaces under the hood for why the address is needed.
Reference
| Tool / term | Meaning |
|---|---|
go build -gcflags=-m | Print escape (and inlining) decisions |
-gcflags='-m -m' | Add the compiler’s reasoning |
| ”escapes to heap” | Value is heap-allocated |
| ”moved to heap: x” | Local x promoted to the heap |
| ”does not escape” | Stayed on the stack |
//go:noinline | Prevent inlining (changes some escape outcomes) |
⚠️ Escapes are a performance concern, not a correctness one
A value escaping never makes your program wrong — Go heap-allocates precisely so returning &local stays safe. So don’t restructure code around -m output reflexively. Two traps: inlining interacts with escape analysis (a function that doesn’t escape when inlined may escape when not, so -m results shift with optimization), and micro-benchmarks lie if they let the compiler optimize the allocation away entirely (use b.ReportAllocs() and consume the result). Measure with benchmarks and pprof; optimize escapes only where the allocation rate actually hurts.
See also
- the stack & the heap — the two destinations escape analysis chooses between.
- the memory allocator — what services the heap allocations escapes create.
- interfaces, itab & eface — why interface assignment forces escapes.
- benchmarks — measuring allocations with
-benchmem/ReportAllocs.
Next: where an escaped value actually lands — the memory allocator.
Related topics
Where Go values live — the fast per-goroutine stack vs the garbage-collected heap, why stacks grow and get copied, and how the compiler (not you) decides.
memoryThe Memory AllocatorHow Go serves memory without a syscall every time — the tiered allocator (mcache/mcentral/mheap), size classes, and the tiny-object allocator.
representationInterfaces: itab & efaceHow interfaces are really represented — the iface and eface structures, itab caching, dynamic dispatch and type-assertion cost — deeper than the fundamentals page.
Check your understanding
Score: 0 / 51. What is escape analysis?
Escape analysis runs in the compiler. For each value it asks: can I prove this never outlives the function that created it? If yes, it goes on the stack (free to allocate and reclaim). If it can't prove that — the value 'escapes' — it's heap-allocated and left to the GC.
2. How do you see the compiler's escape decisions?
The -m flag makes the compiler print its optimization decisions, including 'escapes to heap' and 'moved to heap' lines and whether a value 'does not escape'. -m -m (or -m=2) prints more reasoning. It's a build-time flag, not a runtime one.
3. Which of these commonly forces a value to escape to the heap?
A value escapes when the compiler can't bound its lifetime to the frame: returning &x, assigning it to an interface (which holds a pointer), capturing it in an escaping closure, sending it on a channel, or storing it in a heap object. Passing by value or just reading it does not, by itself, cause an escape.
4. Why can assigning a concrete value to an interface variable cause it to escape?
An interface value is a (type, pointer) pair. To store a concrete value in an interface the compiler usually needs its address, and if that interface outlives the function, the pointed-to value must too — so it's heap-allocated. This is why fmt.Println(x) often shows x escaping in -m output.
5. What's the practical payoff of reducing escapes?
Stack allocations are free to reclaim; heap allocations add to the GC's workload. Cutting escapes in hot paths reduces allocation rate and GC pauses. But escapes are invisible to correctness, so chase them only where benchmarks/pprof point — not on instinct.
Comments
Sign in with GitHub to join the discussion.