Escape analysis
Escape analysis is the compiler pass that decides, for each value you create, whether it can live on the stack or has to be allocated on the heap. Stack allocations cost a few nanoseconds and disappear when the function returns. Heap allocations cost more up front and add pressure to the garbage collector later. Knowing how the decision gets made — and how to read the compiler's output — is most of what separates "Go that allocates a lot" from "Go that doesn't."
Why escape analysis exists
A stack allocation in Go is almost free. The function prologue moves the stack pointer down by the right number of bytes; the values live there until the function returns; the epilogue moves the pointer back. No allocator, no bookkeeping, no garbage collector involvement. The cost is a handful of nanoseconds.
A heap allocation is several steps more expensive. The runtime has to size-class the
request, walk to a free slot in the right mspan, and update bookkeeping.
Then later the garbage collector has to scan that allocation, decide whether it's still
live, and eventually free it. None of those steps are slow in isolation, but in a hot
path that allocates per request they add up — both as direct CPU and as GC pause
pressure.
So the compiler tries to put each value on the stack when it can prove the value doesn't outlive its frame. When it can't prove that, it moves the value to the heap. That proof is what escape analysis does.
How to see it
The compiler will tell you its decisions. Pass -gcflags=-m to
go build or go test and it prints one line per non-trivial
allocation, including whether each value escaped:
$ cat main.go
package main
func newOnStack() int {
x := 42
return x
}
func newOnHeap() *int {
x := 42
return &x
}
func main() {
_ = newOnStack()
_ = newOnHeap()
}
$ go build -gcflags="-m" .
./main.go:9:2: moved to heap: x
./main.go:14:6: ... argument does not escapeThe first function returns the value by copy; x lives on the stack. The
second returns its address, so the compiler has to move x to the heap — the
caller might hold the pointer long after newOnHeap returns.
Double the flag — -gcflags="-m -m" — and the compiler explains its
reasoning, function by function. It's verbose, but invaluable when a decision surprises
you.
The rules in plain words
The escape analyser is a data-flow pass. It conservatively assumes a value escapes if any of the following hold:
- Its address is returned, or stored somewhere that outlives the current frame — into a package-level variable, into a field of a value that itself escaped, into a slice or map.
- It's captured by a closure that escapes. If the closure is returned or passed to a goroutine, the captured variables have to outlive the function that created them.
- The compiler can't size it at compile time. A slice made with
make([]byte, n)wherenisn't a constant will usually escape — the stack frame needs a fixed size. - It's stored through an interface. Assigning a concrete value to an interface variable typically allocates, because the interface holds a pointer.
- It's passed to a function that takes a
*Tand the compiler can't prove the target doesn't outlive the call. Interprocedural escape analysis works for many simple cases but gives up across package or interface boundaries.
Interface conversions and boxing
An interface value in Go is a two-word header: a type descriptor and a data pointer. When you assign a concrete value to an interface, the runtime needs somewhere for the data pointer to point. Small values that fit in a word can sometimes be packed directly, but the general case allocates.
fmt.Println takes ...any (which is ...interface{}),
so every argument gets converted on the way in. This is a common source of accidental
allocations in code that prints a lot:
$ cat main.go
package main
import "fmt"
func main() {
n := 42
fmt.Println(n)
}
$ go build -gcflags="-m" .
./main.go:7:13: n escapes to heap
./main.go:7:13: ... argument does not escapeThe integer n would happily live on the stack on its own. The conversion to
any is what forces the heap allocation. The same shape applies to anything
passed through an interface{} parameter — including the
error interface, generic helpers that take any, and map keys
or values of interface type.
Closures
A closure that doesn't escape — called and discarded inside the same function — can keep
its captured variables on the stack. A closure that does escape — returned, stored in a
struct, or handed to go — forces the captured variables to the heap, because
they have to outlive the function that created them.
func makeCounter() func() int {
n := 0 // moved to heap: closure escapes
return func() int {
n++
return n
}
}Go captures variables by reference, not by value. The closure and the surrounding
function share the same n, which is why mutations inside the closure are
visible outside it. The cost is that any variable a closure captures has to live wherever
the closure can reach — usually the heap, once the closure escapes.
Slice and map growth
append is the other common allocator. When a slice's length reaches its
capacity, the next append allocates a new backing array — typically twice
the size — and copies the existing elements into it. The old array becomes garbage. A
loop that builds up a slice without pre-sizing pays this cost repeatedly.
// Allocates several times as the backing array grows:
var xs []int
for i := 0; i < 1000; i++ {
xs = append(xs, i)
}
// Allocates once:
xs := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
xs = append(xs, i)
}Maps are similar. make(map[K]V, hint) sizes the bucket array up front so
that inserts up to roughly hint entries don't trigger rehashes. If you know
the rough cardinality, supply it; the cost of an oversized hint is much smaller than the
cost of repeated growth.
sync.Pool
Some allocations can't move to the stack — buffers handed to libraries, JSON
decoders, request-scoped structs that outlive their handler frame. For the cases where
the allocation rate matters, sync.Pool gives you a free-list of reusable
values keyed by goroutine / processor, so you can borrow an existing object instead of
allocating a new one.
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func handle(w io.Writer, data []byte) {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufPool.Put(buf)
// use buf...
}Typical wins are bytes.Buffer reuse in encoding paths, *json.Decoder
pools in high-throughput RPC servers, and per-request struct pools where the struct is
large. Pool entries can be reclaimed at any GC, so don't rely on the pool to remember
anything — always Reset on the way out and re-initialise on the way in.
sync.Pool earns its keep at thousands of allocations per second, not tens.Common pitfalls
- Premature obsession with allocation. Most code doesn't allocate enough for it to matter. Spending an afternoon shaving allocations out of a once-per-request handler that runs at 10 QPS is not a good use of time.
- Micro-benchmarks that hide real allocations. A benchmark that
assigns to a package-level sink can prevent escape; a benchmark that doesn't use
its result can get dead-code eliminated. Always check
-benchmemoutput and confirm the work actually happened. fmt.Sprintfin hot paths. Every variadic argument escapes throughany. For high-frequency formatting, build withstrconv.Append*into a reused buffer instead.- Interface keys or values in maps. A
map[any]anyboxes every key and value on insert. A typed map of the actual key and value types doesn't. - Returning local pointers. Always escapes, by definition. Sometimes the right call, sometimes not — but it's never free.
- Closing over a loop variable in a goroutine. The classic
for i := range xs { go func() { use(i) }() }bug. Capture by reference means every goroutine sees whateverihappens to be when it runs. Since Go 1.22 the loop variable is scoped to each iteration, which fixes the correctness side but doesn't change the allocation pattern — the captured variable still escapes.
Production checklist
| When | What |
|---|---|
| Reviewing changes to a hot path | Run go build -gcflags=-m and look at the diff of escape decisions. |
| Benchmarking | Always -benchmem. Watch B/op and allocs/op, not just ns/op. |
Adding sync.Pool | Only for a measured allocation hotspot. Confirm the pool actually reduces allocs in benchmarks. |
| Small struct receivers | Prefer value receivers — they avoid taking the address and can stay on the stack. |
| Building slices or maps with known size | Pre-size with make([]T, 0, n) or make(map[K]V, n). |
Using any in a tight loop | Reconsider. A typed function or generic is usually cheaper. |
None of these are absolute. The right amount of escape-analysis attention is whatever the profile says the code needs.
Further reading
- Hudson — Go 1.5 escape analysis design doc — the design behind the current allocator-aware escape pass.
- cmd/compile/internal/escape — the compiler source for the analysis itself.
- Vyukov — Go allocator and GC — a tour of where allocations end up and what they cost.
- Internals / Garbage collection — what allocation pressure actually costs in pause time and CPU.
- Internals / Interfaces — the iface / eface layout that makes interface conversions allocate.