| name | go-perf-escape-analysis |
| description | Guides reading Go escape analysis via go build -gcflags=-m to know what heap-allocates and how to keep it on the stack — interpreting moved to heap / escapes to heap / does not escape and the triggers that force the heap (returned pointers, interface/fmt boxing, escaping closures, escaping slices/maps, []byte(string), channel pointers), with the refactor for each. Fires on why is this allocating, does this escape, keep this on the stack, reduce heap allocations. Routes inlining to go-perf-inlining, allocator cost to go-perf-allocator-internals. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
Go Escape Analysis
"If the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors."
— Go FAQ, stack or heap
A stack allocation is reclaimed for free when the frame returns; a heap allocation is GC work. Which one a value gets is decided by the compiler's escape analysis, and it will print its decisions. The one-paragraph intro — "stack is free, heap costs GC," the moved to heap line — belongs to go-performance §4; read it first. This skill is its deep peer: it owns reading the full -gcflags=-m output, the complete taxonomy of what forces the heap, and the refactor back to the stack for each trigger. Every claim below is backed by real -m output from Go 1.26.
All Go below builds clean under gofmt, go vet, and go test on Go 1.26.
1. The Question Escape Analysis Answers
From a correctness standpoint you never need to know — "The storage location chosen by the implementation is irrelevant to the semantics of the language" (FAQ). It matters only for efficiency. The rule the compiler enforces: "if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic escape analysis recognizes some cases when such variables will not live past the return from the function and can reside on the stack" (FAQ).
Escape analysis is why the heap profile looks the way it does: a heap profile tells you where allocations land; -m tells you why a specific line escapes and whether you can stop it.
2. Running It — -gcflags=-m
-m is documented as "Print optimization decisions. Higher values or repetition produce more detail" (cmd/compile).
go build -gcflags=-m ./...
go build -gcflags='-m -m' ./...
-gcflags takes an optional package pattern that "restricts the use of that argument list to the building of packages matching that pattern" (go help build). So -gcflags='example.com/pkg=-m' hits only that package; -gcflags=all=-m hits it and its dependencies (pages of stdlib noise). Scope it to your package.
The compiler is conservative: it "gives up" in many cases, because "anything assigned to any kind of indirection (*p = ...) is considered escaped" and analysis is inhibited by "function calls, package boundaries, slice literals, subslicing and indexing, etc. Full rules are too complex to describe, so check the -m output" (CompilerOptimizations). The -m output is the ground truth — do not assert "this doesn't allocate" without it.
3. Reading the Messages
The lines that matter, with the exact wording the compiler emits:
| Message | Means |
|---|
moved to heap: x | a named local (often address-taken) is forced to the heap |
&p escapes to heap / ... escapes to heap | this value/expression must be heap-allocated |
x does not escape | stays on the stack — the good case |
leaking param: p to result ~r0 | a parameter flows out (to a result/global); the caller's value may escape |
make([]T, n) does not escape | the backing array is stack-allocated |
can inline F / inlining call to F | inlining decisions (route → go-perf-inlining) |
-m -m adds the flow chain that proves the escape. For fmt.Sprintf("%d", n):
./esc.go:21:27: n escapes to heap in Show:
./esc.go:21:27: flow: {storage for ... argument} ← &{storage for n}:
./esc.go:21:27: flow: {heap} ← {storage for ... argument}:
./esc.go:21:27: from fmt.Sprintf("%d", ... argument...) (call parameter) at ./esc.go:21:20
Read it bottom-up: n is spilled into the ...any slice, which is passed to fmt.Sprintf, which the compiler cannot prove keeps it on-stack — so n escapes. That chain is how you locate the real cause when the top-level line is terse.
4. The Taxonomy of What Forces the Heap
Each item below pairs the wrong shape (with its real -m line) against the stack-safe refactor.
4.1 Returning a pointer to a local
type Point struct{ X, Y int }
func NewPoint(x, y int) *Point { p := Point{x, y}; return &p }
The local p outlives the frame, so it must live on the heap. Refactor: return the value when the struct is small — the copy is cheaper than a GC-tracked allocation.
func NewPointVal(x, y int) Point { return Point{x, y} }
(Inlining can rescue the pointer form — §5.)
4.2 Interface boxing — concrete value into any/interface{}
Assigning a concrete value to an interface boxes it on the heap. The single biggest source is fmt, whose ...any parameter boxes every argument:
func Show(n int) string { return fmt.Sprintf("%d", n) }
func Box(n int) any { return n }
Refactor: use the typed, non-boxing path. strconv.Itoa takes an int, not any, so nothing escapes:
func ShowFast(n int) string { return strconv.Itoa(n) }
The same applies to strconv.AppendInt(buf, n, 10) into a reused buffer (depth → go-perf-strings-bytes-zerocopy). Reach for the typed API before any %v.
4.3 Closures capturing variables that outlive the frame
A variable captured by reference by a closure that escapes is itself moved to the heap:
func Counter() func() int {
c := 0
return func() int { c++; return c }
}
-m -m spells it out: c escapes to heap ... from c (captured by a closure). Refactor: if you don't need the closure to outlive the call, don't return it — pass state explicitly, or make the captured value a method receiver on a stack-allocated struct. A closure used and discarded within the frame does not force its captures to the heap.
4.4 Slices and maps the compiler can't bound
A make whose result stays local with a compiler-visible size stays on the stack; one that is returned, or grown unboundedly, escapes:
func SumLocal() int { s := make([]int, 3); return sum(s) }
func MakeSlice(n int) []int { return make([]int, n) }
func Grow(n int) (s []int) {
for i := 0; i < n; i++ { s = append(s, i) }
return s
}
Refactor: keep scratch slices local and reuse them (s = s[:0]); when a slice must be returned, preallocate (make([]T, 0, n)) so it grows once (slice growth depth → go-perf-slices). A very large local also escapes even when it doesn't outlive the frame — "if a local variable is very large, it might make more sense to store it on the heap" (FAQ).
4.5 []byte(string) and string([]byte) conversions
A conversion that escapes copies onto the heap, but the compiler elides the copy in specific read-only cases — a map lookup keyed by string(b) does not allocate:
func Bytes(s string) []byte { return []byte(s) }
func Lookup(m map[string]int, b []byte) int { return m[string(b)] }
for range string(b) is elided the same way. Refactor: stay in one representation on the hot path; exploit the elision cases; zero-copy unsafe.String/unsafe.Slice with their safety rules → go-perf-strings-bytes-zerocopy.
4.6 Pointers sent on a channel
A pointer placed on a channel escapes — the runtime cannot prove the receiver stays in-frame:
func SendPtr(ch chan *Point, x, y int) { p := &Point{x, y}; ch <- p }
Refactor: send the value (chan Point) when the struct is small and you don't need shared mutation; the channel copies it regardless.
4.7 Storing a concrete value into an interface-typed field
Same boxing as §4.2, but via a struct field rather than a parameter:
type Box2 struct{ v any }
func StoreIface(b *Box2, n int) { b.v = n }
Refactor: give the field a concrete type when the set of stored types is known; an int field never boxes.
5. leaking param and the Interaction with Inlining
leaking param is distinct from escapes to heap: it describes a parameter whose reference flows out of the function (to a result or a global). It does not, by itself, allocate — it tells the caller's analysis that the argument may escape there:
func Sink(p *Point) any { return p }
func Add(p *Point) int { return p.X + p.Y }
This is the hook into inlining. Inlining can remove an escape: when NewPoint (§4.1) is inlined into a caller that never lets the pointer escape, the local can stay on the caller's stack and the moved to heap line disappears at the call site. So "a returned *T always allocates" is false — it depends on whether the callee inlines and whether the result escapes in the caller. The inlining budget, what blocks inlining, and //go:noinline are owned by go-perf-inlining; the cost of a heap allocation once it happens (size classes, GC scan) is go-perf-allocator-internals.
6. Don't
- Don't claim "this doesn't allocate" without
-m evidence. The rules are too complex to eyeball — "check the -m output" (CompilerOptimizations).
- Don't forget that
fmt.Sprint/Sprintf/Print box every argument into ...any; prefer strconv.Append*/Itoa on a hot path (§4.2).
- Don't assume a returned
*T always escapes — inlining can keep it on the caller's stack (§5).
- Don't assume small structs never heap-allocate — taking their address, boxing them into an interface, or sending them on a channel all escape them (§4.1, §4.2, §4.6).
- Don't ignore interface-typed fields and
any parameters — storing a concrete value into either boxes it (§4.2, §4.7).
- Don't re-
make a scratch slice per call when it could be a local that "does not escape" and is reused with s = s[:0] (§4.4).
- Don't reach for
unsafe to dodge a copy the compiler already elides (m[string(b)], for range string(b) — §4.5).
7. Routing to Related Skills
This plugin (go-perf-* depth):
go-perf-inlining — the inlining budget, blockers, //go:noinline, and how inlining removes an escape (§5).
go-perf-allocator-internals — what a heap allocation costs: size classes, the tiny allocator, scan vs no-scan spans.
go-perf-slices / go-perf-maps — growth internals and preallocation that keep backing storage from escaping.
go-perf-strings-bytes-zerocopy — strconv.Append*, unsafe.String/unsafe.Slice, conversion-elision cases (§4.5).
go-perf-pprof-profiling — the heap profile that says where allocations land; pair it with -m's why.
go-perf-methodology — whether reducing this allocation is worth it (measure first).
Parent / sibling marketplace (go-* correctness):
go-performance — the measure-first overview and the one-paragraph escape intro this skill deepens (§4 there).
go-version-feature-map — version floors for the runtime/GC behavior an escape feeds.
8. Reference Files
High-frequency escape-analysis mistakes in LLM-generated Go, each with wrong/right code, the real -m line, and citations:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml