Escape analysis moves a hot-path helper's locals to the heap. How do you remove that allocation without changing behaviour?
answer
- read the compiler before guessing
- the symptom line is not the cause
- who keeps the pointer past the call
- let the caller own the destination
- confirm with allocs/op, not with -m
basics
~20 sFind why the value escapes with go build -gcflags='-m -m', then reshape the code so the lifetime ends with the frame: return values instead of pointers, let the caller own the destination, avoid indirect calls the compiler cannot see through. Confirm with a benchmark.
solid answer
~50 sStart by reading the compiler, not guessing: `go build -gcflags='-m -m'` on that one package names both the escaping value and the reason. Nearly always the reason is one of four — the helper returns a pointer to a local, it hands the pointer to a callee reported as `leaking param`, the value is passed through an indirect call the compiler cannot see into, or the allocation has no compile-time-constant size. The fixes match the causes: return a small struct by value, take a destination from the caller and write into it so the caller owns the lifetime, replace an indirect callback with a direct call, or size the allocation as a constant. Then verify twice: the escape line should change to `does not escape`, and a benchmark with -benchmem should show allocs/op fall. Keep the tests green — an escape that the API genuinely requires should be left alone.
code
go · 11 linestype Point struct{ X, Y int }
// Allocates on every call: the value is returned by pointer.
func Scale(p *Point, f int) *Point {
return &Point{X: p.X * f, Y: p.Y * f}
}
// Nothing here escapes: the caller owns dst.
func ScaleInto(dst, p *Point, f int) {
dst.X, dst.Y = p.X*f, p.Y*f
}go deeper
Know that go build -gcflags=-m tells you which values are heap-allocated, and that an escape is a cost to investigate rather than a bug to panic about.
Be able to name the common causes — a returned pointer, a callee that keeps the pointer, an indirect call, a non-constant allocation size — and the code change each one implies.
Show the full loop: measure, read the reasoning chain to find the real cause, make the smallest change that removes it, keep tests green, and confirm with allocs/op rather than with compiler output alone.
Own where this discipline applies. Caller-owned buffers and concrete types buy allocations back at the cost of API clarity and misuse risk; decide which packages are hot enough to pay that, and keep the rest optimised for readability.
## Establish the fact before the theory A helper that allocates on every call is worth attention only if it is genuinely hot and the allocations are genuinely yours. Confirm both first: a benchmark showing allocs/op greater than zero for the operation, and the compiler's own account of what escapes. `go build -gcflags='-m -m'` on the package prints each decision plus the reasoning chain that produced it. Build the single package, not the whole tree — the full output is unusable. What you are looking for is not the `moved to heap` line, which is the symptom. It is the end of the chain: the return, the assignment, or the call that forced the value's lifetime past the frame. ## The four causes, and what each one costs to fix **1. The helper returns a pointer to a local.** The lifetime demand is in the signature. If the value is small and callers do not need to observe mutations through a shared pointer, returning it by value moves the storage into the caller's frame and the escape disappears. If callers do share it, this is a real requirement and the allocation is correct — leave it. **2. The helper hands a pointer to a callee reported as `leaking param`.** The callee stores the pointer somewhere longer-lived, so the compiler must assume your local escapes. The fix lives in the callee: if it only needs the data during the call, stop retaining it, and the caller's local goes back on the stack. This is the case that most often looks mysterious, because the allocation is attributed to your function while the cause is in someone else's. **3. The value passes through a call the compiler cannot see into.** An indirect call — through an interface value or a function-typed field — gives escape analysis no callee to inspect at that site, so it conservatively assumes the pointer leaks. Making the call direct, or accepting a concrete type where the abstraction is not earning anything, restores the analysis. This is a design change, not a micro-optimisation, and should be argued as one. **4. The allocation has no compile-time-constant size, or is too large.** `make([]byte, n)` with a variable `n` cannot be placed in a frame whose size must be known when the function is compiled, so it goes to the heap; the same applies to a local far larger than the compiler is willing to keep in a frame. Fixing this means bounding the size as a constant, or moving the allocation out of the hot path entirely. ## The shape that usually wins: let the caller own the memory The most durable fix in Go is a signature change rather than a body change. Instead of a function that allocates a result and returns a pointer to it, write one that takes a destination and writes into it: ```go func Scale(p *Point, f int) *Point // allocates per call func ScaleInto(dst, p *Point, f int) // caller owns dst ``` The callee now retains nothing, so its parameters are reported as not escaping, and the caller's `dst` can live in the caller's own frame across many calls. The same pattern applies to byte slices: take a `dst []byte`, append to it, return the extended slice. This is a genuine API change and it is not free. It pushes lifetime management onto callers, it is easier to misuse, and it reads worse. That is the trade you are making, and it should be made only where a measurement justifies it — typically an exported helper on an inner loop, not a general style. ## Verifying, and the discipline that keeps it honest Three checks, in this order: 1. The tests still pass. Behaviour is the constraint; if it changed, the optimisation is not the same function. 2. The escape line changed. Rerun `-gcflags='-m -m'` and see `does not escape` where you saw `moved to heap`. 3. The benchmark moved. Run it with -benchmem and compare allocs/op and ns/op before and after. The compiler's diagnostic is evidence about the compiler; the benchmark is evidence about the program. The third check is the one people skip, and it is the one that catches the case where the allocation was never on the path that mattered. ## What not to do Do not treat escape output as a contract to be enforced. The analysis improves between compiler releases, and a decision you worked around this year may be made for you next year; pinning the current output into CI freezes behaviour that is legitimately allowed to change. Do not restructure code for an escape you have not measured, and do not remove an escape the semantics require by handing out memory whose lifetime you can no longer reason about — a correctness bug is a much worse outcome than an allocation.
- Which escapes should you not try to remove?The ones the semantics require. If callers keep the pointer after the call, or share the value across goroutines, the value genuinely outlives the frame and the heap is where it belongs. Forcing it out means handing back memory whose lifetime you can no longer reason about — a correctness bug traded for one allocation.
- Why can a call through an interface or a function-typed field defeat escape analysis?Because escape analysis reasons per call site using what it knows about the callee, and an indirect call names no callee at compile time. With no summary to consult, the compiler must assume the pointer arguments are retained, so they escape. Making the call direct gives the analysis something to inspect again.
- How do you prove the change actually worked?Rerun `go build -gcflags='-m -m'` and check the line for that value now reads as not escaping, then run the benchmark with -benchmem and compare allocs/op and ns/op against the baseline. Both matter: the first says the compiler agrees, the second says it made a measurable difference on the path you care about.
saying these in an interview costs you the question
- Rewrites the API before measuring anything
- Treats escape output as a stable guarantee
- Says avoiding pointers everywhere prevents allocation
- Hides the allocation in a package-level shared variable
- Declares victory from -m output with no benchmark