Escape Analysis in Go: Why Your Local Variable Escaped

Go's escape analysis builds a weighted graph of variables and assignments, then solves it to decide which locals can live on the stack and which must be heap-allocated.

TL;DR: The Go compiler refuses to put a stack pointer in the heap, so every variable that could outlive its frame has to be heap-allocated. Escape analysis works out which ones those are by building a weighted graph of variables and assignments, walking it looking for paths where an address outlives its owner, and stamping each variable EscNone (stack) or EscHeap (heap). A closure that captures a variable by reference is enough to do it.

Everyone has seen the message:

text
./main.go:14:14: variable x escapes to heap

It shows up when you were expecting a stack allocation and got a heap one instead. It is also, annoyingly, the first thing you check when a benchmark shows 40 nanoseconds of allocator overhead per iteration.

The interesting part is what the compiler is actually doing when it prints that. It is not “I saw a pointer, so I gave up”. The Go escape analysis is a real data-flow analysis, and understanding it tells you exactly how to write code that stays on the stack.

This is a walk through how it works, based on reading the Go 1.28 development tree (src/cmd/compile/internal/escape/).

The two invariants

Everything in the escape analyzer is in service of exactly two rules. From the top of escape.go:

go
// src/cmd/compile/internal/escape/escape.go:23
// Escape analysis.
//
// Here we analyze functions to determine which Go variables
// (including implicit allocations such as calls to "new" or "make",
// composite literals, etc.) can be allocated on the stack. The two
// key invariants we have to ensure are: (1) pointers to stack objects
// cannot be stored in the heap, and (2) pointers to a stack object
// cannot outlive that object (e.g., because the declaring function
// returned and destroyed the object's stack frame, or its space is
// reused across loop iterations for logically distinct variables).
//
// We implement this with a static data-flow analysis of the AST.
// First, we construct a directed weighted graph where vertices
// (termed "locations") represent variables allocated by statements
// and expressions, and edges represent assignments between variables
// (with weights representing addressing/dereference counts).
//
// Next we walk the graph looking for assignment paths that might
// violate the invariants stated above. If a variable v's address is
// stored in the heap or elsewhere that may outlive it, then v is
// marked as requiring heap allocation.
//
// To support interprocedural analysis, we also record data-flow from
// each function's parameters to the heap and to its result
// parameters. This information is summarized as "parameter tags",
// which are used at static call sites to improve escape analysis of
// function arguments.

Read that third paragraph carefully, because it is the whole design. It is a directed weighted graph, where:

  • Vertices are locations — one per variable declaration, and one per allocating expression (new, make, a composite literal, a closure).
  • Edges are assignments, and the weight is a dereference count.

The weight is the trick. Every assignment in Go is one of four shapes, and the analyzer records the difference between address-of operations and dereference operations:

text
    p = &q    // -1
    p = q     //  0
    p = *q    //  1
    p = **q   //  2

A negative weight means “an address flows along this edge”. A positive weight means “the pointed-to value flows along this edge”. The escape analysis is then a search for a path with a negative weight that reaches the heap — because a negative-weight path to the heap means “somebody stored the address of this variable into memory the GC can reach”, which is invariant (1) violated.

The whole graph, drawn:

text
        [heap]  (attrEscapes)
            ^
            |  edge, derefs = -1   (the address flows here)
            |
    +-------+-------+
    |               |
 [loc x]        [loc y]         <- locations (vertices)
    |               |
    | edge derefs=0 | edge derefs=1   (the value flows here)
    v               v
 [loc p]        [loc *q]

   loop depth and closure nesting decide whether the root
   outlives the source location

Locations, holes, and edges

The graph has three node types. First the location, one per variable:

