Below the API

Escape Analysis

Intermediate Intermediate 55 min Difficulty 3/5

Prerequisites 01, II.05

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 compute

How 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 argument

Three phrases to recognize:

PhraseMeaning
moved to heap: xA named variable had to be heap-allocated
x escapes to heapAn expression’s value (often a boxed interface, a literal, a make) is heap-allocated
x does not escapeThe 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#

CauseExampleWhat to do
Returning a pointer to a localreturn &vReturn the value if it is small; or accept it
Storing into something longer-livedcache[k] = &v, ch <- &v, obj.field = &vInherent if the value must persist
Conversion to an interfacefmt.Println(v), log.Print(v), any(v)Keep fmt/logging out of hot loops; use typed APIs
Call through an interface or function valuew.Write(buf) with unknown wThe compiler must assume the worst about buf
Closure outliving its framereturn func() { n++ }, go func() { use(v) }()Pass values as arguments where possible
Unknown or large sizemake([]T, n), arrays over the frame limitGive a constant capacity; reuse a buffer
Slice growthappend beyond capacityPreallocate
Pointer fieldss.p = &localStore 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 of io.Reader — means the callee allocates nothing, and the caller can use a stack array or a reused buffer. strconv.AppendInt(dst, n, 10) and fmt.Appendf follow 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 any on 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 new or &, decides stack versus heap — by asking whether a reference can outlive the function.
  • go build -gcflags=-m prints 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#

  1. Run escape.go, then build it with -gcflags=-m and match each line of the report to a function.
  2. Remove //go:noinline from dynamicSlice. Why does case 5 stop allocating?
  3. Rewrite toInterface to produce the same string with strconv.AppendFloat into a caller-supplied buffer, with zero allocations.

Check yourself#

  1. What question does escape analysis answer?
  2. Why does passing a value to fmt.Println usually make it escape?
  3. Why can a make that “does not escape” still allocate on the heap?

↑↓ navigate ↵ open