Escape Analysis
Escape analysis is the compiler pass that decides, for every value a Go program allocates, whether it can live on the goroutine’s stack or must be placed on the heap. The decision is purely a compile-time static analysis — Go has no
new-versus-stack syntax; the programmer writes&xand the compiler determines wherexlives. The governing rule is stated verbatim in the top-of-file comment ofcmd/compile/internal/escape/escape.go: the analysis preserves two invariants — “(1) pointers to stack objects cannot be stored in the heap, and (2) pointers to a stack object cannot outlive that object.” Any value whose lifetime or reachability would violate those invariants escapes and is heap-allocated; everything else stays on the stack, where allocation is free (a pointer bump) and reclamation is automatic (the frame is popped). Escape analysis runs in the compiler’s middle end, on the IR, after inlining (compiler README).
Mental Model
Stack memory is cheap in every dimension: allocating is incrementing the stack pointer, freeing is decrementing it when the function returns, and the garbage collector never has to scan or track it. Heap memory is expensive: allocation goes through the allocator, and every heap object adds work for the GC. So the compiler wants to put as much as possible on the stack. The constraint is lifetime: a stack frame vanishes when its function returns, so anything still reachable after that point cannot live there.
Escape analysis is the static decision procedure for “is this value still reachable after its frame dies?” It cannot know runtime behavior, so it is conservative: when it cannot prove a value is confined to its frame, it assumes the worst and heap-allocates. “Escapes to heap” therefore does not mean “this is a bug” — it means “the compiler could not prove confinement.”
The mechanism, per escape.go, is a directed weighted graph. Vertices are locations — variables created by statements and expressions. Edges are assignments, and edge weights count pointer indirections. There is one distinguished location, the heap, representing anything that outlives all stack frames (globals, returned pointers, the insides of chan/map/interface). A value escapes iff there is a path in this graph from its location to the heap location that the weights say is a genuine address flow.
graph LR subgraph FuncFrame["function frame (locations)"] X["x (local struct)"] P["p (local *T)"] end HEAP["heap location<br/>(globals, returns,<br/>chan/map/interface)"] X -->|"p = &x (deref -1)"| P P -->|"return p (deref 0)"| HEAP X -.->|"address path to heap<br/>⇒ x ESCAPES"| HEAP
Diagram: escape analysis as a data-flow graph. x’s address flows into p, and p flows to the heap location via return. Because an address-carrying path reaches the heap, x must be heap-allocated. The insight: escape is a reachability question on a graph, not a per-statement rule.
Mechanical Walk-through
The location graph
The escape.go comment describes the construction precisely: the analysis builds “a directed weighted graph where vertices (termed ‘locations’) represent variables allocated by statements and expressions, and edges represent assignments between variables (with weights representing addressing/dereference counts).” It is a “static data-flow analysis of the AST” (more precisely the IR) and is “generally insensitive to flow, path, and context” — it does not track which branch was taken, and it treats a struct as a single location rather than distinguishing its fields. This conservatism trades precision for speed and simplicity.
Edge weights: derefs
Each edge carries an integer weight, the deref count, equal to dereferences minus address-of operations along the assignment. The comment gives the table directly:
p = &q→ weight −1 (one address-of)p = q→ weight 0p = *q→ weight +1 (one dereference)p = **q→ weight +2
And a key bound: “derefs cannot go below −1” — because address-of applies only to addressable expressions, you cannot take “the address of the address.” A weight of −1 on an edge q → p means “p holds the address of q”; that is the signal that q’s storage is now reachable through p.
Walking the graph
Once the graph is built, the analysis “walk[s] the graph looking for assignment paths that might violate the invariants stated above. If a variable v’s address is stored in the heap or elsewhere that may outlive it, then v is marked as requiring heap allocation.” Concretely: starting from the heap location, follow edges backward; any location reachable along a path whose accumulated weight indicates an address flow (it reaches a location through a −1 edge / its address is held) is marked as escaping. Marked locations get the IR flag that tells the later allocation lowering to emit a heap allocation (runtime.newobject and friends) instead of a stack slot.
Interprocedural analysis via parameter tags
Escape analysis must handle function calls without re-analyzing every callee at every call site. The escape.go comment explains the trick: the compiler “record[s] data-flow from each function’s parameters to the heap and to its result parameters. This information is summarized as ‘parameter tags’, which are used at static call sites to improve escape analysis of function arguments.” So each function is analyzed once; the result is a compact tag on each parameter saying, in effect, “this parameter does not escape,” or “this parameter leaks to the heap,” or “this parameter leaks to result 0.” At a call site, the compiler reads the callee’s tags (available even across packages, because tags are part of the Unified IR export data) and propagates escape accordingly. This is why escape analysis works across package boundaries at all.
Why it runs after inlining
The compiler README lists the middle-end order: dead code, devirtualization, inlining, then escape analysis. The order is deliberate. Inlining copies a callee’s body into the caller, erasing the call boundary. A value passed by address to a small helper might be conservatively assumed to escape across the call; once the helper is inlined, the boundary is gone and the analysis can often prove the value never leaves the frame. Inlining therefore creates stack-allocation opportunities that escape analysis then harvests — a concrete example of why pass ordering is a real design decision.
Why young objects die on the stack
Rick Hudson’s ISMM keynote ties escape analysis to GC design. Because Go is value-oriented (arrays and maps hold values, not pointers, and functions often take values rather than pointers), “escape analysis would only have to do intraprocedural escape analysis” in many cases, and the practical result is that “the young objects live and die young on the stack.” That is the stated reason Go’s collector is non-generational: a generational GC optimizes for short-lived heap objects, but Go’s short-lived objects are mostly never on the heap in the first place — escape analysis already removed them. Escape analysis and the GC’s design are two halves of one strategy.
Code and Diagnostic Examples
Reading escape decisions with -gcflags=-m
The compiler reports every escape decision when asked:
go build -gcflags='-m' . # one level of escape diagnostics
go build -gcflags='-m -m' . # more verbose: shows the reasoning chainpackage p
func noEscape() int {
x := 42 // (a)
p := &x // (b)
return *p // (c) returns the VALUE, not the pointer
}
func escapes() *int {
x := 42 // (d)
return &x // (e) returns the POINTER
}For noEscape, -gcflags=-m prints nothing about x escaping: at line (b) p holds &x, but line (c) dereferences p and returns the int value. No address flows out of the frame, so x stays on the stack. For escapes, the compiler (verified against go1.26.1) prints ./p.go:12:2: moved to heap: x, and with -m -m the reasoning chain x escapes to heap in escapes: followed by flow: ~r0 ← &x: / from &x (address-of) / from return &x (return). Note the exact phrasing: the escape line names the variable (x escapes to heap), not &x escapes to heap — the address-of is reported as a step inside the flow trace, not as the headline. Line (e) returns the address of x; the returned pointer outlives the frame, invariant (2) would be violated, so x is heap-allocated. The two functions are nearly identical in source — the difference between returning *p and &x is the entire difference between zero allocations and one.
A subtler escape: the interface boundary
func leakViaInterface() {
x := compute() // compute() is //go:noinline so x is not a constant
fmt.Println(x) // x escapes to heap
}fmt.Println takes ...any. Putting x into an interface{} value, combined with Println’s parameter being tagged as leaking (the implementation may store the value through os.Stdout), makes the compiler conservatively heap-allocate the boxed value. On go1.26.1 this prints ./q.go:10:14: x escapes to heap along with ./q.go:10:13: ... argument does not escape and ./q.go:10:13: inlining call to fmt.Println; with -m -m the flow trace runs {storage for ... argument} ← &{storage for 42} → fmt.a ← &{storage for ... argument} → {heap} ← *fmt.a, ending at fmt.Fprintln(os.Stdout, fmt.a...) (call parameter) — i.e. the value leaks because Fprintln’s parameter a ...any is tagged as escaping to the heap. This is the canonical “why is my tiny program allocating?” surprise — the escape is caused by the interface conversion feeding a function whose parameter tag says “leaks,” not by anything visibly heap-ish.
One subtlety worth pinning, because it trips people reading -m output: if x is a compile-time constant (x := 42), the compiler folds it and the diagnostic instead names the literal — ./p.go:NN:14: 42 escapes to heap — and the variable x never appears in the message at all. The escape is identical (a *int-sized box is heap-allocated to satisfy the any interface); only the name the compiler attaches to the escaping storage differs. The example above forces x to be a genuine non-constant (via a //go:noinline compute()) so the message reads x escapes to heap, matching the variable in the source.
Forcing the issue: go:noinline and benchmarks
//go:noinline
func id(p *int) *int { return p } // returns its pointer arg ⇒ arg leaks to resultThe //go:noinline directive (Compiler Directives and Pragmas) prevents inlining, so the call boundary survives and the parameter tag on p (“leaks to result 0”) is what governs callers. go test -bench . -benchmem then reports allocs/op, the empirical confirmation of the static decision. The static -m output and the runtime allocs/op count should agree; when they do not, the analysis was more conservative than the dynamic behavior.
Go 1.26: more slices on the stack
The Go 1.26 release notes record a relevant improvement: “the compiler can now allocate the backing store for slices on the stack in more situations.” This extends escape analysis (specifically the handling of make([]T, n) with a non-constant n) so that more slice backing arrays stay on the stack. The notes give the escape hatch for diagnosing regressions: the bisect tool with -compile=variablemake, or disabling it wholesale with -gcflags=all=-d=variablemakehash=n.
Common Misunderstandings
“Escape to heap means a bug or bad code.” No. It means the compiler could not prove stack-safety. Returning a *T, storing a pointer in a long-lived structure, or passing a value to an interface frequently and correctly escapes. The signal matters only in hot paths where the allocation count is measured.
“Bigger objects go on the heap, small ones on the stack.” Size is one input — very large objects do go to the heap to avoid blowing the stack — but the dominant factor is reachability, not size. A one-byte value can escape and a large struct can stay on the stack.
“new allocates on the heap, &x on the stack.” Both new(T) and &T{} are just “give me a *T”; escape analysis, not the syntax, decides placement. new(T) whose result never escapes is a stack allocation; &T{} whose result is returned is a heap allocation.
“Escape analysis is flow-sensitive and exact.” It is explicitly “insensitive to flow, path, and context” (escape.go) and treats compound values (struct fields, array elements) without fine distinction. It is deliberately conservative: a value the analysis cannot prove confined is assumed to escape, even if at runtime it never would.
“It is the same as in Java/the JVM.” Conceptually related, but the JVM does escape analysis at JIT time on running code with profile data; Go does it once, ahead of time, statically. Go’s is simpler and more conservative, but it has the whole program’s static structure and runs before code ships.
Alternatives and When to Choose Them
There is no alternative within Go — escape analysis is the only allocation-placement mechanism, and it is not configurable beyond the diagnostic and bisect flags above. The programmer’s lever is writing code the analysis can prove: prefer returning values over pointers in hot paths, avoid unnecessary interface conversions in loops, and use sync.Pool to amortize allocations the compiler genuinely cannot keep on the stack. When escape is unavoidable and frequent, the next tools are pooling and arena-style batching, not fighting the compiler.
Other ecosystems take other stances: C/C++ and Rust make stack-vs-heap explicit in the source, trading convenience for control; the JVM and other managed runtimes do escape analysis dynamically at JIT time. Go’s choice — static, ahead-of-time, conservative — matches its values of fast builds and predictable, dependency-free compilation. See Stack vs Heap Allocation for the placement model and gc Compiler vs gccgo vs TinyGo for how alternative Go compilers differ.
Production Notes
Escape analysis is the practical answer to “why is my program allocating?” — the Go GC guide and the standard performance workflow both route through it: profile with pprof to find allocation hot spots, then go build -gcflags='-m' on the offending package to see which values escape and why, then restructure to keep them on the stack. Common high-value fixes: hoisting a buffer out of a loop, avoiding interface{} parameters on hot calls, returning a struct value instead of a pointer, and pre-sizing slices so the backing array’s size is known.
It is also a moving target across releases — every few versions the analysis gets sharper (Go 1.26’s variable-make slice improvement is one example), so an escape that happened in an older toolchain may stop happening after an upgrade. This is why escape-sensitive performance code should be paired with a -benchmem benchmark that fails CI if allocs/op regresses: the benchmark, not a memorized rule, is the contract.
A final connection worth keeping in mind: escape analysis sits at the head of a chain — it decides Stack vs Heap Allocation, which determines goroutine stack growth and allocator traffic, which sets the pace of the garbage collector. A change in escape behavior ripples all the way to GC frequency. It is the single compiler decision with the longest-range performance consequences.
The interface-boxing escape above also explains a recurring lesson from Interface Internals: a value placed into an interface is, in the general case, copied into a heap box because the interface’s data word must point at storage that outlives the call. Escape analysis sometimes proves the box can stay on the stack (when the interface value never leaves the frame and the callee’s parameter is tagged non-leaking), but a leaking parameter tag — as fmt’s ...any carries — forces the box to the heap. The two notes are two ends of the same mechanism: Interface Internals describes the two-word value being boxed; escape analysis decides where that box lives.
See Also
- Stack vs Heap Allocation — the placement model escape analysis drives
- Interface Internals — the two-word interface value whose data box escape analysis places
- Function Inlining — runs before escape analysis and creates stack-allocation opportunities
- Profile-Guided Optimization — PGO inlining/devirtualization can expose new stack-allocation opportunities downstream
- Intermediate Representation in the Go Compiler — the representation escape analysis operates on
- Compiler Optimization Diagnostics — the
-gcflags=-mtooling in depth - Go Memory Allocator — where escaped values are allocated
- Go Garbage Collector — non-generational partly because escape analysis removes young objects
- Goroutine Stacks — where non-escaping values live
- sync.Pool Internals — amortizes allocations the compiler cannot keep on the stack
- Go Internals MOC — parent map (Section 2, The Compiler Pipeline)