# Escape analysis is not a rule of thumb

> The compiler said "does not escape" and the benchmark reported 104 KiB per operation. Both were right. What -gcflags=-m actually tells you.

- Published: 2026-08-26
- Tags: escape-analysis, compiler, allocation, benchmarking, performance
- Source: https://gopheria.com/blog/escape-analysis-is-not-a-rule-of-thumb/
- Language: en-US
- Author: Nolan Keir

---
"Don't return pointers, it causes heap allocation." "Passing by value avoids the
GC." Every Go codebase has a comment like this, and the reasoning behind it is
almost always a memory of reading `go build -gcflags=-m` output once.

The output is real. The rule of thumb built on it usually is not. Here is a
single file, its actual escape analysis, and the benchmark that contradicts the
obvious reading of it.

All numbers: `go1.27.0 darwin/arm64`, Apple M4 Pro, 12 cores, macOS 26.6.2.
Benchmarks with `-benchmem -count=10`, summarised by `benchstat`.

## The subject

Eight small functions covering the cases people actually argue about:

```go
type Point struct{ X, Y int }

// 1. The textbook case: the pointer outlives the frame.
func NewPoint(x, y int) *Point {
	p := Point{x, y}
	return &p
}

// 2. The address is taken, but never leaves.
func Sum(x, y int) int {
	p := Point{x, y}
	q := &p
	return q.X + q.Y
}

// 4. Same allocation, one size known at compile time and one not.
func FixedBuffer() byte  { buf := make([]byte, 1024); return buf[0] }
func VarBuffer(n int) byte { buf := make([]byte, n); return buf[0] }

// 5. Both sizes constant. Only one of them fits.
func SmallFixed() byte { buf := make([]byte, 65536); return buf[0] }
func LargeFixed() byte { buf := make([]byte, 65537); return buf[0] }
```

`-l` disables inlining, which is what you want while reading escape output:
inlining rewrites the program, and you are trying to read the verdict for the
function you wrote.

```text
$ go build -gcflags='-m -l' ./escape
escape.go:9:2:  moved to heap: p
escape.go:22:24: x escapes to heap
escape.go:31:13: make([]byte, 1024) does not escape
escape.go:36:13: make([]byte, n) does not escape
escape.go:42:13: make([]byte, 65536) does not escape
escape.go:47:13: make([]byte, 65537) escapes to heap
escape.go:54:14: []*int{...} does not escape
escape.go:64:7:  func literal does not escape
escape.go:69:5:  func literal escapes to heap
```

Read line 36. `make([]byte, n)`, size unknown at compile time, **does not
escape**. Hold on to that.

## What the benchmark says

```text
                  │   sec/op   │    B/op    │ allocs/op │
NewPoint-12         4.587n ± 3%   16.00 ± 0%   1.000 ± 0%
Sum-12             0.2303n ± 1%   0.000 ± 0%   0.000 ± 0%
FixedBuffer-12      10.19n ± 0%   0.000 ± 0%   0.000 ± 0%
VarBufferSmall-12   10.19n ± 0%   0.000 ± 0%   0.000 ± 0%
VarBufferLarge-12   4.134µ ±21%  104.0Ki ± 0%  1.000 ± 0%
SmallFixed-12       1.138µ ± 5%   0.000 ± 0%   0.000 ± 0%
LargeFixed-12       3.446µ ±20%  72.00Ki ± 0%  1.000 ± 0%
SumAll-12           1.407n ± 0%   0.000 ± 0%   0.000 ± 0%
```

`VarBufferSmall` and `VarBufferLarge` are the **same function**, the one the
compiler said does not escape. Called with 1024 it allocates nothing. Called
with 100,000 it allocates 104 KiB and one object, every single time.

## Why both are correct

Escape analysis answers one question: *does this value outlive the frame that
created it?* For `make([]byte, n)` the answer is no, and `-m` reports it
honestly.

