GC and Escape Analysis
Where this picks up
Section titled “Where this picks up”The Pointers lesson already introduced escape analysis at the level you need to know “a pointer isn’t always faster,” with one worked example (newConfig returning &c) and one real -gcflags=-m capture. This lesson goes further in two directions: the specific shapes of code that force an escape, read from real compiler output every time, and what actually happens to everything that does escape — the collector that has to track it.
Escape analysis: the shapes that force the heap
Section titled “Escape analysis: the shapes that force the heap”Five patterns account for almost every escape a reviewer needs to recognize on sight. Each one below is a real, isolated, //go:noinline program compiled with go build -gcflags='-m -m' on Go 1.26.5 — the decision lines only, with inlining diagnostics trimmed for readability (the full output includes many more lines about inlining budgets that do not change the escape verdict).
1. Returning a pointer to a local variable — already covered in Pointers, restated here as the baseline:
func newConfig() *Config { c := Config{Workers: 4} return &c}./main.go:7:2: moved to heap: c2. Boxing into an interface:
func asAny(n int) any { return n}./main.go:5:9: n escapes to heap3. A closure capturing a variable by reference:
func makeCounter() func() int { count := 0 return func() int { count++ return count }}./main.go:5:2: moved to heap: count./main.go:6:9: func literal escapes to heap4. A slice of pointers:
func pointerSlice(n int) []*int { out := make([]*int, n) for i := 0; i < n; i++ { v := i out[i] = &v } return out}./main.go:7:3: moved to heap: v./main.go:5:13: make([]*int, n) escapes to heap5. A size the compiler cannot know at compile time:
func makeBuf(n int) []byte { return make([]byte, n) // size not known at compile time -- runtime decides}./main.go:5:13: make([]byte, n) escapes to heapA make with a compile-time constant size and small enough to prove non-escaping stays on the stack; the moment the size is a variable (n, unknown until runtime), the compiler cannot reserve a fixed amount of stack space for it ahead of time, so it goes to the heap regardless of whether the slice itself ever outlives the function.
None of these five require memorizing compiler internals — they are the concrete version of “does this value’s lifetime provably end when the function returns.” A value that is returned by pointer, stored in an interface, closed over, pointed to from something that outlives the pointee, or sized dynamically fails that test.
Measuring allocations directly: testing.AllocsPerRun
Section titled “Measuring allocations directly: testing.AllocsPerRun”Reading -gcflags output predicts allocations; testing.AllocsPerRun measures them, and — unlike a full benchmark — runs fine as a plain program, no go test binary required.
package main
import ( "fmt" "testing")
type Config struct{ Workers int }
var sinkPtr *Configvar sinkVal Configvar sinkAny any
//go:noinlinefunc newConfigPtr() *Config { c := Config{Workers: 4} return &c}
//go:noinlinefunc newConfigVal() Config { return Config{Workers: 4}}
//go:noinlinefunc asAnySmall(n int) any { return n}
//go:noinlinefunc asAnyLarge(n int) any { return n}
func main() { avgPtr := testing.AllocsPerRun(1000, func() { sinkPtr = newConfigPtr() // escapes: stored in a package-level var }) avgVal := testing.AllocsPerRun(1000, func() { sinkVal = newConfigVal() // stays a value -- no pointer ever taken }) avgBoxSmall := testing.AllocsPerRun(1000, func() { sinkAny = asAnySmall(42) // small int (0-255): Go's staticuint64s table, no alloc }) avgBoxLarge := testing.AllocsPerRun(1000, func() { sinkAny = asAnyLarge(424242) // outside the small-int table: real heap alloc }) fmt.Printf("newConfigPtr (escapes, stored): %.0f allocs/op\n", avgPtr) fmt.Printf("newConfigVal (value, no escape): %.0f allocs/op\n", avgVal) fmt.Printf("asAny(42) (small int, 0-255): %.0f allocs/op\n", avgBoxSmall) fmt.Printf("asAny(424242) (large int): %.0f allocs/op\n", avgBoxLarge)} Real output on Go 1.27, darwin/arm64:
newConfigPtr (escapes, stored): 1 allocs/opnewConfigVal (value, no escape): 0 allocs/opasAny(42) (small int, 0-255): 0 allocs/opasAny(424242) (large int): 1 allocs/opThe third line is not a mistake: the runtime keeps a static table of pre-boxed any values for small integers (0–255) — runtime.staticuint64s — so boxing one of them costs nothing. Box 424242, outside that table, and the real allocation reappears. This is a genuine runtime detail, not a simplification for teaching: it is exactly why “does this box a small integer constant” is not a reliable signal by itself when reading code for allocations — the value, not just the shape, can matter.
The collector: tri-color concurrent mark and sweep
Section titled “The collector: tri-color concurrent mark and sweep”Go’s collector is documented, in its own source (runtime/mgc.go, Go 1.26.5), as “a concurrent mark and sweep that uses a write barrier… non-generational and non-compacting.” Concurrent means most of its work happens while your goroutines keep running, not during one long pause — a deliberate trade against languages whose collectors fully stop the program to collect.
The algorithm classifies every object into one of three colors, exactly as the runtime source describes it:
- White — not yet found reachable this cycle (candidates for collection).
- Grey — found reachable, but its own pointers have not been scanned yet.
- Black — found reachable, and every pointer inside it has been scanned.
A GC cycle starts with everything white, moves the roots (globals, goroutine stacks) to grey, and then repeatedly picks a grey object, scans it (turning it black and turning everything it points to grey), until no grey objects remain. Whatever is still white at that point is genuinely unreachable and gets swept.
flowchart LR
subgraph Start["Cycle start"]
W1["white: everything"]
end
subgraph Marking["Marking (concurrent with your goroutines)"]
G["grey: reachable, not yet scanned"]
B["black: reachable, fully scanned"]
G -->|scan, shade pointers found| B
G -->|newly-found pointers| G
end
subgraph End["Cycle end"]
W2["still white = unreachable -> swept"]
end
W1 --> G
B --> End The write barrier keeps marking safe while your program keeps running
Section titled “The write barrier keeps marking safe while your program keeps running”Marking concurrently with a running program creates one real danger: your goroutine could write a pointer from a black (already-scanned) object to a white (not-yet-found) object, then overwrite or lose the only other reference to that white object — the collector would never revisit the black object to find it, and would sweep away something still reachable. The write barrier — extra code the compiler inserts around every pointer write while a GC cycle is active — is what prevents this. Per the source comment: “The write barrier shades both the overwritten pointer and the new pointer value for any pointer writes” — turning both the old and new target grey immediately, so nothing a live pointer write touches can be missed. The write barrier is only active during the marking phase (_GCmark/_GCmarktermination in the runtime’s phase enum) — outside a GC cycle, an ordinary pointer write costs nothing extra.
The two stop-the-world pauses
Section titled “The two stop-the-world pauses”Per go doc runtime (the gctrace section of runtime/extern.go): “The phases are stop-the-world (STW) sweep termination, concurrent mark and scan, and STW mark termination.” Only the first and third of those three actually stop every goroutine; the middle phase — almost all the wall-clock time of a GC cycle — runs alongside your program. This is the single most common misreading of “GC pause”: the multi-millisecond number you see in a trace is mostly not a pause at all.
Reading a real GC pause with GODEBUG=gctrace=1
Section titled “Reading a real GC pause with GODEBUG=gctrace=1”A small linked-list-building program, GODEBUG=gctrace=1, Go 1.27, darwin/arm64:
gc 1 @0.013s 3%: 0.055+0.41+0.39 ms clock, 0.61+0.36/0.74/0+4.3 ms cpu, 3->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 11 PReading it against the exact field documentation from go doc runtime:
| Segment | Meaning here |
|---|---|
gc 1 | The first GC cycle this process has run |
@0.013s | 13ms after the program started |
3% | 3% of all CPU time since start has gone to GC so far |
0.055+0.41+0.39 ms clock | The three phases, wall-clock: 0.055ms STW sweep termination, 0.41ms concurrent mark (your goroutines kept running), 0.39ms STW mark termination |
0.61+0.36/0.74/0+4.3 ms cpu | CPU time (not wall-clock) for the same phases, with the concurrent-mark figure further split into assist/background/idle GC work |
3->4->1 MB | Heap size at cycle start, at cycle end, and the live heap actually found reachable |
4 MB goal | The heap size that would trigger the next cycle |
0 MB stacks, 0 MB globals, 11 P | Scannable stack size, scannable global size, and GOMAXPROCS at the time |
The real “pause” a request handled during this cycle would feel is the two STW numbers added together — roughly 0.45ms here — not the full 0.86ms total, and definitely not the 13ms timestamp. A goroutine running throughout the concurrent-mark phase is slowed by write-barrier overhead and possibly GC assist work, but it is never stopped by it.
GOGC and GOMEMLIMIT
Section titled “GOGC and GOMEMLIMIT”GOGC sets the target heap growth ratio: the default, 100, means “trigger a collection when the heap has grown to roughly double the size of the live heap after the last collection.” Raising it trades memory for fewer, less frequent collections; GOGC=off disables the ratio trigger entirely (collections still happen, just far less often — a forced periodic GC or an explicit runtime.GC() call still runs). Real cycle counts building the same 200,000-node linked list, GODEBUG=gctrace=1, counting lines that start with gc :
GOGC (default, 100): 15 GC cyclesGOGC=400: 6 GC cyclesGOGC=off: 3 GC cyclesGOMEMLIMIT is a different knob entirely: a soft memory ceiling in bytes, independent of GOGC’s ratio. Per runtime/debug.SetMemoryLimit’s doc: “This limit will be respected even if GOGC=off.” Verified directly — adding a 20 MiB limit on top of GOGC=off:
GOMEMLIMIT=20MiB GOGC=off: 36 GC cyclesGOGC=off alone dropped the cycle count to 3; adding a memory limit on top of that same GOGC=off run pushed it back up to 36 — the collector is running far more often specifically to stay under the byte ceiling, exactly as documented, completely ignoring the fact that the ratio trigger is nominally disabled. GOMEMLIMIT is the right tool for “never let this process exceed the container’s memory limit”; GOGC is the right tool for “trade memory for CPU” when there is no hard ceiling to respect.
sync.Pool: reuse without the boxing trap
Section titled “sync.Pool: reuse without the boxing trap”sync.Pool holds a set of temporary objects for reuse instead of letting them get garbage collected and reallocated — genuinely useful for anything expensive to allocate and used briefly and repeatedly (a scratch buffer per request, in the shape net/http’s own internals use it). But sync.Pool.Get/Put operate on any, and storing a value into an interface allocates unless the value already fits in the interface’s single data word (a pointer does; a multi-word type like a raw []byte header does not). Measured with testing.AllocsPerRun, pooling the wrong shape can cost more than not pooling at all:
pooling a raw []byte value (boxed into `any` on every Put): 1 allocs/opnot pooling at all (bytes.Buffer stays a local value): 1 allocs/oppooling *bytes.Buffer (a pointer, fits in the interface): 0 allocs/oppackage main
import ( "bytes" "fmt" "sync" "testing")
var bufPool = sync.Pool{ New: func() any { return new(bytes.Buffer) // pointer type: fits directly in the interface word },}
//go:noinlinefunc withoutPool() { var buf bytes.Buffer buf.WriteString("hello") _ = buf.String()}
//go:noinlinefunc withPool() { buf := bufPool.Get().(*bytes.Buffer) buf.Reset() buf.WriteString("hello") _ = buf.String() bufPool.Put(buf)}
func main() { avgWithout := testing.AllocsPerRun(1000, withoutPool) avgWith := testing.AllocsPerRun(1000, withPool) fmt.Printf("without sync.Pool (bytes.Buffer value): %.0f allocs/op\n", avgWithout) fmt.Printf("with sync.Pool (*bytes.Buffer): %.0f allocs/op\n", avgWith)} Real output on Go 1.27, darwin/arm64:
without sync.Pool (bytes.Buffer value): 1 allocs/opwith sync.Pool (*bytes.Buffer): 0 allocs/opThe pattern that works: pool a pointer to something with real internal state to reuse (*bytes.Buffer, a *MyScratchStruct), reset it after Get, and never assume Get returns a zeroed value — the whole point is that it might be handing you something a previous caller used. sync.Pool also has a documented lifetime quirk worth knowing when reading code that relies on it: the garbage collector is free to clear the entire pool on any GC cycle, so it is only a may-reuse cache, never a guaranteed-reuse one, and code must work correctly (just slower) on a cache miss.
-benchmem: turning escape predictions into numbers
Section titled “-benchmem: turning escape predictions into numbers”go test -bench=. -benchmem reports B/op and allocs/op per iteration — the benchmark-shaped sibling of testing.AllocsPerRun, with real timing alongside it. This requires the go test binary, so it is a fenced snippet here, not a Playground — run it locally with go test -bench=. -benchmem. Real output for the pointer-vs-value pair from earlier in this lesson:
goos: darwingoarch: arm64pkg: benchcpu: Apple M3 ProBenchmarkNewConfigPtr-11 186178467 6.137 ns/op 8 B/op 1 allocs/opBenchmarkNewConfigVal-11 692306694 1.734 ns/op 0 B/op 0 allocs/opThe -11 suffix is GOMAXPROCS at benchmark time, not part of the name. The value version is not just allocation-free — it runs 3.5x faster on this machine, because escaping to the heap also means the object is now something the collector has to track on every subsequent cycle until it becomes unreachable, not just a one-time allocation cost. Reading -m -m output predicts the allocs/op column; it never predicts the ns/op column by itself, since that also depends on cache behavior, GC load, and everything else running at the time.
runtime/metrics: reading the collector’s own numbers from inside the program
Section titled “runtime/metrics: reading the collector’s own numbers from inside the program”runtime/pprof and GODEBUG=gctrace are both external observation; runtime/metrics is how a running program reads the same kind of data about itself — the stable, typed successor to the older, harder-to-extend runtime.ReadMemStats.
package main
import ( "fmt" "runtime/metrics")
func main() { samples := []metrics.Sample{ {Name: "/gc/heap/allocs:bytes"}, {Name: "/gc/heap/objects:objects"}, {Name: "/sched/goroutines:goroutines"}, {Name: "/memory/classes/heap/free:bytes"}, } metrics.Read(samples) for _, s := range samples { switch s.Value.Kind() { case metrics.KindUint64: fmt.Printf("%-30s %d\n", s.Name, s.Value.Uint64()) case metrics.KindFloat64: fmt.Printf("%-30s %f\n", s.Name, s.Value.Float64()) default: fmt.Printf("%-30s (unsupported kind)\n", s.Name) } }} Real output (exact byte and object counts vary run to run — this is what one real run on Go 1.27 produced):
/gc/heap/allocs:bytes 122752/gc/heap/objects:objects 15/sched/goroutines:goroutines 6/memory/classes/heap/free:bytes 0metrics.All() lists every metric the running Go version supports, each with a stable name and description — the same mechanism a monitoring library like expvar or a Prometheus exporter uses internally, rather than scraping runtime.MemStats fields whose meaning and presence have shifted across Go releases.
Mental Model
Section titled “Mental Model”flowchart TD
subgraph EA["Escape analysis (compile time)"]
Q["Does this value's lifetime end when the function returns?"]
Q -->|"yes"| Stack["stack (free)"]
Q -->|"no"| Heap["heap (GC-tracked from here on)"]
end
subgraph GC["The collector (runtime, ongoing)"]
White["white"] --> Grey["grey"] --> Black["black"]
WB["write barrier keeps this safe while goroutines run"]
STW1["STW sweep-term (short)"] --> CM["concurrent mark (most of the time)"] --> STW2["STW mark-term (short)"]
end Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Concurrent mark and sweep | Goroutines keep running through most of a GC cycle | Write barrier overhead on every pointer write while marking |
| Escape analysis keeping values on the stack | Zero-cost allocation and zero GC tracking | The compiler’s proof is conservative — some genuinely safe cases still escape |
Raising GOGC | Fewer, less frequent collections | Higher peak memory (a bigger heap before each trigger) |
GOMEMLIMIT | A hard ceiling regardless of GOGC | Collections become the response to memory pressure, which can mean more frequent, harder-working GC near the limit |
sync.Pool (pointer types) | Fewer allocations for expensive, reused objects | Pool contents can vanish on any GC; only ever a may-reuse cache |
Common Misconceptions
Section titled “Common Misconceptions”- “The number in a gctrace line is how long the program paused.” Only the first and third of the three clock numbers are STW; the middle one, almost all the wall-clock time, runs concurrently with your goroutines.
- “
GOGC=offmeans no more GC, ever.” It disables the heap-growth-ratio trigger specifically; a periodic forced collection andruntime.GC()calls still run, andGOMEMLIMITstill forces collections if set, regardless ofGOGC. - “Boxing a small integer into
anyalways allocates.” Values 0–255 are served from a static table (runtime.staticuint64s) and cost nothing; the allocation reappears the moment the value falls outside that range. - “
sync.Poolalways reduces allocations.” Pooling a value type that does not fit in one interface word (a raw slice, a plain struct larger than a pointer) can cost an allocation on everyPutfrom interface boxing — pool a pointer instead. - “Escape analysis output is exhaustive proof a value never escapes.” It is a real compiler decision that changes across Go releases and inlining decisions; treat
-gcflags=-moutput as ground truth for this build, not a permanent property of the source text.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated Go defensively returns pointers and boxes values into any far more than the code actually needs to, on the theory that both are “safer.” Check for these:
- Does a function return
&SomeStruct{...}when the caller only ever reads a field once and never needs to mutate the original or share it? A returned value, not a pointer, would let the compiler keep it off the heap. - Is something stored in a
map[string]any, aninterface{}parameter, or passed throughfmt.Sprintf("%v", x)when a concretely-typed alternative would do the same job without boxing? - Is a
sync.Poolstoring a raw slice, array, or plain struct value instead of a pointer to one — quietly paying a boxing allocation on everyPut? - Does a benchmark or performance claim in a comment (“this avoids allocation”) lack an actual
-benchmemorAllocsPerRunnumber backing it up? - Is
GOGCorGOMEMLIMITtuned in a comment or config file with a claimed effect (“this eliminates GC pauses”) that confuses disabling the ratio trigger with disabling collection entirely?
🐛 Spot the bug
type Stats struct { Requests int Errors int}
func recordStats(s Stats) map[string]any { return map[string]any{ "requests": s.Requests, "errors": s.Errors, }}
func logAll(entries []Stats) { for _, e := range entries { m := recordStats(e) fmt.Println(m) }}Show the answer
recordStats boxes two int fields into any values inside a freshly allocated map[string]any, on every call, for data that is only ever printed. Every map[string]any allocates the map’s internal buckets, and every non-small-int value assigned into it (here, s.Requests and s.Errors are only avoiding a box if they happen to land in the 0–255 static table — a request counter will not stay there for long) boxes into a fresh heap allocation. None of that indirection buys anything here: fmt.Println would print a concretely-typed struct exactly as well, with zero of these allocations, because Stats itself never needs to leave the stack.
func logAll(entries []Stats) { for _, e := range entries { fmt.Printf("requests=%d errors=%d\n", e.Requests, e.Errors) }}If a genuine need for a dynamically-shaped value exists (serializing to JSON with optional fields, say), reach for map[string]any there — the pattern is not wrong in general, only wrong here, where the concrete struct already had everything the caller needed.
💡 Real-world example
net/http’s server (net/http/server.go, standard library) keeps async.Poolof*bufio.Readerand*bufio.Writervalues (newBufioReader/putBufioReaderand their writer equivalents) for exactly the reason this lesson describes: connections are opened and closed constantly, and rebuilding a fresh buffered reader/writer per connection would be a real allocation cost multiplied across every request — pooling the pointer types avoids the boxing cost this lesson’ssync.Poolexample demonstrates, and the GC-can-clear-it-anytime semantics are fine because a cache miss just means allocating a fresh one, same as not pooling at all.