The idea in one minute#
Escape analysis is the compiler pass that decides, for every variable, whether it can live on the stack. The question it asks is: could anything still refer to this value after the function returns? If the compiler can prove the answer is no, the value stays on the stack. If it cannot prove it — because the address is returned, stored somewhere longer-lived, or handed to code the compiler cannot see into — the value escapes to the heap.
The analysis is conservative and happens entirely at compile time. You can read its decisions
with go build -gcflags=-m, and they are the single most useful thing to look at when a hot
path allocates more than you expect.
An analogy#
A library’s reading room. A book you read at the desk and return before leaving needs no paperwork. The moment a book might leave the room — you want to take it home, lend it to someone, or you simply walk toward the door with it — the librarian must register a loan, because now someone has to track when it comes back. The librarian does not wait to see what you actually do; the possibility is enough.
A picture#
flowchart TB
V["v := Vec{...} or p := &v or make(...)"] --> Q1{"Is its address returned,<br/>or stored in a global,<br/>heap object or channel?"}
Q1 -->|"yes"| HEAP["moved to heap"]
Q1 -->|"no"| Q2{"Passed to a call the compiler<br/>cannot analyze?<br/>interface method, func value, reflect"}
Q2 -->|"yes"| HEAP
Q2 -->|"no"| Q3{"Captured by a closure or<br/>goroutine that may outlive the frame?"}
Q3 -->|"yes"| HEAP
Q3 -->|"no"| Q4{"Size unknown at compile time,<br/>or too large for the stack?"}
Q4 -->|"yes"| HEAP
Q4 -->|"no"| STACK["stays on the stack"]
class V neutral
class Q1,Q2,Q3,Q4 queue
class HEAP memory
class STACK computeHow it really works#
The rule#
A value may live on the stack only if no reference to it survives the function. The compiler builds a graph of where every pointer can flow, across function boundaries, and inlining makes this more precise: once a callee is inlined into its caller, the compiler can see the whole flow and often keeps things on the caller’s stack.
new(T) and &T{} do not mean “heap”, and a plain var x T does not mean “stack”. Only
the flow of the address matters.
Reading the compiler’s report#
go build -gcflags=-m . # decisions
go build -gcflags='-m -m' . # decisions with the reasoning chain
For the program below, the lines that matter are:
moved to heap: v byPointer: &v is returned
make([]int, 8) does not escape fixedSlice: constant small size, stays on the stack
make([]int, n) does not escape dynamicSlice: does not escape — yet it still allocates (see below)
v escapes to heap toInterface: v is boxed into an interface for fmt.Sprint
moved to heap: n counter: n is captured by the returned closure
dst does not escape fill: the callee never keeps its argumentThree phrases to recognize:
| Phrase | Meaning |
|---|---|
moved to heap: x | A named variable had to be heap-allocated |
x escapes to heap | An expression’s value (often a boxed interface, a literal, a make) is heap-allocated |
x does not escape | The value may stay on the stack; a pointer parameter is not retained by the function |
“Does not escape” is necessary for stack allocation but not sufficient: make([]int, n)
with an n unknown at compile time cannot be given a fixed slot in the frame. Recent compilers
keep such slices on the stack when they turn out to be small (tens of bytes) and fall back to
the heap otherwise — which is why case 5 allocates even though nothing escapes.
The common causes#
| Cause | Example | What to do |
|---|---|---|
| Returning a pointer to a local | return &v | Return the value if it is small; or accept it |
| Storing into something longer-lived | cache[k] = &v, ch <- &v, obj.field = &v | Inherent if the value must persist |
| Conversion to an interface | fmt.Println(v), log.Print(v), any(v) | Keep fmt/logging out of hot loops; use typed APIs |
| Call through an interface or function value | w.Write(buf) with unknown w | The compiler must assume the worst about buf |
| Closure outliving its frame | return func() { n++ }, go func() { use(v) }() | Pass values as arguments where possible |
| Unknown or large size | make([]T, n), arrays over the frame limit | Give a constant capacity; reuse a buffer |
| Slice growth | append beyond capacity | Preallocate |
| Pointer fields | s.p = &local | Store values, or indexes |
Patterns that keep values on the stack#
- Return values, not pointers, for small structs. A 24- or 48-byte struct returned by value is copied in registers or on the stack — cheaper than an allocation.
- Let the caller allocate.
func Read(p []byte) (int, error)— the shape ofio.Reader— means the callee allocates nothing, and the caller can use a stack array or a reused buffer.strconv.AppendInt(dst, n, 10)andfmt.Appendffollow the same idea. - Constant sizes.
var buf [64]byte; b := buf[:0]gives a stack-backed slice. - Small functions get inlined, and inlining widens what the analysis can prove. This is
why constructors like
NewThing()returning a pointer frequently do not allocate: after inlining, the pointer never leaves the caller. - Avoid
anyon hot paths.
What escape analysis is not#
It is not a performance oracle. An allocation in code that runs once at start-up is irrelevant;
only measure-then-fix (V.01) tells you which ones matter. And decisions change between compiler
versions — usually for the better. Go 1.25 and 1.26 both extended stack allocation of slice
backing stores. Treat -m output as today’s facts, not as a contract.
Code#
Predict each case before running, then check with go build -gcflags=-m.
// escape.go — eight small functions; predict stack or heap, then ask the compiler.
package main
import (
"fmt"
"testing"
)
type Vec struct{ X, Y, Z float64 }
// 1. Returned by value: copied out, nothing escapes.
func byValue() Vec {
v := Vec{1, 2, 3}
return v
}
// 2. Address returned: v must outlive the frame.
func byPointer() *Vec {
v := Vec{1, 2, 3}
return &v
}
// 3. Address taken but used only locally.
func localPointer() float64 {
v := Vec{1, 2, 3}
p := &v
return p.X + p.Y
}
// 4. Size known and small: the backing array stays on the stack.
func fixedSlice() int {
s := make([]int, 8)
for i := range s {
s[i] = i
}
return s[3]
}
// 5. Size known only at run time (noinline keeps the compiler from seeing the constant).
//
//go:noinline
func dynamicSlice(n int) int {
s := make([]int, n)
return len(s)
}
// 6. Stored in an interface that is passed to a function the compiler cannot see through.
func toInterface(v Vec) string {
return fmt.Sprint(v)
}
// 7. Captured by a closure that is returned.
func counter() func() int {
n := 0
return func() int { n++; return n }
}
// 8. Caller provides the buffer: nothing for the callee to allocate.
func fill(dst []float64) {
for i := range dst {
dst[i] = float64(i)
}
}
func useFill() float64 {
var buf [16]float64
fill(buf[:])
return buf[5]
}
var sink any
func main() {
cases := []struct {
name string
f func()
}{
{"1 byValue", func() { _ = byValue() }},
{"2 byPointer", func() { sink = byPointer() }},
{"3 localPointer", func() { _ = localPointer() }},
{"4 fixedSlice", func() { _ = fixedSlice() }},
{"5 dynamicSlice(1000)", func() { _ = dynamicSlice(1000) }},
{"6 toInterface", func() { _ = toInterface(Vec{1, 2, 3}) }},
{"7 counter", func() { sink = counter() }},
{"8 useFill", func() { _ = useFill() }},
}
for _, c := range cases {
fmt.Printf("%-22s %.0f allocs\n", c.name, testing.AllocsPerRun(100, c.f))
}
}
Remember this#
- The compiler, not
newor&, decides stack versus heap — by asking whether a reference can outlive the function. go build -gcflags=-mprints every decision.- Interfaces, closures, returned pointers and unknown sizes are the usual reasons for escape.
- Return small values, let callers supply buffers, keep sizes constant.
Try it#
- Run
escape.go, then build it with-gcflags=-mand match each line of the report to a function. - Remove
//go:noinlinefromdynamicSlice. Why does case 5 stop allocating? - Rewrite
toInterfaceto produce the same string withstrconv.AppendFloatinto a caller-supplied buffer, with zero allocations.
Check yourself#
- What question does escape analysis answer?
- Why does passing a value to
fmt.Printlnusually make it escape? - Why can a
makethat “does not escape” still allocate on the heap?