Whether the frame can **hold** the value is a different question, and for a
variable size the compiler cannot answer it at compile time. So it emits both
paths: a stack allocation when `n` is small enough, and a call into `runtime.
makeslice` when it is not. The branch is taken at runtime, per call.

That is the whole misunderstanding in one line of output. **`-m` is not an
allocation report.** It tells you what the compiler proved about lifetime. What
actually lands on the heap depends on lifetime *and* size *and*, once inlining
is back on, the call site.

The threshold for a single constant-sized object is 64 KiB, and the two
`Fixed` functions sit either side of it: 65,536 bytes stayed on the stack,
65,537 went to the heap. One byte.

## Taking an address is not the expensive part

`Sum` takes the address of a local, dereferences it twice, and costs **0.23
nanoseconds** with zero allocations — this is the compiler keeping the whole
thing in registers.

`SumAll` builds a three-element slice of pointers to three locals and still
allocates nothing. Indirection is not the trigger. *Escape* is the trigger, and
escape means outliving the frame.

The rule "don't take addresses of locals" costs you readable code and buys
nothing. The rule that survives contact with the data is narrower: **do not
return pointers to values the caller does not need to keep**, and be aware that
passing a value to something that stores it — an interface, a channel, a
goroutine, a global — is what actually moves it.

The interface case is worth seeing, because nothing in the source looks like it
escapes:

```text
escape.go:22:24: x escapes to heap
```

`x` is an `int` passed to a variadic `...any`. Boxing it into an interface means
taking its address, and the callee's signature says nothing about whether it
keeps the pointer, so the compiler must assume the worst. This is why logging an
integer in a hot path allocates and a `strconv.AppendInt` into a reused buffer
does not.

## The closure case is the same rule wearing a costume

Two function literals, two different verdicts:

```text
escape.go:64:7: func literal does not escape
escape.go:69:5: func literal escapes to heap
```

The first is called immediately and discarded. The second is handed to `go`.
Nothing about the closures differs — the capture is the same variable, the body
is the same expression. What differs is that a goroutine's lifetime is unrelated
to the frame that started it, so the closure and everything it captures must
survive independently.

This is the rule that explains every case in the file, including the interface
one: the compiler is not asking whether you took an address, it is asking
whether it can prove the value dies here. Anything it cannot see the end of —
a goroutine, an interface method it cannot devirtualise, a channel send, a
global — is an escape. That is also why a goroutine capturing a large struct
costs more than the `go` statement suggests, and why the fix is passing the
handful of fields it uses rather than the struct. Whether the goroutine then
[costs you an OS thread](/blog/go-scheduler-blocking-goroutine/) is a separate
question with its own surprising answer.

## Stack allocation is not free either

`SmallFixed` allocates nothing and takes **1.14µs**. `NewPoint` allocates 16
bytes on the heap and takes **4.6 nanoseconds**.

Zeroing 64 KiB of stack per call costs more than a small heap allocation, by two
orders of magnitude. "Zero allocs" is a good signal and a bad target: a
benchmark reporting `0 allocs/op` while burning a microsecond per call has
optimised the metric rather than the program.

## What this does not cover

Everything above was compiled with inlining disabled. That is correct for
reading `-m` and wrong for predicting production, where inlining routinely
changes the answer — a constructor that escapes on its own can stop escaping
once it is inlined into a caller that drops the result. The honest workflow is:
read `-m -l` to understand the function, then benchmark the real build to find
out what happens.

The 64 KiB limit is an implementation detail of the current gc compiler, not a
language guarantee. It has moved before and can move again.

And this measures allocation, not cost. An allocation that survives into the old
generation costs the collector far more than one that dies immediately, and
`allocs/op` cannot tell them apart. That needs a heap profile, which is a
different article.

:::tip
The one-line version: run `-gcflags='-m -l'` to learn what the compiler proved,
run `-benchmem` to learn what actually happened, and never let the first stand
in for the second.
:::
