Skip to content
QSWEQB
hardConceptCodingScenarioMidSeniorStaff

How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?

Escape analysis moves a value to the heap when the compiler cannot prove its lifetime ends with the frame that created it, and go build -gcflags=-m prints every decision. Which of those allocations matter is a measurement question, answered with a benchmark, an alloc_space profile and benchstat.

6 min readUpdated 2026-07-27

What the interviewer is scoring

  • Does the candidate frame escape analysis as a proof about lifetime rather than a rule about pointers
  • Whether they know how to ask the compiler what it decided, and can read the output it prints
  • That interface conversion is named as an escape cause, not just returning a pointer
  • Whether the workflow starts with measurement and ends with a comparison, rather than a list of idioms
  • Does the candidate distinguish alloc_space from inuse_space and say which question each answers

Answer

What escape analysis is deciding

Go has no new versus stack distinction in the source. You write &Thing{} and the compiler decides where it lives, and the decision rule is a proof obligation: if the compiler can prove that no reference to the value survives the function that created it, the value goes in that function's stack frame and costs nothing to reclaim. If it cannot prove that, the value goes on the heap, where the garbage collector becomes responsible for it.

Two things follow that people get backwards. Taking a pointer does not force a heap allocation — p := &Thing{} used only locally stays on the stack quite happily. And not taking a pointer does not guarantee a stack allocation, because a value can be copied into something that escapes, or converted to an interface. The stack itself is not fixed either: a goroutine starts small and the runtime grows it by copying, so "it might not fit" is rarely the reason something escapes. The reasons are almost always about lifetime.

Ask the compiler rather than guessing

The analysis is not a mystery you reason about from first principles; it prints its conclusions.

go build -gcflags='-m' ./...        # one line per decision
go build -gcflags='-m -m' ./...     # plus why, including inlining reasoning
package geo

import "fmt"

type point struct{ x, y int }

func sum() int {
	p := point{1, 2} // stays in the frame: no reference outlives sum
	return p.x + p.y
}

func origin() *point {
	p := point{0, 0} // "moved to heap: p" — the pointer is returned
	return &p
}

func show(n int) {
	fmt.Println(n) // "n escapes to heap" — converted to any for a variadic ...any
}

Build that and the compiler tells you moved to heap: p for origin and n escapes to heap for show, and says nothing about sum. Being able to run this in an interview, or describe its output precisely, is the difference between having read about escape analysis and having used it.

show is the instructive case. Nothing about n looks like it leaves the function. But fmt.Println takes ...any, so n is converted into an interface value, the compiler cannot see through fmt's reflection to prove the interface does not outlive the call, and an int therefore becomes a heap allocation. Every interface conversion is a candidate escape for exactly this reason, which is why hot paths in Go tend to avoid any in their signatures and why structured loggers that take typed fields allocate less than ones taking ...any.

The other systematic causes: storing a pointer into something already on the heap (including a slice or map that escapes), capture by a closure that itself escapes, sending a pointer on a channel, allocations whose size is not a compile-time constant, and objects the compiler judges too large to place in a frame.

Removing allocations, in order of payoff

The highest-value fix is almost always preallocation, because it removes a sequence of allocations rather than one. make([]T, 0, n) when you know n, strings.Builder with Grow, and a map with a size hint each convert repeated grow-and-copy into a single allocation.

Next is eliminating the formatting and conversion layer, which is where most surprising allocation volume lives.

package cache

import (
	"fmt"
	"strconv"
)

// Two costs: the returned string, and boxing both arguments into ...any.
func keyFmt(userID int, kind string) string {
	return fmt.Sprintf("u:%d:%s", userID, kind)
}

// No interface conversion and no intermediate string. The caller supplies the
// buffer and reuses it, so the steady-state cost is zero allocations.
func appendKey(buf []byte, userID int, kind string) []byte {
	buf = append(buf, "u:"...)
	buf = strconv.AppendInt(buf, int64(userID), 10)
	buf = append(buf, ':')
	return append(buf, kind...)
}

