Memory and escape analysis: who decides stack vs heap, and what an allocation really costs
Escape analysis decides stack vs heap at compile time: values that outlive their frame, feed interfaces, or get captured by reference escape. Read -gcflags=-m, know that stacks grow by copy, and remember the real cost of an allocation is GC pressure, not the ~25 ns malloc.
The checkout service was spending 31% of its CPU in garbage collection, and the team’s first instinct was to “tune the GC.” Someone pulled an allocation profile instead. The top entry wasn’t JSON decoding or the database driver — it was the logging wrapper, called on every request, formatting a request ID with fmt.Sprintf("%d", id). One innocent line. Because fmt takes ...interface{}, every integer passed to it was boxed onto the heap: a million requests an hour, three log lines each, three heap allocations per line. The compiler had been announcing this the whole time — go build -gcflags=-m printed id escapes to heap for anyone who looked. Replacing the hot-path formatting with strconv.AppendInt into a pooled buffer cut allocation rate by 60% and GC CPU from 31% to 9%. Nobody tuned anything. They just stopped allocating — and to stop allocating, you first have to know why the compiler decided you were.
In the next fifteen minutes you’ll see exactly why fmt.Sprintf was the culprit, how to make the compiler show its reasoning, and what to do instead — so the next time GC CPU spikes, you don’t tune the GC first.
Stack or heap is the compiler’s decision, not your syntax
In C, malloc means heap and a local means stack. In Go, syntax is irrelevant: new(T), &T{}, and a plain var t T all mean “I need a T” — where it lives is decided by escape analysis, a compile-time proof pass. The question the compiler asks about every value is: can this outlive the stack frame that created it? If provably no, it goes on the stack — allocation is bumping the frame pointer (effectively free), and deallocation is the function returning (literally free). If yes, or if the compiler can’t prove no, the value escapes to the heap, where the allocator places it (~25 ns for a small object on the mcache fast path) and the garbage collector must eventually find it, mark it, and sweep it.
That asymmetry is the whole topic. A stack allocation costs nothing now and nothing later. A heap allocation costs a little now and — this is what allocation profiles reveal — a recurring tax later: every live heap byte makes the next GC cycle scan more, and every allocated byte brings that cycle closer. The compiler shows its reasoning if you ask:
// $ go build -gcflags=-m ./... 2>&1 | grep -E 'escapes|moved'
//
// ./handler.go:14:6: moved to heap: user ← &user outlives the frame
// ./handler.go:22:13: id escapes to heap ← passed to fmt (interface)
// ./handler.go:30:12: make([]byte, n) escapes ← size unknown at compile time
// ./handler.go:41:9: func literal escapes ← closure stored beyond the call
func newUser(name string) *User {
user := User{Name: name} // "moved to heap": the pointer is returned,
return &user // so the value must survive this frame
}Use -gcflags=-m (twice, -m -m, for verbose proofs) on hot packages. The output reads like a court ruling: not “this is slow,” but “here is why I could not keep it on the stack.”
What forces an escape — the five usual suspects
Escape analysis is conservative: anything it can’t prove local is heap-bound. In practice five patterns cause nearly all escapes in service code:
- Returning a pointer to a local.
return &user— the frame dies, the value can’t. This is fine and idiomatic; constructors are supposed to allocate. The waste is doing it per-iteration in a hot loop. - Storing into an interface.
fmt.Println(id),logger.Info("n", anyValue)— converting a concrete value tointerface{}usually heap-allocates the value, because the callee receives an opaque box the compiler can’t see through. This is the Hook’s bug, and the single most common accidental escape in Go codebases. - Closure capture by reference. A
func(){ ... }literal that captures a local and is itself stored, returned, or passed togo— the captured variable moves to the heap so both frames can share it. - Sizes unknown at compile time.
make([]byte, n)with a runtimenescapes;make([]byte, 4096)with a constant can stay on the stack (implicit allocations stay stack-eligible up to 64 KB). Same code shape, different proof. - Sending pointers across goroutine boundaries. A value sent on a channel or assigned to a struct field that outlives the call has, by definition, escaped.
Together, patterns 2 and 3 — interface conversions and closures — account for the overwhelming majority of accidental escapes in service code; without those two, most hot paths would never touch the heap at all.
The verdict also changes with inlining: a small function inlined into its caller may let the compiler re-prove locality and un-escape a value. This is why escape behavior can shift between Go versions — the proof, not your code, changed.
You pass a local int to fmt.Println and -gcflags=-m reports it escapes to the heap. What is the actual mechanism?
Stack growth: why 2 KB stacks don’t overflow
Escape analysis would be useless if stacks couldn’t hold real workloads. A goroutine starts at ~2 KB, and every function prologue checks whether the frame fits. On overflow the runtime doesn’t fault — it allocates a stack twice the size, copies the old stack over, and rewrites every pointer that pointed into it (possible because the compiler tracks exactly which words are pointers). The old segment is freed; the goroutine continues, oblivious. Stacks also shrink: during GC, a goroutine using a quarter of its stack gets it halved. The cost model: growth is a copy — O(stack size) — paid rarely, at amortized cost near zero; the pathological case is a call pattern oscillating across a growth boundary, repeatedly paying the copy. Deep recursion on a hot path is therefore not free in Go even though nothing crashes — each doubling from 2 KB to, say, 1 MB is ten copies. This is also why stack allocation stays cheap to promise: the runtime can always relocate a stack, but it can never relocate the heap (more on that in the GC lesson — Go’s heap doesn’t compact).
The honest price list
When you see “GC is slow” in an incident, the honest question is: slow because of what? Before reaching for GOGC, look at the allocation rate — that’s what this section is about.
Numbers worth carrying: a small heap allocation through the per-P mcache is roughly 20–40 ns; a stack allocation is ~0 ns (the frame exists anyway). So a single escape is nothing. What kills services is rate: at 2 GB/min of allocation, the pacer schedules GC cycles back-to-back, mark assists start taxing your allocating goroutines directly, and suddenly “the GC is slow” — when the truthful statement is “we allocate 30 million times a minute.” The leverage order on a hot path is: (1) don’t allocate (escape-proof the path with -m), (2) allocate once and reuse (sync.Pool, preallocated slices with capacity, strconv.Append* into existing buffers), (3) only then think about GC knobs. An allocation profile (pprof -sample_index=alloc_space) ranks exactly where the bytes come from — the Hook’s team found their answer in one screen.
A goroutine's recursion grows past its current 2 KB stack. What does the runtime do?
- 01Name the five common patterns that force a value to escape to the heap, and the tool that shows the compiler's reasoning.
- 02Why is the real cost of a heap allocation not the ~25 ns malloc, and what is the leverage order for fixing an allocation-heavy hot path?
Go decides stack versus heap by proof, not syntax: escape analysis asks whether a value can outlive its frame, and anything it cannot prove local goes to the heap. Stack allocation is frame-pointer arithmetic, freed by returning; heap allocation costs 20–40 ns through the mcache fast path and then a recurring GC tax — more live bytes to mark, more allocated bytes pulling the next cycle closer. The five escapes that dominate real codebases: returned pointers to locals, interface conversions (fmt and structured loggers above all), closures capturing by reference beyond the call, make with runtime-determined size (constants up to 64 KB stay stack-eligible), and pointers crossing goroutine or long-lived-struct boundaries. The compiler publishes every ruling via -gcflags=-m, and inlining can flip verdicts between releases. Stacks start at ~2 KB and grow by allocate-double-copy-rewrite — contiguous stacks, chosen over segmented ones to kill the hot-split problem — and shrink at GC, so deep recursion pays copy costs even though nothing crashes. When GC CPU looks high, the honest diagnosis usually reads from the allocation profile: the Hook’s team cut GC from 31% to 9% not by tuning but by deleting fmt.Sprintf from the hot path. Stop allocating, then reuse, then — rarely — tune. Now when you see GC CPU climbing, your first move is -gcflags=-m on the hot package and an alloc_space heap profile — not a GOGC knob.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.