# Go Perf Escape Analysis

> Guides reading Go escape analysis via go build -gcflags=-m to know what heap-allocates and how to keep it on the stack — interpreting moved to heap / escapes to heap / does not escape and the triggers that force the heap (returned pointers, interface/fmt boxing, escaping closures, escaping slices/maps, []byte(string), channel pointers), with the refactor for each. Fires on why is this allocating, does this escape, keep this on the stack, reduce heap allocations. Routes inlining to go-perf-inlining, allocator cost to go-perf-allocator-internals.

- Skill: `ctoth/go-perf-escape-analysis` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-perf-escape-analysis`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-perf-escape-analysis/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-perf-escape-analysis

---


# Go Escape Analysis

> "If the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors."
> — [Go FAQ, stack or heap](https://go.dev/doc/faq#stack_or_heap)

A stack allocation is reclaimed for free when the frame returns; a heap allocation is GC work. Which one a value gets is decided by the compiler's **escape analysis**, and it will print its decisions. The one-paragraph intro — "stack is free, heap costs GC," the `moved to heap` line — belongs to **`go-performance` §4**; read it first. This skill is its **deep peer**: it owns *reading* the full `-gcflags=-m` output, the complete taxonomy of **what forces the heap**, and the **refactor back to the stack** for each trigger. Every claim below is backed by real `-m` output from Go 1.26.

All Go below builds clean under `gofmt`, `go vet`, and `go test` on Go 1.26.

---

## 1. The Question Escape Analysis Answers

From a correctness standpoint you never need to know — "The storage location chosen by the implementation is irrelevant to the semantics of the language" ([FAQ](https://go.dev/doc/faq#stack_or_heap)). It matters only for **efficiency**. The rule the compiler enforces: "if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic *escape analysis* recognizes some cases when such variables will not live past the return from the function and can reside on the stack" ([FAQ](https://go.dev/doc/faq#stack_or_heap)).

Escape analysis is *why* the heap profile looks the way it does: a heap profile tells you **where** allocations land; `-m` tells you **why** a specific line escapes and whether you can stop it.

---

## 2. Running It — `-gcflags=-m`

`-m` is documented as "Print optimization decisions. Higher values or repetition produce more detail" ([cmd/compile](https://pkg.go.dev/cmd/compile)).

```sh
go build -gcflags=-m ./...          # decisions
go build -gcflags='-m -m' ./...     # add the flow that explains WHY it escaped
```

`-gcflags` takes an optional package pattern that "restricts the use of that argument list to the building of packages matching that pattern" ([go help build](https://pkg.go.dev/cmd/go#hdr-Compile_packages_and_dependencies)). So `-gcflags='example.com/pkg=-m'` hits only that package; `-gcflags=all=-m` hits it **and its dependencies** (pages of stdlib noise). Scope it to your package.

The compiler is conservative: it "gives up" in many cases, because "anything assigned to any kind of indirection (`*p = ...`) is considered escaped" and analysis is inhibited by "function calls, package boundaries, slice literals, subslicing and indexing, etc. Full rules are too complex to describe, so check the `-m` output" ([CompilerOptimizations](https://go.dev/wiki/CompilerOptimizations)). **The `-m` output is the ground truth — do not assert "this doesn't allocate" without it.**

---

## 3. Reading the Messages

The lines that matter, with the exact wording the compiler emits:

| Message | Means |
|---|---|
| `moved to heap: x` | a named local (often address-taken) is forced to the heap |
| `&p escapes to heap` / `... escapes to heap` | this value/expression must be heap-allocated |
| `x does not escape` | stays on the stack — the good case |
| `leaking param: p to result ~r0` | a *parameter* flows out (to a result/global); the caller's value may escape |
| `make([]T, n) does not escape` | the backing array is stack-allocated |
| `can inline F` / `inlining call to F` | inlining decisions (route → `go-perf-inlining`) |

`-m -m` adds the **flow chain** that proves the escape. For `fmt.Sprintf("%d", n)`:

```
./esc.go:21:27: n escapes to heap in Show:
./esc.go:21:27:   flow: {storage for ... argument} ← &{storage for n}:
./esc.go:21:27:   flow: {heap} ← {storage for ... argument}:
./esc.go:21:27:     from fmt.Sprintf("%d", ... argument...) (call parameter) at ./esc.go:21:20
```

Read it bottom-up: `n` is spilled into the `...any` slice, which is passed to `fmt.Sprintf`, which the compiler cannot prove keeps it on-stack — so `n` escapes. That chain is how you locate the *real* cause when the top-level line is terse.

---

## 4. The Taxonomy of What Forces the Heap

Each item below pairs the wrong shape (with its real `-m` line) against the stack-safe refactor.

### 4.1 Returning a pointer to a local

```go
type Point struct{ X, Y int }
func NewPoint(x, y int) *Point { p := Point{x, y}; return &p } // ./esc.go:12:2: moved to heap: p
```

The local `p` outlives the frame, so it must live on the heap. **Refactor:** return the *value* when the struct is small — the copy is cheaper than a GC-tracked allocation.

```go
func NewPointVal(x, y int) Point { return Point{x, y} } // no heap line
```

(Inlining can rescue the pointer form — §5.)

### 4.2 Interface boxing — concrete value into `any`/`interface{}`

Assigning a concrete value to an interface boxes it on the heap. The single biggest source is **`fmt`**, whose `...any` parameter boxes *every* argument:

```go
func Show(n int) string { return fmt.Sprintf("%d", n) } // ./esc.go:21:27: n escapes to heap
func Box(n int) any { return n }                        // ./esc.go:31:9: n escapes to heap
```

**Refactor:** use the typed, non-boxing path. `strconv.Itoa` takes an `int`, not `any`, so nothing escapes:

```go
func ShowFast(n int) string { return strconv.Itoa(n) } // no "escapes to heap" for n
```

The same applies to `strconv.AppendInt(buf, n, 10)` into a reused buffer (depth → `go-perf-strings-bytes-zerocopy`). Reach for the typed API before any `%v`.

### 4.3 Closures capturing variables that outlive the frame

A variable captured by reference by a closure that escapes is itself moved to the heap:

```go
func Counter() func() int {
	c := 0                              // ./esc.go:36:2: moved to heap: c
	return func() int { c++; return c } // ./esc.go:37:9: func literal escapes to heap
}
```

`-m -m` spells it out: `c escapes to heap ... from c (captured by a closure)`. **Refactor:** if you don't need the closure to outlive the call, don't return it — pass state explicitly, or make the captured value a method receiver on a stack-allocated struct. A closure used and discarded *within* the frame does **not** force its captures to the heap.

### 4.4 Slices and maps the compiler can't bound

A `make` whose result stays local with a compiler-visible size stays on the stack; one that is returned, or grown unboundedly, escapes:

```go
func SumLocal() int { s := make([]int, 3); /* ... */ return sum(s) }
//   ./esc.go:42:11: make([]int, 3) does not escape
func MakeSlice(n int) []int { return make([]int, n) } // ./esc.go:53:13: make([]int, n) escapes to heap
func Grow(n int) (s []int) {
	for i := 0; i < n; i++ { s = append(s, i) } // ./more.go:20:13: append escapes to heap
	return s
}
```

**Refactor:** keep scratch slices local and reuse them (`s = s[:0]`); when a slice must be returned, preallocate (`make([]T, 0, n)`) so it grows once (slice growth depth → `go-perf-slices`). A very large local also escapes even when it doesn't outlive the frame — "if a local variable is very large, it might make more sense to store it on the heap" ([FAQ](https://go.dev/doc/faq#stack_or_heap)).

### 4.5 `[]byte(string)` and `string([]byte)` conversions

A conversion that escapes copies onto the heap, but the compiler **elides** the copy in specific read-only cases — a `map` lookup keyed by `string(b)` does not allocate:

```go
func Bytes(s string) []byte { return []byte(s) }              // ./more.go:11:45: ([]byte)(s) escapes to heap
func Lookup(m map[string]int, b []byte) int { return m[string(b)] } // ./more.go:14:63: string(b) does not escape
```

`for range string(b)` is elided the same way. **Refactor:** stay in one representation on the hot path; exploit the elision cases; zero-copy `unsafe.String`/`unsafe.Slice` with their safety rules → `go-perf-strings-bytes-zerocopy`.

### 4.6 Pointers sent on a channel

A pointer placed on a channel escapes — the runtime cannot prove the receiver stays in-frame:

```go
func SendPtr(ch chan *Point, x, y int) { p := &Point{x, y}; ch <- p }
//   ./esc.go:58:7: &Point{...} escapes to heap   (./esc.go:57:14: ch does not escape)
```

**Refactor:** send the value (`chan Point`) when the struct is small and you don't need shared mutation; the channel copies it regardless.

### 4.7 Storing a concrete value into an interface-typed field

Same boxing as §4.2, but via a struct field rather than a parameter:

```go
type Box2 struct{ v any }
func StoreIface(b *Box2, n int) { b.v = n } // ./more.go:8:41: n escapes to heap (b does not escape)
```

**Refactor:** give the field a concrete type when the set of stored types is known; an `int` field never boxes.

---

## 5. `leaking param` and the Interaction with Inlining

`leaking param` is distinct from `escapes to heap`: it describes a *parameter* whose reference flows out of the function (to a result or a global). It does not, by itself, allocate — it tells the **caller's** analysis that the argument may escape there:

```go
func Sink(p *Point) any { return p } // ./more.go:26:11: leaking param: p to result ~r0 level=0
func Add(p *Point) int { return p.X + p.Y } // ./esc.go:63:10: p does not escape (kept in-frame)
```

This is the hook into inlining. **Inlining can remove an escape**: when `NewPoint` (§4.1) is inlined into a caller that never lets the pointer escape, the local can stay on the *caller's* stack and the `moved to heap` line disappears at the call site. So "a returned `*T` always allocates" is false — it depends on whether the callee inlines and whether the result escapes in the caller. The inlining budget, what blocks inlining, and `//go:noinline` are owned by **`go-perf-inlining`**; the *cost* of a heap allocation once it happens (size classes, GC scan) is **`go-perf-allocator-internals`**.

