Crux Read real Go snippets and a gctrace line, predict the GC behaviour, and pick the highest-leverage fix.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at senior altitude — in orbit
◷ 14 min
The allocation profile and the GC log are where GC problems are actually diagnosed. Read the code and the trace, then choose the fix a senior engineer would make first.
Goal
Practise the loop you run in every GC incident: read the hot path, predict where the garbage comes from, and reach for the highest-leverage fix before touching a single tuning knob.
Snippet 1 — the growing slice
func collect(rows []Row) []byte { var out []byte // nil slice, zero capacity for _, r := range rows { line := fmt.Sprintf("%d,%s\n", r.ID, r.Name) out = append(out, line...) // repeatedly grows + copies } return out}
Quiz
Completed
With 100k rows, where does most of the garbage come from, and what is the single highest-leverage fix?
Heads-up `rows` is passed in, not allocated here. The garbage is created inside the loop — Sprintf strings and the repeatedly-grown `out` backing array.
Heads-up append reuses capacity only while it lasts; a zero-cap slice reallocates (typically doubling) many times over 100k rows, copying everything each time.
Heads-up GOGC changes when GC runs, not how much the loop allocates. The fix is to allocate less: pre-size the buffer and drop Sprintf.
Snippet 2 — the pool
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}func render(w io.Writer, v *View) error { b := bufPool.Get().(*bytes.Buffer) defer bufPool.Put(b) // ... write a lot into b ... _, err := w.Write(b.Bytes()) return err}
Quiz
Completed
This pooled-buffer code has a correctness bug that a load test will eventually expose. What is it?
Heads-up sync.Pool is explicitly safe for concurrent Get/Put across goroutines. The bug is hygiene — the buffer is not Reset before reuse.
Heads-up defer runs at function return, after w.Write completes — the ordering is fine. The missing Reset is the defect.
Heads-up The New func only runs when the pool is empty, and the GC can drain sync.Pool between cycles. No leak — the issue is stale state on reuse.
Snippet 3 — the gctrace line
gc 488 @62.1s 39%: 0.41+352+1.1 ms clock, ... 1900->2980->1490 MB, 1990 MB goal, 8 P
Quiz
Completed
Reading this single gctrace line, which statement is correct?
Heads-up The STW phases are tiny and healthy. The pathology is the 352 ms concurrent-mark and the 39% GC CPU — the collector can't keep up with allocation.
Heads-up Ending below goal is normal post-sweep; the alarm is that mark time and GC CPU are exploding, meaning the pacer is losing the race.
Heads-up The X% field is the fraction of CPU time spent in GC since program start, not a per-request pause rate.
Snippet 4 — escape analysis
func newPoint(x, y int) *Point { // returns a pointer... p := Point{x, y} return &p // ...so p escapes to the heap}func sumLocal(x, y int) int { p := Point{x, y} // never escapes return p.X + p.Y // stays on the stack}
Quiz
Completed
Which call allocates on the heap (adding GC work), and what is the general rule?
Heads-up Stack-allocated values cost no GC work. sumLocal's Point never escapes, so escape analysis keeps it on the stack.
Heads-up Arithmetic on stack values allocates nothing. It is escape (a reference outliving the frame), not computation, that forces heap allocation.
Heads-up Go uses the stack only when escape analysis proves the value does not escape. Returning &p forces newPoint's Point onto the heap.
Recap
Every GC incident is read in code and traces: zero-capacity slices and per-iteration Sprintf are classic allocation hotspots; pooled objects must be Reset before reuse; a gctrace line tells you GC CPU share and mark time at a glance; and escape analysis decides stack vs heap based on whether a reference outlives its frame. Diagnose from the profile, fix the allocation, then re-profile to confirm.