Skip to content
gohacks.dev
Performance

Escape Analysis in Go and Reading the Compiler Output

Read go build -gcflags=-m for ten Go functions and match each escape to its cost: 2.2 ns on the stack, 13.7 ns and an allocation on the heap

10 min read
Summarize in AI
Flat illustration of a grid of heap cells with the garbage collector sweeping across it.

Go decides where each variable lives, the stack or the heap, and you never write which. The compiler's escape analysis makes the call: a value that cannot outlive the function that created it goes on the stack, which is free to allocate and free; a value that can is allocated on the heap, where it costs an allocation and later work for the garbage collector. Most Go performance work on allocations comes down to understanding those decisions, and the compiler will print them for you. This article reads that output for ten small functions, runs each one, and ties every message to a number: nanoseconds, bytes and allocations.

Tested with Go 1.25.6 on Apple M1 Max (10 cores, 32 GB), macOS, median of 5 runs of 200 ms each

It supports reducing allocations in hot paths and allocation profiles, which find where allocation happens. Escape analysis explains why. Related compiler topics are inlining (which changes escape results) and bounds check elimination. The cornerstone is profiling and speeding up Go.

The rule, and the tool

A value escapes when the compiler cannot prove it is unreachable after the function returns. The usual causes are:

  • returning a pointer to a local, or storing it somewhere that outlives the call (a global, a field of something that escapes, a channel);
  • capturing a variable in a closure that escapes;
  • converting a value to an interface that escapes (the boxed copy goes on the heap);
  • a size the compiler cannot bound: a make with a non constant size, or a constant above the stack limit;
  • passing to a function that the compiler cannot see into (an interface method, a function value).

go build -gcflags=-m prints one line per decision. -m=2 adds the reasoning. The lab filters the output down to the lines that matter.

The functions

Ten functions, each a different case. The first group is about pointers:

shapes.goGo
package main

import "fmt"

type point struct{ x, y, z int }

var sink any
var global *point

// value stays on the stack: it is returned by value.
func byValue(i int) point { return point{i, i + 1, i + 2} }

// pointer escapes: the caller receives an address into this frame.
//
//go:noinline
func byPointer(i int) *point { return &point{i, i + 1, i + 2} }

// inlinedPointer would escape, but when it is inlined the pointer never leaves the caller.
func inlinedPointer(i int) *point { return &point{i, i + 1, i + 2} }

func useInlined(i int) int {
	p := inlinedPointer(i)
	return p.x + p.y + p.z
}

func useNoInline(i int) int {
	p := byPointer(i)
	return p.x + p.y + p.z
}

// toInterface boxes an int in an interface that outlives the call.
//
//go:noinline
func toInterface(i int) { sink = i + 1000 }

// closure captures counter and is returned, so counter moves to the heap.
//
//go:noinline
func closure() func() int {
	counter := 0
	return func() int { counter++; return counter }
}

// smallConst has a constant size below the 64 KB limit for implicit allocations.
//
//go:noinline
func smallConst() int {
	b := make([]byte, 4096)
	b[0] = 1
	return int(b[0]) + len(b)
}

// variableSize has a size the compiler cannot know.
//
//go:noinline
func variableSize(n int) int {
	b := make([]byte, n)
	b[0] = 1
	return int(b[0]) + len(b)
}

// bigConst is a constant size above the 64 KB limit.
//
//go:noinline
func bigConst() int {
	b := make([]byte, 100000)
	b[0] = 1
	return int(b[0]) + len(b)
}

// printed passes a value to fmt, whose parameters are interfaces.
//
//go:noinline
func printed(i int) string { return fmt.Sprint(i + 1000) }

// stored keeps a pointer in a global.
//
//go:noinline
func stored(i int) { global = &point{i, i, i} }

And a program that benchmarks each, using a small helper that takes the median of five runs:

main.goGo
package main

import (
	"fmt"
	"testing"
)