---

## 6. Don't

- **Don't claim "this doesn't allocate" without `-m` evidence.** The rules are too complex to eyeball — "check the `-m` output" ([CompilerOptimizations](https://go.dev/wiki/CompilerOptimizations)).
- **Don't forget that `fmt.Sprint`/`Sprintf`/`Print` box every argument** into `...any`; prefer `strconv.Append*`/`Itoa` on a hot path (§4.2).
- **Don't assume a returned `*T` always escapes** — inlining can keep it on the caller's stack (§5).
- **Don't assume small structs never heap-allocate** — taking their address, boxing them into an interface, or sending them on a channel all escape them (§4.1, §4.2, §4.6).
- **Don't ignore interface-typed fields and `any` parameters** — storing a concrete value into either boxes it (§4.2, §4.7).
- **Don't re-`make` a scratch slice per call** when it could be a local that "does not escape" and is reused with `s = s[:0]` (§4.4).
- **Don't reach for `unsafe` to dodge a copy** the compiler already elides (`m[string(b)]`, `for range string(b)` — §4.5).

---

## 7. Routing to Related Skills

**This plugin (`go-perf-*` depth):**

- `go-perf-inlining` — the inlining budget, blockers, `//go:noinline`, and how inlining removes an escape (§5).
- `go-perf-allocator-internals` — what a heap allocation *costs*: size classes, the tiny allocator, scan vs no-scan spans.
- `go-perf-slices` / `go-perf-maps` — growth internals and preallocation that keep backing storage from escaping.
- `go-perf-strings-bytes-zerocopy` — `strconv.Append*`, `unsafe.String`/`unsafe.Slice`, conversion-elision cases (§4.5).
- `go-perf-pprof-profiling` — the heap profile that says *where* allocations land; pair it with `-m`'s *why*.
- `go-perf-methodology` — whether reducing this allocation is worth it (measure first).

**Parent / sibling marketplace (`go-*` correctness):**

- `go-performance` — the measure-first overview and the one-paragraph escape intro this skill deepens (§4 there).
- `go-version-feature-map` — version floors for the runtime/GC behavior an escape feeds.

---

## 8. Reference Files

High-frequency escape-analysis mistakes in LLM-generated Go, each with wrong/right code, the real `-m` line, and citations:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

