06 / 10
Internals / 06

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 escape

The 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) where n isn'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 *T and 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.
Conservative by design. When the analyser isn't sure, it picks heap. False positives (heap-allocating something that could have lived on the stack) are correct but slow; false negatives would be unsafe.

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 escape

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

When not to pool. If the allocation isn't on a measured hot path, a pool just adds complexity and lock contention. Profile first. The general rule is that 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 -benchmem output and confirm the work actually happened.
  • fmt.Sprintf in hot paths. Every variadic argument escapes through any. For high-frequency formatting, build with strconv.Append* into a reused buffer instead.
  • Interface keys or values in maps. A map[any]any boxes 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 whatever i happens 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

WhenWhat
Reviewing changes to a hot pathRun go build -gcflags=-m and look at the diff of escape decisions.
BenchmarkingAlways -benchmem. Watch B/op and allocs/op, not just ns/op.
Adding sync.PoolOnly for a measured allocation hotspot. Confirm the pool actually reduces allocs in benchmarks.
Small struct receiversPrefer value receivers — they avoid taking the address and can stay on the stack.
Building slices or maps with known sizePre-size with make([]T, 0, n) or make(map[K]V, n).
Using any in a tight loopReconsider. 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

Found this useful?