Escape analysis and allocation

Go · Expert · 15 min

Heap allocations drive GC cost; knowing what escapes lets you fix hot paths with evidence instead of guesses.

Go doesn't let you choose stack or heap — the compiler does, through escape analysis. A value stays on the goroutine's stack, freed for free when the function returns, if the compiler can prove no reference to it outlives the call. Otherwise it escapes to the heap and becomes the garbage collector's problem.

See the decisions with go build -gcflags=-m ./... (add a second -m for reasons). You'll see lines like moved to heap: x, &T{...} escapes to heap and p does not escape.

Common reasons a value escapes:

  • Its address is returned, or stored in a global, a heap object, or a channel.
  • It's converted to an interface and the callee isn't analysable — passing values to fmt.Println is the classic case.
  • A closure captures it and the closure itself escapes (e.g. it's started with go).
  • Its size isn't known at compile time, like make([]byte, n) for variable n (beyond small cases), or it's very large.

Function inlining matters: when a small constructor is inlined, its result may no longer escape. -gcflags=-m also reports what was inlined.

Goroutine stacks start small and grow by copying to a bigger stack, so the runtime adjusts pointers into the stack. That's one reason you can't hand stack pointers to C.

Techniques for hot paths, once a profile proves they matter:

  • Let callers pass in buffers (Append-style APIs such as strconv.AppendInt) instead of returning new slices.
  • Preallocate slices and maps with known sizes.
  • Reuse temporary objects with sync.Pool.
  • Avoid interface conversions and fmt in inner loops.

Measure with -benchmem or testing.AllocsPerRun; don't distort clean code for allocations that don't show up in a profile.

Example

// go build -gcflags=-m escape.go
package main

import (
	"fmt"
	"strconv"
)

type point struct{ x, y int }

func newPoint() *point { return &point{1, 2} } // &point{...} escapes to heap

func sum() int {
	p := point{3, 4} // stays on the stack
	return p.x + p.y
}

func main() {
	p := newPoint()         // inlined here: &point{...} does not escape
	fmt.Println(p.x, sum()) // args escape: converted to interface

	buf := make([]byte, 0, 32) // constant size, does not escape
	buf = strconv.AppendInt(buf, 42, 10)
	println(string(buf)) // 42
}

Remember

  • The compiler puts a value on the heap only if it may outlive its function
  • -gcflags=-m shows escape and inlining decisions
  • Interfaces, captured variables and returned pointers are common escape causes

Watch out for

  • Micro-optimising away allocations that never appear in a profile, making code harder to read for no gain.

Check yourself

  1. Why does `fmt.Println(x)` usually make x escape?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: go.dev/doc/gc-guide