---
title: 'Escape Analysis'
source: 'https://academia.sh/en/courses/go-production/escape-analysis'
course: 'Production Go: Testing, Tooling and Performance'
language: en
updated: '2026-09-14T10:59:33+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "Reads the compiler's decision on whether to allocate a value on the stack or the heap without a run"
    - 'Demonstrates by running how a small change to the same function changes its decision to move a value to the heap'
    - 'Distinguishes that the escape analysis decision is not a promise of the language but an implementation detail of the compiler'
---

# Escape Analysis

Escape analysis is a tool fact: the compiler decides whether to allocate a value on the stack or the heap and reports this without a run. Three of four functions move to the heap, and the same function changes this decision with a small change. Limiting measurement: this decision is not a promise of the language, it is an implementation detail tied to the compiler's version.

A profile stated where a value was allocated **after** the run, as a number. But the
decision of whether that value will sit on the stack or the heap is made long before
the run — while the source is merely being compiled, not yet executed. This lesson
looks at that decision itself: how the compiler reports this decision, and how a small
change in the same function's body changes it.

This is the clearest **tool** example of the source/tool/run distinction that has
held since the course opened. Escape analysis is not written in the source — the word
`heap` or `stack` appears nowhere; the code only defines a variable and uses it. But
the compiler does not **run** the program to make this decision either; it makes the
decision during compilation and reports it on request. This differs from a run fact:
a run fact (like the previous lesson's profile number) only appeared once the
program was executed, and it was separately tested for staying the same across runs.
A tool fact, by contrast, requires no run — the same source, with the same compiler,
gives the same decision every time.

## Stack, Heap, and the Decision Between Them

The question of whether a value sits on the stack or the heap is not new in this
course: stack and heap were established as part of memory layout in the How
Computers Work course (M01/K01), and are not repeated here. That course established
this distinction — the stack is a fast, sequential region that opens and closes
together with a call frame; the heap is a region independent of the call frame,
managed by hand (or, as this lesson will show, by the compiler's decision). This
lesson does not measure what those two regions **are**, but who decides **which**
one a value goes to.

The decision itself can be summarized with a simple rule: if a value's lifetime
outlasts the lifetime of the call frame that created it, the value must move to the
heap — otherwise the value would be lost when the call frame closes. The compiler
decides this by tracing the source code itself — following where a pointer goes,
which interface it is passed to, which container it is appended to. This tracing
step is **escape analysis**: the compilation step that determines whether a value
"escapes" the scope that created it.

The difference between stack and heap is not only "where it sits" but "who manages
it." A value on the stack has its lifetime automatically closed together with the
call frame — no separate cleanup step is needed. A value on the heap has a lifetime
independent of the call frame, and it is the runtime, not the program, that decides
when this value gets removed. This second question — how a value moved to the heap
eventually gets cleared out — is the next lesson's subject; this lesson only looks
at whether the value goes to the heap **or not**.

In the previous lesson, the `verbose` branch of the `format` function allocated
seven extra values, and in this lesson where those seven allocations **come from**
becomes clear: each of the three `fmt.Sprintf` calls made exactly the kind of escape
decision this lesson will examine while wrapping the string it produced and its
arguments into an interface. The profile counted the **result** of those decisions
(how many values); this lesson reads the decision **itself**.

## Reading the Decision on Its Own

The way to read escape analysis's decision is to call the compiler **from inside the
program** and read only its decision, not its text. The helper function below does
this: it writes a given source fragment on its own into a temporary module, runs the
compiler on that module, and searches the output for whether a move-to-heap
notification exists — the notification's **text** is never printed, only this truth
value is.

```go
// escape.go — reads the compiler's move-to-heap decision from inside the program, without printing its text
package main

import (
	"os"
	"os/exec"
	"strings"
)

// escapes writes the given body on its own into a package and runs the
// compiler on that package. The returned value is only a truth value;
// the compiler's notification text is never read or printed.
func escapes(body string) bool {
	dir, err := os.MkdirTemp("", "escape")
	if err != nil {
		return false
	}
	defer os.RemoveAll(dir)
	os.WriteFile(dir+"/go.mod", []byte("module escape\n\ngo 1.24\n"), 0o644)
	os.WriteFile(dir+"/value.go", []byte(body), 0o644)
	cmd := exec.Command("go", "build", "-gcflags=-m -l", "-o", os.DevNull, ".")
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
	output, _ := cmd.CombinedOutput()
	text := string(output)
	return strings.Contains(text, "moved to heap") || strings.Contains(text, "escapes to heap")
}
```

**PM4.** `escapes` never calls the target function — it only defines it. The
compiler resolves a function's body during compilation and reports its decision even
when no code calls it; this is why `func main() {}` can stay empty in `escapes`'s
temporary module. This shows the decision depends on **definition, not being
called** — escape analysis requires no run, because there is nothing running; there
is only something being compiled.

Compiling each source **on its own**, in a separate package, is also a deliberate
choice. The compiler can inline some small functions at their call site; when such
inlining happens, the escape decision is written not to the function's own body but
to the call site it was inlined into, and it becomes harder to read. Compiling each
source only in its own package, without mixing into any other call, removes this
complication from the start: where the decision gets written and which function it
belongs to never falls into ambiguity.

This isolation has a cost too: in a real program, if a function like
`returnsAddress` gets inlined at its call site, its decision may come out
**different** from what is measured here — if the caller uses the result
immediately and discards it, the compiler may still manage to keep the value on the
stack after inlining. This lesson does not measure that situation; it measures only
the decision the function gives **on its own**, without being inlined into any
call. The decision in a real program can therefore come out more optimistic than
what is seen here, never more pessimistic — inlining can only **prevent** an
escape, it never adds a new one.

## Four Functions, One Decision Table

The measurement below defines four separate functions, all four using the same
`Record` struct, but all four handling the value in a different way: one only reads
the value locally, one returns its address, one gives it to an interface, one
appends it to a slice. A fifth source is nearly identical to the first one
(`staysLocal`) — the only difference is that the last line returns an address
instead of a value.

```go
// measurement.go — four functions, the compiler's move-to-heap decision, and a small change
package main

import "fmt"

const staysLocal = `package main

type Record struct{ Name string }

func staysLocal() int {
	k := Record{Name: "bolt"}
	return len(k.Name)
}

func main() {}
`

const returnsAddress = `package main

type Record struct{ Name string }

func returnsAddress() *Record {
	k := Record{Name: "nut"}
	return &k
}

func main() {}
`

const givenToInterface = `package main

import "fmt"

type Record struct{ Name string }

func givenToInterface(k Record) {
	fmt.Println(k)
}

func main() {}
`

const appendedToSlice = `package main

type Record struct{ Name string }

func appendedToSlice(target []*Record) []*Record {
	k := Record{Name: "nail"}
	return append(target, &k)
}

func main() {}
`

const staysLocalChanged = `package main

type Record struct{ Name string }

func staysLocalChanged() *string {
	k := Record{Name: "bolt"}
	return &k.Name
}

func main() {}
`

func main() {
	rows := []struct {
		name   string
		source string
	}{
		{"value staying local", staysLocal},
		{"value returning an address", returnsAddress},
		{"value given to an interface", givenToInterface},
		{"value appended to a slice", appendedToSlice},
		{"same function, small change", staysLocalChanged},
	}
	escaped := 0
	fmt.Println("-- compiler's decision to move to heap --")
	for i, s := range rows {
		moved := escapes(s.source)
		if moved && i < 4 {
			escaped++
		}
		fmt.Printf("%-32s moved to heap=%t\n", s.name, moved)
	}
	fmt.Println("moved to heap out of four functions:", escaped, "/ 4")
}
```

```
-- compiler's decision to move to heap --
value staying local              moved to heap=false
value returning an address       moved to heap=true
value given to an interface      moved to heap=true
value appended to a slice        moved to heap=true
same function, small change      moved to heap=true
moved to heap out of four functions: 3 / 4
```

## Reading the Numbers

**Three** of the four functions move to the heap, one does not. `staysLocal` only
reads the value inside its own body (`len(k.Name)`) and gives nothing outward — the
value can close together with the call frame, it stays on the stack. The other
three carry the value **outward** by three separate paths: `returnsAddress` returns
the value's address, `givenToInterface` passes the value into an interface
parameter (`fmt.Println`'s variadic `...any` parameter), `appendedToSlice` appends
the value's address to a slice. All three share one thing in common: the value
**must outlive** the call frame that created it — someone may still access that
value after the call ends. The compiler sees this and moves the value to the heap,
making it independent of the call frame's lifetime.

The fifth row shows this more sharply: `staysLocalChanged` is nearly identical to
`staysLocal` — same struct, same field name, same first line. The only difference
is in the last line: `return &k.Name` instead of `return len(k.Name)`. This single
change turns the returned value from an integer into an **address**, and flips the
decision from `false` to `true`. The source shows no new pointer type, no explicit
memory allocation call — only an `&` was added. The escape analysis decision looks
not at the code's **intent**, but at the value's **reachability**; taking a
field's address means the struct holding that field can no longer stay inside the
call frame.

This asymmetry explains why staying on the stack is counted a **gain**, not the
**default**. Keeping a value on the stack requires writing nothing special —
merely not carrying the value outward is enough. But this "not carrying outward"
is fragile: appending to a slice as in `appendedToSlice`, passing to an interface
as in `givenToInterface`, or taking a single field's address as in
`staysLocalChanged` — all four push in the same direction: they push the value
from the stack to the heap. There is no operation in the reverse direction — a
single line is enough to "force" a value to the heap, while rescuing it from the
heap requires the code to never give that value outward at all. This is why three
of four functions escaping is not a coincidence specific to this example set:
every operation that gives outward pushes in the same direction.

The course's opening split eleven facts into source, tool, and run; this lesson
uses only the tool out of those three classes — the escaping value count is
neither written in the source nor learned only by running; the compiler derives
and reports it **without a run**.

`escapes` returning a single truth value hides the fact that the compiler actually
gives **two separate kinds** of notification. In a function like `returnsAddress`,
the notification says a local value was moved; in a function like
`givenToInterface`, the notification says a **parameter** itself "leaks" into the
caller's scope, because the value is already produced by the caller and passed in
as a parameter. Both end up in the same place — the value sits on the heap — but
the path differs: one carries a value that forms **inside** the function, the
other carries a value that comes **from outside** into the function. This lesson's
measurement merges the two under a single `true`/`false` because their shared
outcome — moving to the heap — is the question this course asks; the detail of the
path belongs to the compiler's internal workings and is not read here.

## Limiting Measurement: This Is Not a Promise, It Is a Decision

Someone reading the table above might quickly arrive at a rule like "in Go, a value
goes to the heap if its address is taken." This rule holds true for today's
compiler, but it is not the language's **promise**. The language specification
nowhere says "a value whose address is taken goes to the heap"; it only guarantees
how long a value will live, not **where** it sits. Escape analysis is a **method**
of fulfilling this guarantee — an implementation decision a particular compiler
makes in a particular version.

This has a concrete consequence: a smarter compiler could manage to keep a
function like `returnsAddress` on the stack too — for example, if the caller uses
the result immediately and discards it, and the compiler can prove this. This
lesson did not see that proof, because none of the four functions in this lesson
permit such a proof — all of them genuinely carry the value outside the call
frame. But the same source, compiled with a different compiler version, may give a
different decision; this lesson therefore presents the decision table not as a
**fact**, but as an **observation** of this particular compilation.

The same uncertainty is an example of the general distinction between a
language's **specification** and its **implementation**. The specification
guarantees how long a value will live and which operations are valid; the
implementation chooses which memory region and which method holds this guarantee.
The same distinction holds for other compiler decisions too — a map's iteration
order, which carries no ordering guarantee, was likewise an implementation
choice, not the language's promise. Escape analysis is this general pattern
applied to memory layout: the decision you see is correct, but that decision
belongs to the compiler, not the language.

This limit comes down to the same root as the previous lesson's limit: a tool
fact is limited by the tool itself. The profiling tool only saw the path that
ran; the escape analysis tool only shows **this compiler's this decision**.
Neither gives the truth — both give the tool's answer at that moment.

This has a practical consequence: a comment written in a codebase saying "this
value escapes to the heap, fix it" may turn out wrong once the compiler changes.
The escape decision is not a fact stored together with the source; it is an
output reproduced at compile time. This is why the table in this lesson is not a
one-time thing either — the same `escapes` call re-runs the compiler and asks the
decision again every time it executes; no decision is stored from a previous run.

## Summary

- Escape analysis is a tool fact: the compiler decides whether a value will be
  allocated on the stack or the heap without ever running the program, and
  reports this without a run.
- Three of four functions moved to the heap — the one returning the value's
  address, the one giving it to an interface, and the one appending it to a
  slice. The function that keeps the value only in its own body stayed on the
  stack.
- A one-line change to the same function (`&k.Name` instead of `len(k.Name)`)
  flipped the decision from `false` to `true` — escape analysis looks at the
  value's reachability, not the code's intent.
- This decision is not a promise of the language, it is an implementation detail
  tied to the compiler's version; a different compiler version may give a
  different decision for the same source.
- The concepts of stack and heap were not re-established here; this lesson only
  measured the compiler's decision between the two.
- The escape decision works in only one direction: every new path that carries a
  value outward and pushes it to the heap increases escaping; no operation alone
  calls a value back from the heap.

## Next Step

This lesson asked whether a value **moved to the heap or not** — a one-time
decision made at compile time. But in a program's real running process, this
decision alone is not enough: every value moved to the heap must eventually be
cleared out of it too, and this clearing happens not at compile time but during a
run. The next lesson looks at this second half — how much the "moves to the
heap" decision built in this lesson adds up to in total, how certain techniques
lower that total, and why a number going down does not always mean an
improvement.