go
// src/cmd/compile/internal/escape/graph.go:41
// A location represents an abstract location that stores a Go
// variable.
type location struct {
	n         ir.Node  // represented variable or expression, if any
	curfn     *ir.Func // enclosing function
	edges     []edge   // incoming edges
	loopDepth int      // loopDepth at declaration

	// resultIndex records the tuple index (starting at 1) for
	// PPARAMOUT variables within their function's result type.
	// For non-PPARAMOUT variables it's 0.
	resultIndex int

	// derefs and walkgen are used during walk to track the
	// minimal dereferences from the walk root.
	derefs  int // >= -1
	walkgen uint32

	// queuedWalkAll is used by walkAll to track whether this location is
	// in its work queue.
	queuedWalkAll bool

	// queuedWalk is used by walk to track whether this location is
	// in its work queue. The value is the walkgen when this location was
	// last queued for walk, or 0 if it's not currently queued.
	queuedWalk uint32

	// attrs is a bitset of location attributes.
	attrs locAttr

	// paramEsc records the represented parameter's leak set.
	paramEsc leaks

	captured   bool // has a closure captured this variable?
	reassigned bool // has this variable been reassigned?
	addrtaken  bool // has this variable's address been taken?
	param      bool // is this variable a parameter (ONAME of class ir.PPARAM)?
	paramOut   bool // is this variable an out parameter (ONAME of class ir.PPARAMOUT)?
}

Three fields in there do most of the interesting work later: addrtaken, reassigned, and captured decide whether a closure can capture a variable by value; resultIndex decides whether a leak goes to “the heap” or “result number 2”.

The edge carries the weight:

go
// src/cmd/compile/internal/escape/graph.go:107
// An edge represents an assignment edge between two Go variables.
type edge struct {
	src    *location
	derefs int // >= -1
	notes  *note
}

And the hole is the analyzer’s way of saying “a value is flowing into this location, N dereferences deep”:

go
// src/cmd/compile/internal/escape/graph.go:140
// A hole represents a context for evaluation of a Go
// expression. E.g., when evaluating p in "x = **p", we'd have a hole
// with dst==x and derefs==2.
type hole struct {
	dst    *location
	derefs int // >= -1
	notes  *note

	// addrtaken indicates whether this context is taking the address of
	// the expression, independent of whether the address will actually
	// be stored into a variable.
	addrtaken bool
}

hole.addr() shifts the deref count down by one (an address-of), and hole.deref() shifts it up by one (a dereference). The analyzer is essentially threading a single integer through the entire program.

Where the edges come from

The analyzer walks the AST and turns every assignment into an edge. The interesting one is flow, which is where the “address escapes” decision actually gets made:

go
// src/cmd/compile/internal/escape/graph.go:193
func (b *batch) flow(k hole, src *location) {
	if k.addrtaken {
		src.addrtaken = true
	}

	dst := k.dst
	if dst == &b.blankLoc {
		return
	}
	if dst == src && k.derefs >= 0 { // dst = dst, dst = *dst, ...
		return
	}
	if dst.hasAttr(attrEscapes) && k.derefs < 0 { // dst = &src
		if base.Flag.LowerM >= 2 || logopt.Enabled() {
			pos := base.FmtPos(src.n.Pos())
			if base.Flag.LowerM >= 2 {
				fmt.Printf("%s: %v escapes to heap in %v:\n", pos, src.n, ir.FuncName(src.curfn))
			}
			explanation := b.explainFlow(pos, dst, src, k.derefs, k.notes, []*logopt.LoggedOpt{})
			if logopt.Enabled() {
				var e_curfn *ir.Func // TODO(mdempsky): Fix.
				logopt.LogOpt(src.n.Pos(), "escapes", "escape", ir.FuncName(e_curfn), fmt.Sprintf("%v escapes to heap", src.n), explanation)
			}

		}
		src.attrs |= attrEscapes | attrPersists | attrMutates | attrCalls
		return
	}

	// TODO(mdempsky): Deduplicate edges?
	dst.edges = append(dst.edges, edge{src: src, derefs: k.derefs, notes: k.notes})
}

Read the condition dst.hasAttr(attrEscapes) && k.derefs < 0. That means: “the destination is the heap, and what is flowing into it is an address”. In that case the source variable escapes, immediately and irrevocably. Otherwise the assignment just becomes an edge and the analysis continues.

There are four synthetic locations set up at the start of every batch:

go
// src/cmd/compile/internal/escape/escape.go:145
func Batch(fns []*ir.Func, reassignOracles map[*ir.Func]*ir.ReassignOracle) {
	var b batch
	b.heapLoc.attrs = attrEscapes | attrPersists | attrMutates | attrCalls
	b.mutatorLoc.attrs = attrMutates
	b.calleeLoc.attrs = attrCalls
	b.reassignOracles = reassignOracles

The heap location has attrEscapes, which is precisely the flag that flow above checks. The mutator and callee locations exist so the analyzer can distinguish “this pointer gets written through” (which is a mutation of the pointee, not an escape of the pointer) from “this pointer is stored somewhere long-lived”.

Solving it: the walk

Once the graph is built, walkAll solves it. The solver is a Bellman-Ford variant, because the graph has negative edges:

go
// src/cmd/compile/internal/escape/solve.go:135
// walk computes the minimal number of dereferences from roots that were
// scheduled with state s to all other locations. A root's live attributes may
// have grown since it was scheduled; the resulting new state is walked in a
// later round.
func (b *batch) walk(s walkState, roots []*location, walkgen uint32, enqueue func(*location), todo *queue) {
	// The data flow graph has negative edges (from addressing
	// operations), so we use the Bellman-Ford algorithm. However,
	// we don't have to worry about infinite negative cycles since
	// we bound intermediate dereference counts to 0.

	diagnose := base.Flag.LowerM >= 2 || logopt.Enabled()

And the actual escape decision is inside the loop:

go
// src/cmd/compile/internal/escape/solve.go:175
		// If l.derefs < 0, then l's address flows to root.
		addressOf := derefs < 0
		if addressOf {
			// For a flow path like "root = &l; l = x",
			// l's address flows to root, but x's does
			// not. We recognize this by lower bounding
			// derefs at 0.
			derefs = 0

			// If l's address flows somewhere that
			// outlives it, then l needs to be heap
			// allocated.
			if s.outlives(b, l) {
				if !l.hasAttr(attrEscapes) && diagnose {
					if base.Flag.LowerM >= 2 {
						fmt.Printf("%s: %v escapes to heap in %v:\n", base.FmtPos(l.n.Pos()), l.n, ir.FuncName(l.curfn))
					}
					root := walkRoot(l, paths)
					explanation := b.explainPath(root, l, paths[l])
					if logopt.Enabled() {
						var e_curfn *ir.Func // TODO(mdempsky): Fix.
						logopt.LogOpt(l.n.Pos(), "escape", "escape", ir.FuncName(e_curfn), fmt.Sprintf("%v escapes to heap", l.n), explanation)
					}
				}
				newAttrs |= attrEscapes | attrPersists | attrMutates | attrCalls
			} else
			// If l's address flows to a persistent location, then l needs
			// to persist too.
			if s.hasAttr(attrPersists) {
				newAttrs |= attrPersists
			}
		}

So the rule reduces to: if the address of l reaches a root that outlives l, then l escapes.

“Outlives” is the interesting question

What outlives what? That is the outlives function, and it is where most of the subtlety lives:

go
// src/cmd/compile/internal/escape/solve.go:366
// outlives reports whether values stored in roots with state s may survive
// beyond other's lifetime if stack allocated.
func (s walkState) outlives(b *batch, other *location) bool {
	// The heap outlives everything.
	if s.hasAttr(attrEscapes) {
		return true
	}

	// Pseudo-locations that don't really exist.
	if s.sink == &b.mutatorLoc || s.sink == &b.calleeLoc {
		return false
	}

	// We don't know what callers do with returned values, so
	// pessimistically we need to assume they flow to the heap and
	// outlive everything too.
	if s.sink != nil && s.sink.paramOut {
		// Exception: Closures can return locations allocated outside of
		// them without forcing them to the heap, if we can statically
		// identify all call sites. For example:
		//
		//	var u int  // okay to stack allocate
		//	fn := func() *int { return &u }()
		//	*fn() = 42
		if ir.ContainsClosure(other.curfn, s.curfn) && !s.curfn.ClosureResultsLost() {
			return false
		}

		return true
	}

	// If root and other are within the same function, then root
	// outlives other if it was declared outside other's loop
	// scope. For example:
	//
	//	var l *int
	//	for {
	//		l = new(int) // must heap allocate: outlives for loop
	//	}
	if s.curfn == other.curfn && s.loopDepth < other.loopDepth {
		return true
	}

	// If other is declared within a child closure of where root is
	// declared, then root outlives it. For example:
	//
	//	var l *int
	//	func() {
	//		l = new(int) // must heap allocate: outlives call frame (if not inlined)
	//	}()
	if ir.ContainsClosure(s.curfn, other.curfn) {
		return true
	}

	return false
}

Four cases:

  1. The heap outlives everything. Obvious.
  2. The mutator and callee sinks outlive nothing — writing through a pointer is not the same as storing the pointer.
  3. A result parameter outlives everything, because the caller might stash the returned pointer somewhere. The one exception is a closure whose call sites the compiler can see.
  4. Loop depth and closure nesting decide the rest. A variable declared outside a loop outlives one declared inside it (the loop’s space gets reused). A variable declared in an enclosing function outlives one declared in a closure.

That last rule is why this code allocates on the heap:

go
var l *int
for {
    l = new(int) // must heap allocate: outlives for loop
}

The new(int) result is assigned to l, which lives outside the loop. On the second iteration, the int from the first iteration is dead, so its memory could be reused — but l still points at it. Heap allocation is the only safe answer.

The tags: EscNone, EscHeap, EscNever

When the analysis is done, finish stamps every node with its verdict. The flags live in ir/node.go and are deliberately tiny:

go
// src/cmd/compile/internal/ir/node.go:573
const (
	EscUnknown = iota
	EscNone    // Does not escape to heap, result, or parameters.
	EscHeap    // Reachable from the heap
	EscNever   // By construction will not escape.
)

EscNever is the interesting one: it means “this variable cannot possibly escape, by construction of the program, not by analysis”. The escape analyzer uses it for the defer record, which is a compiler-synthesised variable:

go
// src/cmd/compile/internal/escape/call.go:238
func (e *escape) goDeferStmt(n *ir.GoDeferStmt) {
	k := e.heapHole()
	if n.Op() == ir.ODEFER && e.loopDepth == 1 && n.DeferAt == nil {
		// Top-level defer arguments don't escape to the heap,
		// but they do need to last until they're invoked.
		k = e.later(e.discardHole())

		// force stack allocation of defer record, unless
		// open-coded defers are used (see ssa.go)
		n.SetEsc(ir.EscNever)
	}

Note that this enum has exactly four values. There is no “escapes to result” state here — that direction is carried separately, in the leaks set described below, so that a parameter can be recorded as leaking to result 0 without being recorded as leaking to the heap.

And here is the actual stamping, with the diagnostic messages:

go
// src/cmd/compile/internal/escape/escape.go:464
		if loc.hasAttr(attrEscapes) {
			if n.Op() == ir.ONAME {
				if base.Flag.CompilingRuntime {
					base.ErrorfAt(n.Pos(), 0, "%v escapes to heap, not allowed in runtime", n)
				}
				if base.Flag.LowerM != 0 {
					base.WarnfAt(n.Pos(), "moved to heap: %v", n)
				}
			} else {
				if base.Flag.LowerM != 0 && !goDeferWrapper {
					if n.Op() == ir.OAPPEND {
						base.WarnfAt(n.Pos(), "append escapes to heap")
					} else {
						base.WarnfAt(n.Pos(), "%v escapes to heap", n)
					}
				}
				if logopt.Enabled() {
					var e_curfn *ir.Func // TODO(mdempsky): Fix.
					logopt.LogOpt(n.Pos(), "escape", "escape", ir.FuncName(e_curfn))
				}
			}
			n.SetEsc(ir.EscHeap)
		} else {

So the compiler’s -m output distinguishes two cases that people usually conflate:

  • moved to heap: x — x is a variable (ONAME) that got promoted.
  • x escapes to heap — x is an expression (a new, a composite literal, an append) whose value lives on the heap.

Interprocedural: parameter tags

The analyzer does not stop at one function. If it can see a callee’s definition, it can compute what that callee does with its parameters, and bake that into the callee’s export data as a “leak set”:

go
// src/cmd/compile/internal/escape/leaks.go:13
// A leaks represents a set of assignment flows from a parameter to
// the heap, mutator, callee, or to any of its function's (first
// numEscResults) result parameters.
type leaks [8]uint8

const (
	leakHeap = iota
	leakMutator
	leakCallee
	leakResult0
)

const numEscResults = len(leaks{}) - leakResult0

Eight bytes: one for the heap, one for the mutator, one for the callee, and up to five result parameters. Each entry is a minimum dereference count, stored biased by one so that zero means “no flow”. The encoding is deliberately compact because it goes into every package’s export data:

go
// src/cmd/compile/internal/escape/leaks.go:97
// Encode converts l into a binary string for export data.
func (l leaks) Encode() string {
	if l.Heap() == 0 {
		// Space optimization: empty string encodes more
		// efficiently in export data.
		return ""
	}
	if s, ok := leakTagCache[l]; ok {
		return s
	}

	n := len(l)
	for n > 0 && l[n-1] == 0 {
		n--
	}
	s := "esc:" + string(l[:n])
	leakTagCache[l] = s
	return s
}

That "esc:" prefix plus raw bytes is what you see in export data. The compiler reads it back at call sites and uses it to decide where an argument flows:

go
// src/cmd/compile/internal/escape/call.go:403
	// Call to previously tagged function.

	var tagKs []hole
	esc := parseLeaks(param.Note)

	if x := esc.Heap(); x >= 0 {
		tagKs = append(tagKs, e.heapHole().shift(x))
	}
	if x := esc.Mutator(); x >= 0 {
		tagKs = append(tagKs, e.mutatorHole().shift(x))
	}
	if x := esc.Callee(); x >= 0 {
		tagKs = append(tagKs, e.calleeHole().shift(x))
	}

	if ks != nil {
		for i := 0; i < numEscResults; i++ {
			if x := esc.Result(i); x >= 0 {
				tagKs = append(tagKs, ks[i].shift(x))
			}
		}
	}

	return e.teeHole(tagKs...)

So when you pass a pointer to a function whose body you cannot see (an assembly function, or one in another package compiled earlier), the compiler uses the tag. If the tag says “this parameter leaks to the heap”, your argument escapes. If it says “does not escape”, your argument can stay on the stack.

“Leaks to result” — the rule people misread

The most common source of confusion is the difference between a parameter that leaks to the heap and one that leaks to a result. They are different flows, and they are recorded separately.

go
// src/cmd/compile/internal/escape/graph.go:118
// leak records that parameter l leaks to sink.
func (l *location) leakTo(sink *location, derefs int) {
	// If sink is a result parameter that doesn't escape (#44614)
	// and we can fit return bits into the escape analysis tag,
	// then record as a result leak.
	if !sink.hasAttr(attrEscapes) && sink.isName(ir.PPARAMOUT) && sink.curfn == l.curfn {
		ri := sink.resultIndex - 1
		if ri < numEscResults {
			// Leak to result parameter.
			l.paramEsc.AddResult(ri, derefs)
			return
		}
	}

	// Otherwise, record as heap leak.
	l.paramEsc.AddHeap(derefs)
}

A parameter that leaks only to result 0 is not a heap leak. The function returns the pointer, so the caller decides what to do with it. This is what makes the classic “identity function” free:

go
func id(p *int) *int { return p }

p leaks to result 0, not to the heap. The caller’s variable stays on the stack. But:

go
func keep(p *int) *int { global = p; return p }

leaks to the heap, so the caller’s variable is promoted. The -m output makes the distinction visible:

go
// src/cmd/compile/internal/escape/escape.go:670
	for i := 0; i < numEscResults; i++ {
		if x := esc.Result(i); x >= 0 {
			res := sig.Result(i).Nname.Sym().Name
			base.WarnfAt(pos, "leaking param: %v to result %v level=%d", name, res, x)
			warned = true
		}
	}

leaking param: p to result r level=1 means “the pointer inside p (one dereference deep) comes back out as result r”. That is a much weaker statement than leaking param: p.

Closures: why capturing forces the heap

Now the case everybody hits. When a closure captures a variable, the analyzer has to decide: by value or by reference?

go
// src/cmd/compile/internal/escape/escape.go:262
func (b *batch) flowClosure(k hole, clo *ir.ClosureExpr) {
	for _, cv := range clo.Func.ClosureVars {
		n := cv.Canonical()
		loc := b.oldLoc(cv)
		if !loc.captured {
			base.FatalfAt(cv.Pos(), "closure variable never captured: %v", cv)
		}

		// Capture by value for variables <= 128 bytes that are never reassigned.
		n.SetByval(!loc.addrtaken && !loc.reassigned && n.Type().Size() <= 128)
		if !n.Byval() {
			n.SetAddrtaken(true)
			if n.Sym().Name == typecheck.LocalDictName {
				base.FatalfAt(n.Pos(), "dictionary variable not captured by value")
			}
		}
	}

Three conditions, all of which must hold to capture by value:

  1. The variable’s address was never taken (!loc.addrtaken).
  2. The variable was never reassigned (!loc.reassigned).
  3. The variable is at most 128 bytes.

If any of those fails, the variable is captured by reference. Then the address of the variable is stored in the closure record. And if the closure record itself can escape, the address of the variable has reached the heap, which is exactly invariant (1).

Note the “128 bytes” limit. That is not arbitrary — it is the size at which copying the value into the closure record becomes as expensive as heap-allocating it. Larger captures go by reference so the closure does not carry a fat copy of a big struct around.

Here is the closure case in expr.go, where the closure expression itself is spilled into the graph:

go
// src/cmd/compile/internal/escape/expr.go:230
	case ir.OCLOSURE:
		n := n.(*ir.ClosureExpr)
		k = e.spill(k, n)
		e.closures = append(e.closures, closure{k, n})

		if fn := n.Func; fn.IsClosure() {
			for _, cv := range fn.ClosureVars {
				if loc := e.oldLoc(cv); !loc.captured {
					loc.captured = true

					// Ignore reassignments to the variable in straightline code
					// preceding the first capture by a closure.
					if loc.loopDepth == e.loopDepth {
						loc.reassigned = false
					}
				}
			}

And spill is the generic “this expression allocates storage” operation:

go
// src/cmd/compile/internal/escape/expr.go:336
// spill allocates a new location associated with expression n, flows
// its address to k, and returns a hole that flows values to it. It's
// intended for use with most expressions that allocate storage.
func (e *escape) spill(k hole, n ir.Node) hole {
	loc := e.newLoc(n, false)
	e.flow(k.addr(n, "spill"), loc)
	return loc.asHole()
}

A spill creates a location and flows its address into the destination. If the destination is the heap, the expression escapes.

Some things cannot be stack allocated at all

Even if nothing escapes, some things are too big or too aligned. That check is separate from the graph analysis:

go
// src/cmd/compile/internal/escape/utils.go:186
	if n.Type().Size() > ir.MaxStackVarSize {
		return "too large for stack"
	}
	if n.Type().Alignment() > int64(types.PtrSize) {
		return "too aligned for stack"
	}

	if (n.Op() == ir.ONEW || n.Op() == ir.OPTRLIT) && n.Type().Elem().Size() > ir.MaxImplicitStackVarSize {
		return "too large for stack"
	}
	if (n.Op() == ir.ONEW || n.Op() == ir.OPTRLIT) && n.Type().Elem().Alignment() > int64(types.PtrSize) {
		return "too aligned for stack"
	}

And the limits themselves:

go
// src/cmd/compile/internal/ir/cfg.go:8
	// MaxStackVarSize is the maximum size variable which we will allocate on the stack.
	// This limit is for explicit variable declarations like "var x T" or "x := ...".
	// Note: the flag smallframes can update this value.
	MaxStackVarSize = int64(128 * 1024)

	// MaxImplicitStackVarSize is the maximum size of implicit variables that we will allocate on the stack.
	//   p := new(T)          allocating T on the stack
	//   p := &T{}            allocating T on the stack
	//   s := make([]T, n)    allocating [n]T on the stack
	//   s := []byte("...")   allocating [n]byte on the stack
	// Note: the flag smallframes can update this value.
	MaxImplicitStackVarSize = int64(64 * 1024)

128 KiB for named variables, 64 KiB for implicit ones like new(T). Go’s stacks grow by copying, and a giant stack frame makes every stack growth expensive, so the compiler draws a line.

From EscHeap to runtime.newobject

The escape verdict becomes code in the SSA backend. The SSA builder asks the IR what the verdict is, and if it is EscHeap, it allocates:

go
// src/cmd/compile/internal/ssagen/ssa.go:1789
	case ir.ODCL:
		n := n.(*ir.Decl)
		if v := n.X; v.Esc() == ir.EscHeap {
			s.newHeapaddr(v)
		}

And newHeapaddr is where the actual runtime.newobject call comes from:

go
// src/cmd/compile/internal/ssagen/ssa.go:724
// newHeapaddr allocates heap memory for n and sets its heap address.
func (s *state) newHeapaddr(n *ir.Name) {
	size := allocSize(n.Type())
	if n.Type().HasPointers() || size >= maxAggregatedHeapAllocation || size == 0 {
		s.setHeapaddr(n.Pos(), n, s.newObject(n.Type()))
		return
	}

And newObject is the runtime.newobject call site:

go
// src/cmd/compile/internal/ssagen/ssa.go:871
// newObject returns an SSA value denoting new(typ).
func (s *state) newObject(typ *types.Type) *ssa.Value {
	if typ.Size() == 0 {
		return s.newValue1A(ssaop.OpAddr, types.NewPtr(typ), ir.Syms.Zerobase, s.sb)
	}
	rtype := s.reflectType(typ)
	if specialMallocSym := s.specializedMallocSym(typ.Size(), typ.HasPointers()); specialMallocSym != nil {
		return s.rtcall(specialMallocSym, true, []*types.Type{types.NewPtr(typ)},
			s.constInt(types.Types[types.TUINTPTR], typ.Size()),
			rtype,
			s.constBool(true),
		)[0]
	}
	return s.rtcall(ir.Syms.Newobject, true, []*types.Type{types.NewPtr(typ)}, rtype)[0]
}

If the variable does not escape, none of this runs. The variable becomes a stack slot, assigned a frame offset, and lives in the function’s frame. The decision point is the ir.Name.OnStack predicate, which is a one-liner over the escape flag:

go
// src/cmd/compile/internal/ir/name.go:235
// OnStack reports whether variable n may reside on the stack.
func (n *Name) OnStack() bool {
	if n.Op() == ONAME {
		switch n.Class {
		case PPARAM, PPARAMOUT, PAUTO:
			return n.Esc() != EscHeap
		case PEXTERN, PAUTOHEAP:
			return false
		}
	}
	// Note: fmt.go:dumpNodeHeader calls all "func() bool"-typed
	// methods, but it can only recover from panics, not Fatalf.
	panic(fmt.Sprintf("%v: not a variable: %v", base.FmtPos(n.Pos()), n))
}

Reading the diagnostics

The tool for this is go build -gcflags=-m. Two levels matter:

  • -m prints the decisions: moved to heap: x, x escapes to heap, leaking param: p, p does not escape.
  • -m=2 prints the paths: the chain of flows that led to the decision.
text
./main.go:10:30: leaking param: p to result ~r0 level=1
./main.go:10:30: p does not escape
./main.go:14:14: moved to heap: x
./main.go:14:14: leaking param: content to result ~r0 level=1

That last pair is the interesting one: content is a field of x, and it escapes even though x itself did not need to move. That happens when the struct field is a pointer that gets stored somewhere.

For JSON logging of the same decisions, the compiler supports -json=... style optimization logs through the logopt package, which is what gopls and benchstat-style tooling read.

Trade-offs

What escape analysis buys you: a local variable that does not escape costs zero allocations. No runtime.mallocgc, no GC pressure, no cache misses on a fresh span. In a hot loop this is often the difference between 5 ns and 50 ns per iteration.

What it costs you: compile time. The analyzer is a whole-program-ish data-flow analysis over every function in the package, with a Bellman-Ford solver and interprocedural tag propagation across package boundaries. For a big package this is measurable.

Why it is conservative in specific places: several flows are deliberately pessimistic.

  • A pointer returned from a function is assumed to outlive the frame, because the compiler cannot see all the callers.
  • A pointer passed to an unknown function is assumed to leak to the heap, unless the function is annotated //go:noescape.
  • A pointer stored in a struct field is treated as escaping if any field of that struct escapes, because the analyzer does not distinguish fields within a compound variable.

Why the “leaks to result” distinction is worth caring about: it is the difference between a function that can be used in a zero-allocation data structure and one that cannot. If every parameter of your accessor leaked to the heap, every call site would allocate.

Caveats

Escape analysis is per-package, not per-program. When you compile a package, the escape tags of the exported functions of its dependencies are available, but functions in the same package are analyzed together and can be inlined into one another. A change in one file can move a variable in another file.

The 128-byte closure capture limit is a hard cut, not a heuristic. A 129-byte struct captured by a closure goes by reference, and therefore usually to the heap. There is no “small enough to be worth copying” tier below it.

//go:noinline and //go:nosplit do not affect escape analysis. They are separate pragmas. //go:noinline stops the inliner; //go:nosplit stops the function from being split across a stack growth. The only pragmas that influence escaping are //go:noescape (on a function declaration, meaning “my pointer parameters do not escape”) and //go:uintptrescapes (the opposite, for syscall-style functions).

The analyzer does not distinguish fields inside a struct. The header comment in escape.go says it plainly: x.f = u[0] is modelled as x = *u, without regard for which field. So a struct with one escaping pointer field can drag the whole struct to the heap.

Zero-sized and very large types behave oddly. A zero-sized type gets runtime.zerobase instead of an allocation. A type bigger than 64 KiB allocated implicitly is always heap-allocated, no matter what the analysis says.

append is a special case. The analyzer models append as possibly reallocating, so the result of append can be on the heap even when the input slice is on the stack. s = append(s, x) where s has spare capacity usually stays on the stack; s = append(s, x...) usually does not.

Wrapping up

The one thing to remember: escape analysis asks one question about every variable — “can the address of this thing reach memory that outlives my stack frame?” — and if the answer might be yes, the variable is heap allocated. It is not “pointers are slow”; it is a lifetime question.

The part people get wrong: “the function does not store the pointer anywhere” is not sufficient. A pointer that is returned, or captured by a closure that might outlive the frame, or assigned to a variable declared outside a loop, escapes. The -m output distinguishes these cases: leaking param: p to result ~r0 is much friendlier than leaking param: p.

What to go do about it: compile with -gcflags=-m=2 and read the flow paths for the variables you expected to stay on the stack. If a local escapes, the reason is almost always one of four things: it is captured by a closure, it is stored into a longer-lived variable, it is passed to a function the compiler cannot see into, or it is returned. Fix the one that applies and the allocation disappears.

Further reading

  • src/cmd/compile/internal/escape/escape.go — the pass driver: Batch, flowClosure, finish, paramTag, reportLeaks.
  • src/cmd/compile/internal/escape/graph.go — location, edge, hole, the location attributes, and flow, where the escape decision is made.
  • src/cmd/compile/internal/escape/solve.go — the solver: walkAll, the Bellman-Ford walk, and outlives.
  • src/cmd/compile/internal/escape/leaks.go — the parameter leak sets and their compact export-data encoding.
  • src/cmd/compile/internal/escape/expr.go — how each expression kind contributes edges, including spill and the closure case.
  • src/cmd/compile/internal/escape/call.go — call sites: parameter tags, mergedTagHole, and the append model.
  • src/cmd/compile/internal/escape/utils.go — HeapAllocReason, the size and alignment rules.
  • src/cmd/compile/internal/ir/node.go — the EscNone / EscHeap / EscNever flags.
  • src/cmd/compile/internal/ir/name.go — Name.OnStack, which turns the escape verdict into a stack-or-heap decision.
  • src/cmd/compile/internal/ssagen/ssa.go — newHeapaddr and newObject, where the verdict becomes a runtime.newobject call.
  • src/cmd/compile/internal/ir/cfg.go — MaxStackVarSize and MaxImplicitStackVarSize.
  • src/cmd/compile/internal/gc/main.go — where escape.Funcs is called in the compilation pipeline, and why it has to run before closures are transformed.

GoInternalsEscape analysisCompilerMemory