Then buffer reuse. sync.Pool is the standard tool and easy to misuse: it pays only for buffers large enough that the allocation is meaningful, it requires you to reset the object before returning it, and pooled entries can be dropped at any garbage collection, so it is a cache rather than a free list. Last, avoid []byte to string conversions mid-pipeline — the compiler elides some of them, such as a string(b) used directly as a map key, but one whose result is stored allocates.

A profiling workflow that finds the real cost

None of the above should be applied by reading code. This is the workflow to describe, because it is what an interviewer is checking for when they ask the question at all.

Start with a benchmark that resembles the work. -benchmem gives you B/op and allocs/op, which is the number you will report at the end.

go test -run='^$' -bench=BenchmarkHandle -benchmem -count=10 \
        -cpuprofile=cpu.out -memprofile=mem.out ./internal/handler

Read the allocation profile by total bytes allocated, not by what is live.

go tool pprof -http=:8080 -sample_index=alloc_space mem.out

alloc_space and alloc_objects count everything ever allocated during the run, which is what drives garbage-collector work. inuse_space and inuse_objects count what was still live when the profile was taken, which is what you want when hunting a leak. Asking the wrong one of those two questions is the most common way to waste an afternoon. Note also that heap profiling samples — roughly one allocation per 512 KB by default — so add -memprofilerate=1 when you need exact counts from a small benchmark.

Confirm the cost in the CPU profile. Garbage-collection work shows up as runtime.mallocgc, runtime.gcDrain, runtime.gcBgMarkWorker and runtime.gcAssistAlloc. That last one is the important signal: assist time means your own goroutines were conscripted into marking because they were allocating faster than the background collector could keep up. If gcAssistAlloc is invisible in the profile, allocation reduction is not your bottleneck.

Fix one thing, then compare properly. Re-run the benchmark with -count=10 into a second file and use benchstat to say whether the difference is real rather than noise. A single -count=1 before-and-after pair is not evidence.

go test -run='^$' -bench=BenchmarkHandle -benchmem -count=10 ./... > new.txt
benchstat old.txt new.txt

For a live service, profile the live service. Import net/http/pprof on an internal port and pull profiles from it: /debug/pprof/allocs for the cumulative allocation profile, /debug/pprof/heap for what is live, /debug/pprof/profile?seconds=30 for CPU, /debug/pprof/goroutine?debug=1 when you suspect a leak. Two heap profiles an hour apart, compared with go tool pprof -base first.pb.gz second.pb.gz, is the standard way to find growth. GODEBUG=gctrace=1 prints a line per collection cycle if you want to see frequency before you go further, and go tool trace is the tool when the question is scheduler latency rather than allocation volume.

Why the top of the profile is often the wrong thing to fix

Two mistakes account for most wasted optimisation effort in Go. The first is optimising a service that is not allocation-bound: a handler spending its wall-clock time waiting on Postgres will not get faster because you removed a Sprintf, however prominent that Sprintf is in the allocation profile. Allocation profiles rank by allocation, and allocation is not latency.

The second is optimising a benchmark instead of a workload. A microbenchmark with one warm input and no concurrency lets the compiler and the branch predictor behave in ways production never will, and it is entirely possible to halve allocs/op while making the real service slower — a sync.Pool under contention across many cores is the classic example. Use inputs that match real distributions, and verify the win against tail latency rather than throughput.

Treat the escape-analysis output the same way: information, not a task list. A value escaping in a function called once at startup does not matter at all; the same escape inside a loop running a million times per second is the whole problem.

Escape analysis answers "where does this live", and it will tell you if you ask with -gcflags=-m. It cannot tell you which of its decisions is expensive — that comes from a benchmark, an alloc_space profile, and a benchstat run proving the change did something.

Likely follow-ups

  • Why does passing a value to fmt.Println move it to the heap when passing it to your own function does not?
  • When does sync.Pool make things worse?
  • You reduced allocs/op by half and p99 latency did not move. What do you conclude?
  • How would you diff two heap profiles taken an hour apart on a running service?

Related questions

Further reading

escape-analysisallocationspprofprofilinggarbage-collection