Escape Analysis and Allocation¶
Stack or heap is decided by the compiler¶
Go has no new-means-heap rule. For every value, the compiler decides
at build time whether it can live in the function's stack frame
(freed for free when the function returns) or must be placed on the
heap (allocated by the runtime, later found and freed by the
garbage collector). The analysis that decides is escape analysis:
a value escapes when the compiler cannot prove that no reference to it
outlives the frame.
Why it matters, measured (Go 1.26.4, Apple M1 Pro):
func NewPointPtr(x, y int) *Point { return &Point{x, y} } // escapes
func NewPointVal(x, y int) Point { return Point{x, y} } // stays in registers/stack
BenchmarkPtr-8 107494903 11.02 ns/op 16 B/op 1 allocs/op
BenchmarkVal-8 548172045 2.19 ns/op 0 B/op 0 allocs/op
The heap version is 5x slower here, and that is only the direct cost. Every heap allocation also adds work for the GC later (Garbage Collector): allocation rate is the main input to how often the GC runs.
Reading the compiler's decisions¶
go build -gcflags='-m' ./pkg # escape + inlining decisions
go build -gcflags='-m=2' ./pkg # with the reasoning chain
go test -gcflags='-m' -run=x ./pkg # same for test files
Real output for a set of small functions:
esc.go:7:44: &Point{...} escapes to heap // returned pointer
esc.go:12:7: &Point{...} does not escape // pointer used locally only
esc.go:18:12: leaking param: p // p is stored somewhere global
esc.go:20:23: moved to heap: p // local var whose address leaked
esc.go:22:33: x escapes to heap // passed to fmt.Println(...any)
esc.go:25:11: make([]byte, 64) does not escape // constant size, local
esc.go:30:11: make([]byte, n) does not escape // see "variable-size make" below
esc.go:35:11: make([]byte, 131072) escapes to heap // too big for the stack
esc.go:40:2: moved to heap: x // captured by a returned closure
esc.go:41:9: func literal escapes to heap
esc.go:50:17: S{...} does not escape // interface call devirtualized
The vocabulary:
| Message | Meaning |
|---|---|
escapes to heap |
This allocation (&T{}, make, new, a boxed value) goes on the heap |
moved to heap: x |
Variable x itself lives on the heap, because its address outlives the frame |
leaking param: p |
The pointer passed in as p flows somewhere that outlives the call (a global, the heap, a return value) — callers' arguments escape |
leaking param content |
What p points to escapes, but not p itself |
does not escape |
Stays on the stack |
The main reasons values escape¶
- Returned or stored pointers. Returning
&local, storing a pointer in a global, a heap object, a map, or a channel. - Interface conversions where the callee is not known. Passing a
value to
fmt.Println,log.Printf,errors.New-style APIs takingany: the compiler cannot see what the callee does with it, so the boxed copy escapes. This is the single most common source of "mysterious" allocations. - Closures that outlive the function capture their variables by reference, so the variables move to the heap.
- Size. Values larger than the compiler's stack limits go to the
heap: explicitly declared variables (
var x T,x := ...) over 128 KB, and implicit ones (new(T),&T{},make([]T, n)) over 64 KB (measured:make([]byte, 128<<10)escapes). Both limits are incmd/compile/internal/ir/cfg.go. - Unknown size.
make([]byte, n)with non-constantnused to always escape. Since Go 1.25 the compiler reserves a 32-byte stack buffer and uses it whennfits — measured allocations formake([]byte, n): n=16 → 0, n=32 → 0, n=33 → 1, n=1024 → 1. - Calls through function values or interfaces the compiler cannot resolve: parameters are assumed to escape.
- Slices of pointers / maps of pointers: storing
&xinto any heap container makesxescape.
Escape analysis is per call site, after inlining¶
Escape analysis runs after inlining. A function that "returns a pointer" does not necessarily allocate at every call: if it is inlined, the pointer may not escape in the caller's context.
func Closure() func() int { x := 0; return func() int { x++; return x } }
Compiled alone, x is moved to the heap and the closure escapes. But
testing.AllocsPerRun(100, func() { Closure()() }) reports 0
allocations: Closure was inlined into the caller, the closure was
called immediately, and nothing outlived the frame. The flip side: a
small change that pushes a function over the inlining budget
(see Compiler, Inlining, and Assembly) can
suddenly add allocations at every call site. Benchmarks with
-benchmem catch this; reading code does not.
Patterns that avoid allocations¶
Return values, not pointers, for small structs. Copying 16–64 bytes is cheaper than an allocation plus GC work. Use pointers when the struct is large, must be shared, or must be mutated by the callee.
Let the caller provide the memory. The standard library's
Append* style puts the allocation decision with the caller, who can
reuse a buffer:
// Allocates every call:
func (k Key) Bytes() []byte { b := make([]byte, 0, 16); ...; return b }
// Caller controls allocation; zero allocs when buf has capacity:
func (k Key) AppendBytes(buf []byte) []byte { ...; return append(buf, ...) }
buf = k.AppendBytes(buf[:0])
strconv.AppendInt, time.Time.AppendFormat, binary.AppendUvarint,
and fmt.Appendf follow this pattern.
Accept io.Writer/[]byte, not return string, in hot encoding
paths.
Avoid any parameters in hot paths. A generic function
func Max[T cmp.Ordered](a, b T) T does not box; func Max(a, b any)
does.
Pre-size. make([]T, 0, n), make(map[K]V, n), strings.Builder.Grow(n).
Reuse with sync.Pool for large, frequently allocated temporary
objects (buffers, encoders). Covered with measurements in
Reducing Allocations and GC Pressure.
Do not fight the compiler blindly¶
Escape analysis is conservative and changes between releases. Before contorting code:
- Measure with
-benchmemor a heap profile that the allocation is actually significant (it is in a hot path and allocation shows up in the CPU profile asruntime.mallocgc). - Read
-gcflags=-m=2for the exact reason. - Fix the reason (pass a value, hoist a buffer, avoid
any). - Lock it in: a test with
testing.AllocsPerRunfails if a future change reintroduces the allocation.
func TestEncodeNoAlloc(t *testing.T) {
var buf []byte
k := Key{...}
if n := testing.AllocsPerRun(100, func() { buf = k.AppendBytes(buf[:0]) }); n != 0 {
t.Fatalf("AppendBytes allocates %v times per call", n)
}
}
Common pitfall¶
Adding a log line or an error wrap inside a hot loop. A function that
did zero allocations gains log.Debug("processing", "key", key) —
even with debug logging disabled, key is converted to any before
the level check runs, so it is boxed and escapes on every iteration.
The loop goes from 0 to 1–3 allocations per item, and at a million
items per second that is a new GC cycle every few hundred
milliseconds. Guard expensive logging with an explicit level check
(if logger.Enabled(ctx, slog.LevelDebug) { ... }), use typed
attribute constructors (slog.String, slog.Int) that avoid boxing,
and keep an AllocsPerRun test on any path you have optimized.
Review questions¶
- What does
moved to heap: buftell you, and how is it different fromleaking param: buf? - Why can the same function allocate at one call site and not at another?
- Why does passing an
inttofmt.Printlnallocate? - Since Go 1.25, when does
make([]byte, n)with variablenavoid the heap? - How do you make sure an optimization that removed an allocation is not silently undone six months later?