What escape analysis is deciding
Go has no new versus stack distinction in the source. You write &Thing{} and the
compiler decides where it lives, and the decision rule is a proof obligation: if the
compiler can prove that no reference to the value survives the function that created
it, the value goes in that function's stack frame and costs nothing to reclaim. If it
cannot prove that, the value goes on the heap, where the garbage collector becomes
responsible for it.
Two things follow that people get backwards. Taking a pointer does not force a heap
allocation — p := &Thing{} used only locally stays on the stack quite happily. And not
taking a pointer does not guarantee a stack allocation, because a value can be copied
into something that escapes, or converted to an interface. The stack itself is not fixed
either: a goroutine starts small and the runtime grows it by copying, so "it might not
fit" is rarely the reason something escapes. The reasons are almost always about
lifetime.
Ask the compiler rather than guessing
The analysis is not a mystery you reason about from first principles; it prints its
conclusions.
go build -gcflags='-m' ./...
go build -gcflags='-m -m' ./...
package geo
import "fmt"
type point struct{ x, y int }
func sum() int {
p := point{1, 2}
return p.x + p.y
}
func origin() *point {
p := point{0, 0}
return &p
}
func show(n int) {
fmt.Println(n)
}
Build that and the compiler tells you moved to heap: p for origin and
n escapes to heap for show, and says nothing about sum. Being able to run this
in an interview, or describe its output precisely, is the difference between having
read about escape analysis and having used it.
show is the instructive case. Nothing about n looks like it leaves the function.
But fmt.Println takes ...any, so n is converted into an interface value, the
compiler cannot see through fmt's reflection to prove the interface does not outlive
the call, and an int therefore becomes a heap allocation. Every interface conversion
is a candidate escape for exactly this reason, which is why hot paths in Go tend to
avoid any in their signatures and why structured loggers that take typed fields
allocate less than ones taking ...any.
The other systematic causes: storing a pointer into something already on the heap
(including a slice or map that escapes), capture by a closure that itself escapes,
sending a pointer on a channel, allocations whose size is not a compile-time constant,
and objects the compiler judges too large to place in a frame.
Removing allocations, in order of payoff
The highest-value fix is almost always preallocation, because it removes a sequence of
allocations rather than one. make([]T, 0, n) when you know n, strings.Builder
with Grow, and a map with a size hint each convert repeated grow-and-copy into a
single allocation.
Next is eliminating the formatting and conversion layer, which is where most
surprising allocation volume lives.
Then buffer reuse. sync.Pool is the standard tool and easy to misuse: it pays only for
buffers large enough that the allocation is meaningful, it requires you to reset the
object before returning it, and pooled entries can be dropped at any garbage collection,
so it is a cache rather than a free list. Last, avoid []byte to string conversions
mid-pipeline — the compiler elides some of them, such as a string(b) used directly as
a map key, but one whose result is stored allocates.
A profiling workflow that finds the real cost
None of the above should be applied by reading code. This is the workflow to describe,
because it is what an interviewer is checking for when they ask the question at all.
Start with a benchmark that resembles the work. -benchmem gives you B/op and
allocs/op, which is the number you will report at the end.
go test -run='^$' -bench=BenchmarkHandle -benchmem -count=10 \
-cpuprofile=cpu.out -memprofile=mem.out ./internal/handler
Read the allocation profile by total bytes allocated, not by what is live.
go tool pprof -http=:8080 -sample_index=alloc_space mem.out
alloc_space and alloc_objects count everything ever allocated during the run,
which is what drives garbage-collector work. inuse_space and inuse_objects count
what was still live when the profile was taken, which is what you want when hunting a
leak. Asking the wrong one of those two questions is the most common way to waste an
afternoon. Note also that heap profiling samples — roughly one allocation per 512 KB
by default — so add -memprofilerate=1 when you need exact counts from a small
benchmark.
Confirm the cost in the CPU profile. Garbage-collection work shows up as
runtime.mallocgc, runtime.gcDrain, runtime.gcBgMarkWorker and
runtime.gcAssistAlloc. That last one is the important signal: assist time means your
own goroutines were conscripted into marking because they were allocating faster than
the background collector could keep up. If gcAssistAlloc is invisible in the
profile, allocation reduction is not your bottleneck.
Fix one thing, then compare properly. Re-run the benchmark with -count=10 into a
second file and use benchstat to say whether the difference is real rather than
noise. A single -count=1 before-and-after pair is not evidence.
go test -run='^$' -bench=BenchmarkHandle -benchmem -count=10 ./... > new.txt
benchstat old.txt new.txt
For a live service, profile the live service. Import net/http/pprof on an
internal port and pull profiles from it: /debug/pprof/allocs for the
cumulative allocation profile, /debug/pprof/heap for what is live,
/debug/pprof/profile?seconds=30 for CPU, /debug/pprof/goroutine?debug=1 when you
suspect a leak. Two heap profiles an hour apart, compared with
go tool pprof -base first.pb.gz second.pb.gz, is the standard way to find growth.
GODEBUG=gctrace=1 prints a line per collection cycle if you want to see frequency
before you go further, and go tool trace is the tool when the question is scheduler
latency rather than allocation volume.
Why the top of the profile is often the wrong thing to fix
Two mistakes account for most wasted optimisation effort in Go. The first is optimising
a service that is not allocation-bound: a handler spending its wall-clock time waiting
on Postgres will not get faster because you removed a Sprintf, however prominent that
Sprintf is in the allocation profile. Allocation profiles rank by allocation, and
allocation is not latency.
The second is optimising a benchmark instead of a workload. A microbenchmark with one
warm input and no concurrency lets the compiler and the branch predictor behave in ways
production never will, and it is entirely possible to halve allocs/op while making the
real service slower — a sync.Pool under contention across many cores is the classic
example. Use inputs that match real distributions, and verify the win against tail
latency rather than throughput.
Treat the escape-analysis output the same way: information, not a task list. A value
escaping in a function called once at startup does not matter at all; the same escape
inside a loop running a million times per second is the whole problem.
Escape analysis answers "where does this live", and it will tell you if you ask with
-gcflags=-m. It cannot tell you which of its decisions is expensive — that comes
from a benchmark, an alloc_space profile, and a benchstat run proving the change
did something.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).