{} The Go Reference

Memory · Internals · Intermediate

Escape Analysis

How the compiler decides whether a value lives on the stack or escapes to the heap — reading go build -gcflags=-m, the patterns that cause escapes, and why it matters for performance.

Memory Intermediate ⏱ 6 min read Complete

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

escapes.go — editable & runnable
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

PatternWhy it escapes
return &localPointer outlives the frame
Assign to an interface{}Interface holds a pointer; value must persist
Capture in a closure that escapesClosure may run after the call returns
Send on a channelReceiver 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 stackBig 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 / termMeaning
go build -gcflags=-mPrint 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:noinlinePrevent 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

Next: where an escaped value actually lands — the memory allocator.

Check your understanding

Score: 0 / 5

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