func main() {
	var x int
	cases := []struct {
		name string
		f    func(b *testing.B)
	}{
		{"return a struct by value", func(b *testing.B) {
			for b.Loop() {
				x += byValue(3).x
			}
		}},
		{"return a pointer (not inlined)", func(b *testing.B) {
			for b.Loop() {
				x += useNoInline(3)
			}
		}},
		{"return a pointer (inlined)", func(b *testing.B) {
			for b.Loop() {
				x += useInlined(3)
			}
		}},
		{"box an int in an interface", func(b *testing.B) {
			for b.Loop() {
				toInterface(3)
			}
		}},
		{"closure returned to the caller", func(b *testing.B) {
			for b.Loop() {
				x += closure()()
			}
		}},
		{"make 4096 bytes (constant)", func(b *testing.B) {
			for b.Loop() {
				x += smallConst()
			}
		}},
		{"make n bytes (variable)", func(b *testing.B) {
			n := 4096
			for b.Loop() {
				x += variableSize(n)
			}
		}},
		{"make 100000 bytes (constant)", func(b *testing.B) {
			for b.Loop() {
				x += bigConst()
			}
		}},
		{"fmt.Sprint(int)", func(b *testing.B) {
			for b.Loop() {
				x += len(printed(3))
			}
		}},
		{"store a pointer in a global", func(b *testing.B) {
			for b.Loop() {
				stored(3)
			}
		}},
	}
	for _, c := range cases {
		bench(c.name, c.f)
	}
	fmt.Println(x > 0)
}
bench.goGo
package main

import (
	"flag"
	"fmt"
	"sort"
	"testing"
)

func init() {
	testing.Init()
	flag.Set("test.benchtime", "200ms")
}

// bench runs f five times and prints the median, which damps the noise of a laptop.
func bench(name string, f func(b *testing.B)) {
	const runs = 5
	ns := make([]float64, 0, runs)
	bs := make([]int64, 0, runs)
	as := make([]int64, 0, runs)
	for i := 0; i < runs; i++ {
		r := testing.Benchmark(f)
		ns = append(ns, float64(r.T.Nanoseconds())/float64(r.N))
		bs = append(bs, r.AllocedBytesPerOp())
		as = append(as, r.AllocsPerOp())
	}
	sort.Float64s(ns)
	sort.Slice(bs, func(i, j int) bool { return bs[i] < bs[j] })
	sort.Slice(as, func(i, j int) bool { return as[i] < as[j] })
	fmt.Printf("%-38s %12.1f ns/op %9d B/op %6d allocs/op\n", name, ns[runs/2], bs[runs/2], as[runs/2])
}
Shell
set -e
echo "== what the compiler decided (go build -gcflags=-m)"
go build -gcflags='-m' -o /dev/null . 2>&1 | grep -E 'shapes.go' | grep -E 'escapes to heap|moved to heap|does not escape' | grep -vE 'leaking param|\.\.\. argument' | sed -E 's/^\.\/shapes.go:([0-9]+):[0-9]+: /line \1: /'
echo
echo "== what each costs"
go run .
Text
== what the compiler decided (go build -gcflags=-m)
line 16: &point{...} escapes to heap
line 22: &point{...} does not escape
line 34: i + 1000 escapes to heap
line 40: moved to heap: counter
line 41: func literal escapes to heap
line 48: make([]byte, 4096) does not escape
line 57: make([]byte, n) does not escape
line 66: make([]byte, 100000) escapes to heap
line 74: i + 1000 escapes to heap
line 79: &point{...} escapes to heap
line 19: &point{...} escapes to heap

== what each costs
return a struct by value                        2.2 ns/op         0 B/op      0 allocs/op
return a pointer (not inlined)                 13.7 ns/op        24 B/op      1 allocs/op
return a pointer (inlined)                      2.2 ns/op         0 B/op      0 allocs/op
box an int in an interface                      9.7 ns/op         8 B/op      1 allocs/op
closure returned to the caller                 21.9 ns/op        24 B/op      2 allocs/op
make 4096 bytes (constant)                      2.2 ns/op         0 B/op      0 allocs/op
make n bytes (variable)                       440.2 ns/op      4096 B/op      1 allocs/op
make 100000 bytes (constant)                 5518.5 ns/op    106496 B/op      1 allocs/op
fmt.Sprint(int)                                49.1 ns/op        16 B/op      2 allocs/op
store a pointer in a global                    13.6 ns/op        24 B/op      1 allocs/op
true

What the compiler said

The filtered output, by source line:

  • line 16, &point{...} escapes to heap (byPointer)
  • line 22, &point{...} does not escape (inlinedPointer, as inlined into useInlined)
  • line 34, i + 1000 escapes to heap (toInterface)
  • line 40, moved to heap: counter and line 41, func literal escapes to heap (closure)
  • line 48, make([]byte, 4096) does not escape (smallConst)
  • line 57, make([]byte, n) does not escape (variableSize)
  • line 66, make([]byte, 100000) escapes to heap (bigConst)
  • line 74, i + 1000 escapes to heap (printed, the argument to fmt.Sprint)
  • line 79, &point{...} escapes to heap (stored)

What each cost

