Lesson 08 / 16
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.
Published
In this lesson
- 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
Contents
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.
// 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.
// 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.Nameinstead oflen(k.Name)) flipped the decision fromfalsetotrue— 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.