Where Did That Variable Go?
A profiling session shows the garbage collector consuming 20% of CPU in a service that allocates almost nothing on the request path — according to the code, which declares a handful of locals per request. The mental model most programmers bring from C is that a local variable lives on the stack for the life of its function. Go's compiler does not promise that. When it decides a value must outlive the frame that declared it, it moves the variable to the heap at compile time, and every such move is a future allocation for the collector to reclaim.
The compiler will tell you exactly what it decided:
go build -gcflags="-m" ./...
./report.go:14:12: can inline buildReport
./report.go:18:9: moved to heap: summary
./report.go:21:20: composite literal escapes to heap
./report.go:25:13: &totals escapes to heap
./report.go:30:7: leaking param: rows
Three messages matter most. moved to heap: x — the local could not stay in its stack frame, so the compiler allocates it on the heap and the variable becomes pointer-to-heap access on every read. escapes to heap — a value (often a composite literal) is being stored somewhere the stack cannot reach: a global, a heap struct, a channel, or a grown slice. leaking param: p — a function parameter's reference escapes into something longer-lived than the call, so the caller's value may also need heap storage; this diagnostic is contagious and explains why one escaping value inflates allocations across a call chain.
A concrete case that shows several at once:
func buildReport(rows []Row) *Report {
var summary string // moved to heap: returned through r below
var totals Totals // composite literal escapes with the report
for _, r := range rows {
totals.Add(r)
}
summary = totals.String()
r := &Report{Summary: summary, Totals: totals} // escapes: result outlives the frame
return r
}
The escape is not a mistake here — the function returns *Report, and the caller now owns data that must survive buildReport's frame. Stack allocation is impossible by definition. The useful reading of -m is therefore not "how many warnings" but "which escapes were avoidable": values that escape because they must are free of blame; values that escape because of how the code was written — a loop that appends without capacity, a string concatenated ten times, a local boxed into an interface for fmt.Println — are candidates.
Two measurement tools complete the picture. go test -bench=. -benchmem reports B/op and allocs/op for a benchmark, telling you how many heap allocations a code path performs per operation — the number the collector actually cares about. And a heap profile from CPU and Runtime Profiling with pprof tells you which functions produce the volume in production. Compiler output says what escaped; benchmarks and profiles say whether it matters.
The Six Escapes Worth Recognizing on Sight
Escape analysis output looks chaotic until you group it by cause. Six patterns account for nearly every avoidable heap allocation in ordinary service code.
Returning a local pointer — necessary, not a bug.
func newUser(id int64) *User { return &User{ID: id} } // escapes: result outlives frame
Stack allocation cannot satisfy a caller-owned reference. The compiler already inlines small constructors when it can; leave these alone.
Appending in a loop without capacity — trivially avoidable.
var out []string
for _, s := range rows { // each append may grow: allocate, copy, discard
out = append(out, normalize(s))
}
When the length is knowable, out := make([]string, 0, len(rows)) collapses several allocations into one. The diagnostics say append(...) and slice growth escape; the benchmark (allocs/op) proves the fix.
String concatenation in a loop — the classic.
var s string
for _, p := range parts {
s += p // allocates a new string every iteration
}
strings.Builder allocates amortized once: it grows a byte buffer and materializes the string once at String(). Ten parts become one allocation instead of ten.
Passing a pointer where a value would do — the semantic question.
func total(t Totals) Totals { return t } // value: copies, no escape pressure
func total(t *Totals) Totals { return *t } // pointer: caller's Totals may escape via leaking param
Small structs passed as values cost a copy (cheap for a few words) and give the compiler stack-allocation freedom. This connects directly to Value Semantics versus Pointers: choose the pointer when you need shared mutation or the struct is large, not by reflex.
Boxing into an interface — invisible in source, visible in -m.
fmt.Println(id) // id escapes to heap: converted to interface
Formatting converts the concrete value into an any, which requires a heap-allocated interface box on hot paths. This is why production code formats in logging statements that the level filter removes before the call, not in unconditional Sprintf per iteration.
Closure capture that outlives the call — deliberate by nature. A function returned from a factory captures its variables and forces them to the heap, which is exactly what makes closures work (Closures and Anonymous Functions). It appears in every -m run and belongs there.
The classifier to apply to every diagnostic: does the value need to outlive the frame because of what the program means, or only because of how the line was written? The first group is semantics. The second group is where optimization lives.
Measure, Change One Thing, Measure Again
Escape analysis is a hypothesis generator; a benchmark is the experiment. The workflow that keeps optimization honest is: find a path with real allocation volume (heap profile or -benchmem), form a hypothesis about one cause, change exactly one thing, and re-run the same benchmark.
func BenchmarkFormat(b *testing.B) {
parts := []string{"user=42", "op=checkout", "status=ok", "latency=12ms"}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var s string
for _, p := range parts {
s += " " + p
}
_ = s
}
}
BenchmarkFormat-8 8500000 142 ns/op 192 B/op 5 allocs/op
Five allocations per call, four of them from concatenating five pieces. One change — strings.Builder with Grow — and the same benchmark:
BenchmarkFormat-8 41000000 28.6 ns/op 64 B/op 1 allocs/op
From 192 bytes and 5 allocations to 64 bytes and 1, at roughly five times the speed. The compiler diagnostics explained why (each += allocates a new string); the benchmark proved the change mattered; and the allocation count — not the nanoseconds — is the number to watch, because the collector's work scales with allocation volume.
That last point sets the limits of this whole discipline. Allocations matter only through garbage collection pressure: at a modest rate the collector is invisible, at extreme rates it shows up as CPU and pause cost in the heap profile. Optimizing a function that runs twice a day saves nothing; optimizing the function in the request hot path at 10k rps saves real CPU. Three rules keep escape optimization from becoming cargo cult:
- Never trade correctness for a diagnostic.
sync.Poolto avoid allocations, object pooling that leaks references and inflates the heap, hand-rolled buffer reuse that creates aliasing bugs — each converts a measurable cost into an invisible one. Pool only objects that are large and proven hot. - Re-measure under production shape. Microbenchmarks that operate on four strings say nothing about a path whose input is a 10 KB payload; take the heap profile after the change (see CPU and Runtime Profiling with pprof) and confirm the volume moved.
- Keep the readability ledger honest.
strings.Builderandmake([]T, 0, n)are pure wins — clearer intent and fewer allocations. A rewrite that obscures the code to remove two allocations on a cold path is a net loss, and the review comment should say so.
The end state is not an -m run with no warnings — that output is unachievable for any program that returns data. The end state is knowing, for the code paths that matter, which values must escape, what each one costs per operation, and that the number was worth paying for.
Quiz
1. The compiler reports `moved to heap: summary` inside `func buildReport(rows []Row) *Report`. What is the correct interpretation?
2. A loop concatenates strings with `s += p` and runs 100k times per request. What does `-benchmem` most likely show, and what is the standard fix?
3. A teammate proposes replacing all `*T` parameters with `T` values to reduce heap allocations. What is the best response?
4. A service allocates 200 MB/s but shows flat heap and stable RSS. Why is `inuse_space` the wrong profile view, and what should you use?
Flashcards
Question
What does `moved to heap: x` mean in -gcflags=-m output, and when is it not a problem?
Click to reveal answer
Answer
The local variable was promoted to heap storage because its address or value outlives the stack frame — typically because it is reachable from a return value. When the escape is semantically required (returning data the caller owns), it is correct behavior; only escapes caused by how the code was written are optimization targets.
Question
What does `leaking param: p` tell you about a function's callers?
Click to reveal answer
Answer
The reference stored in parameter p escapes into something longer-lived than the call, so the caller's value may need heap storage too. The diagnostic propagates up the call chain, which is why one escaping pointer can add allocations to several functions — the reason to check callers before optimizing a callee in isolation.
Question
Which two numbers from `go test -bench=. -benchmem` connect escape analysis to garbage collector cost?
Click to reveal answer
Answer
allocs/op — heap allocations per operation, the direct output of escaping values — and B/op, the bytes those allocations consume. Together they estimate allocation volume at a given request rate, which is what translates into GC CPU.
Question
Why is `fmt.Println(x)` or fmt.Sprintf on a hot path a hidden allocation?
Click to reveal answer
Answer
The concrete value must be boxed into an interface{} to be passed to formatting, and the box — plus the formatted string — is heap-allocated. The escape appears in -m output as an interface conversion escape; filtering the log level before the call avoids the formatting entirely.
Question
When is reducing allocations NOT worth doing?
Click to reveal answer
Answer
When the path is cold, the saving is small relative to other work, or the fix harms correctness or readability — pooling an object that leaks references makes the heap worse, and hand-rolled buffer reuse risks aliasing bugs. The trigger to act is allocation volume on a hot path proven by a benchmark or heap profile, not a warning count from -m.
Revision Notes
Key Takeaways
- 1. Escape analysis is compile-time: values that outlive their frame move to the heap automatically, correctly, and often necessarily
- 2. Classify every -m diagnostic: required by semantics versus caused by how the line was written
- 3. allocs/op and B/op are the numbers that connect code shape to GC pressure; -m output alone cannot tell you if it matters
- 4. strings.Builder and preallocated slices are the two pure-win optimizations; pooling and reuse trade measurable cost for hidden risk
- 5. Measure before, change one thing, measure after — with the same benchmark and a production-shaped heap profile
Interview Tips
- • "What is escape analysis?" — a compile-time decision about stack versus heap placement; give the returned-pointer example, then note some escapes are mandatory and the tool is -gcflags=-m
- • "How would you reduce allocations in a hot loop?" — show the two standard moves (preallocate capacity, strings.Builder) and cite -benchmem numbers as the proof
- • "Why do allocations matter for performance?" — not the bytes themselves but GC: allocation volume drives collector CPU, visible as churn under alloc_space while inuse stays flat
- • "Would you replace pointers with values everywhere?" — answer with the tradeoff: copy cost vs shared mutation, 'leaking param' propagation, and measurement before rewriting
Cheat Sheet
Heap Analysis and Escape Optimization
Tools and what they answer:
| Tool | Question |
|---|---|
go build -gcflags="-m" ./... |
Which values escaped, and why |
go test -bench=. -benchmem |
How many allocations per operation does this path make |
pprof heap (alloc_space) |
Which functions produce allocation volume in production |
pprof heap (default inuse_space) |
Who is holding memory live |
Diagnostic reading:
| Message | Meaning |
|---|---|
moved to heap: x |
local promoted to heap (usually necessary if returned) |
escapes to heap |
value stored where the stack can't reach (global, heap struct, grown slice) |
leaking param: p |
parameter reference outlives the call — escapes propagate to callers |
Standard fixes: loop append → make([]T, 0, n); string += loop → strings.Builder (+ Grow); avoid fmt on hot paths; value-receiver/value-param for small immutable structs.
Decision rule: only optimize when a benchmark or alloc_space profile shows volume on a hot path; never zero out -m warnings — necessary escapes exist.
Worked result: concat loop, 5 allocs/op 192 B/op → Builder, 1 alloc/op 64 B/op.
Continue Learning
Before You Start
- No prerequisites listed.