Functionns/opB/opallocs/op
return a struct by value2.200
return a pointer, not inlined13.7241
return a pointer, inlined2.200
box an int into a global interface9.781
closure returned to the caller21.9242
make of 4096 bytes, constant2.200
make of n bytes, variable440.240961
make of 100000 bytes, constant5518.51064961
fmt.Sprint(int)49.1162
store a pointer in a global13.6241

The pattern is clear. The three zero allocation rows cost 2.2 ns, which is the cost of the benchmark loop. Everything that escapes costs ten to several thousand times more.

The cases that teach something

Returning a value versus a pointer. byValue returns a 24 byte struct by value, and the compiler copies it into the caller's frame: free. byPointer returns &point{...}; the struct has to outlive the call, so it escapes: 13.7 ns, 24 bytes, one allocation. The tempting rule "pointers are faster because they avoid copying" is wrong for small structs. Copying 24 bytes costs less than an allocation plus the collector's later work. Return pointers when the type is large, must be shared or mutated through the reference, or is used as an interface; return values otherwise.

Inlining changes the answer. inlinedPointer has the same body as byPointer. Because it is small, the compiler inlines it into useInlined, where the pointer is only dereferenced locally and never leaves. The line 22 message flips to does not escape, the struct sits on the stack, and the benchmark shows 2.2 ns and 0 allocations. //go:noinline on byPointer forced the other outcome. So whether a pointer returning helper allocates depends on whether it is inlined at that call site, which depends on its cost (inlining). A helper that grows past the budget starts allocating, which is a reason to benchmark with -benchmem after a "harmless" refactor.

Interfaces box. sink = i + 1000 stores an int in an any. The value is copied to the heap so the interface can point at it: 9.7 ns and 8 bytes. (Go avoids the allocation for small values from 0 to 255 and for some constants, which is why the lab adds 1000.) The same applies to every argument to fmt.Sprint, fmt.Println and log.Printf: fmt.Sprint(int) made 2 allocations, one for the boxed argument and one for the resulting string. In a hot path, strconv and the Append variants avoid the boxing (the allocation article shows 3478 allocations falling to 2).

Closures capture by reference. closure returns a function that increments counter. The variable must live as long as the function value, so it moves: moved to heap: counter, plus func literal escapes to heap for the closure object itself. 21.9 ns and two allocations per call of closure(). A closure that does not escape (called in the same function, passed to something the compiler can see does not retain it) costs nothing.

Sizes must be known. make([]byte, 4096) with a constant stays on the stack, 0 allocations. The same make with a variable n allocated 4096 bytes on the heap and took 440 ns, even though the compiler printed does not escape. That is the confusing one. "Does not escape" says the slice cannot outlive the function; it does not promise a stack allocation. A stack frame has a fixed size chosen at compile time, so the compiler cannot place a block of unknown size there (the Go 1.25 compiler has an optimisation for small variable sized slices that can use a small stack buffer, and 4096 bytes is far beyond it). Treat does not escape for a variable sized make as "the allocation could be freed early", and measure.

There is a size ceiling. Even for a constant size, make([]byte, 100000) escaped: implicit allocations on the stack are limited to 64 KB, and larger ones go to the heap. It cost 5518 ns and 106 KB per call, 2500 times the stack case. A fixed array variable can reach 10 MB on the stack when declared explicitly; implicit allocations (make, new, &T{}) have the smaller limit.

How to use this in practice

  1. Do not guess; ask the compiler. For a function in a hot path, run go build -gcflags=-m and look at lines of that function. The messages escapes to heap and moved to heap are the allocations. For the reasoning behind one, use -m=2 and read the "flow" lines.
  2. Confirm with a benchmark and -benchmem, as the lab does. A message tells you what the compiler decided and the benchmark tells you what it costs.
  3. Find the real hot spots first with an allocation profile. Escape analysis is for explaining and fixing what the profile points at, not for rewriting everything.
  4. Prefer simple fixes: return values for small types, pass buffers in (Append style APIs), size slices with constants or reuse them, avoid interfaces and closures in the innermost loops.
  5. Do not contort code for the compiler. Escape analysis improves between Go releases. A rewrite that saves an allocation today may be unnecessary after an upgrade, and readable code survives upgrades.

Checklist

  • go build -gcflags=-m prints the decisions; -m=2 adds reasons.
  • A returned pointer, a captured closure variable and a boxed interface value usually escape.
  • Inlining can make an escape disappear; check after refactors.
  • A variable sized make allocates on the heap even when it "does not escape".
  • Implicit stack allocations stop at 64 KB.
  • Benchmark with -benchmem to confirm.

The behaviour is described in the Go compiler's escape analysis source notes and the Go FAQ on stack and heap allocation.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.