Notes from production
GoGarbage CollectionMemory

Go garbage collection: stack, heap, and pointers

Go makes you a deal most languages don't: you never free memory yourself. You allocate freely, and a background cleanup crew — the garbage collector — reclaims whatever you stop using. The price of that safety is a little CPU time and some spare room. This article is the story of where your data lives (a notepad and a pantry), when the cleaner shows up (the GOGC dial), and the one quiet decision that moves work from the cheap place to the expensive one: reaching for a * pointer.

When the cleaner shows up — the GOGC heuristic

The collector doesn't run constantly; that would waste effort. It waits until enough new garbage has piled up, and GOGC (default 100) sets how much:

// target heap = live × (1 + GOGC/100)
// GOGC=100, live=10GB  →  target = 20GB
// The GC runs once another 10GB has been allocated.

So the popular one-liner is true: if my live memory is 10GB, Go waits until another 10GB is allocated before collecting. Two footnotes keep it honest. "Live" means the heap that was marked reachable at the end of the last cycle — a baseline that moves every cycle, not a fixed number. And since Go 1.19 it isn't the only trigger.

MARKlive=10GBALLOCATEheap growssweep?SWEEPfree deadyesnew live_heap → next baseline (every cycle)heap ≥ 20GB || heap ≥ GOMEMLIMITtrue → run the sweep · false → keep allocating
The loop: mark live, allocate, then sweep only when heap ≥ the GOGC target OR heap ≥ GOMEMLIMIT — otherwise keep allocating. After the sweep, the new live heap is the next baseline.

GOGC sets the gap between cleanups, measured as a percentage of what's still alive.

the one-line mental model

The knob is a dial between two costs

You can't minimise both memory and CPU — the knob trades one for the other:

GOMEMLIMITGOGC=50@15GB · GC oftenGOGC=100@20GB · balancedGOGC=200@30GB · GC rareliveheadroom (GOGC)
The knob slides the headroom band. Live (solid) is fixed; only the headroom changes. GOMEMLIMIT is a ceiling that forces a collection early.
  • Low GOGC (e.g. 50) — clean often. The heap stays tidy (less memory) but the cleaner runs constantly (more CPU).
  • High GOGC (e.g. 200) — clean rarely. The cleaner barely works (less CPU) but the heap runs high (more memory).
  • GOGC=off — never collect on growth; the heap climbs until it hits a limit.

The notepad and the pantry — stack vs heap

Why does the cleaner exist at all? Because of where data can live. There are two places, and they could not be more different.

The stack is a waiter's notepad. A function writes its locals on the top page, and when it returns, the page is torn off and thrown away — instantly, in order, no thinking required. Each goroutine carries its own notepad. Nothing has to figure out what's garbage: it's obvious by position.

STACK · the notepadframe: localstorn off on return →
The stack is a notepad: each function frame is a page, torn off the instant the function returns — no cleanup crew needed.

The heap is a shared pantry. Anyone can store something, and it stays as long as it's needed — used up in no particular order. Because you can't just "tear off the top," somebody has to walk the shelves and ask "can anyone still reach this?" That somebody is the cleaner — the garbage collector. The pantry is the only reason the cleaner exists.

HEAP · the workstationyou · the cookcleaner · GCyou pile it on · the cleaner clears the unused
The heap is the cook's workstation: you pile objects on as you work, and the cleaner (GC) clears away whatever you're done with so the bench stays usable.

The compiler decides which place each value goes, via escape analysis. A value that doesn't outlive its function stays on the notepad; one that escapes goes to the pantry:

func onStack() int {
	p := &Point{X: 1, Y: 2} // local pointer, never leaves → STACK, free
	return p.X
}

func onHeap() *Point {
	return &Point{X: 1, Y: 2} // returned → escapes → HEAP, the cleaner now tracks it
}

Both come from 4KB pages

Neither the notepad nor the pantry talks to the operating system directly. The OS hands out memory in exactly one unit — a 4KB page — and you can only take or return a whole page, never half of one.

STACKthe notepad4KB4KB4KB4KB4KB4KBfrom the OS — a page at a timeHEAPthe pantry64MB arena64MBfrom the OS — one wholesale arena
Stack and heap, and where each gets memory: the OS feeds the stack 4KB pages one at a time (watch one rise into each frame), and hands the heap one 64MB arena wholesale — both carved from the same 4KB-page RAM.

What differs is how each side uses those pages. The stack uses them directly: each goroutine's stack is a small contiguous run of pages (it starts at ~8KB — two pages). Outgrow it and Go maps a bigger run, copies the stack over, and frees the old pages — cheap, automatic, no fragmentation. The heap uses them wholesale: asking the OS every time is a slow syscall, so Go buys a 64MB arena (≈16,384 pages) at once and slices it into spans of equal-sized slots — which is why most allocations never touch the kernel.

4KB pageOS unitSTACK4KB4KB4KBcontiguous run · grows by copying · no arenaHEAP64MB ARENA≈ 16,384 × 4KB pagesspans→ object slotsGo buys pages wholesale, then resells slots
The OS only deals in 4KB pages. The stack takes a contiguous run of them; the heap buys them wholesale as 64MB arenas, then slices each arena into spans of object slots.

Stack — the notepad

  • A contiguous run of pages, grown by copying to a bigger run.
  • Pages freed the instant the goroutine ends — one tidy block, no holes.
  • No arena, no GC: page use tracks exactly what's live.

Heap — the pantry

  • Pages bought wholesale as 64MB arenas, sliced into spans → slots.
  • A page frees only when every object on it dies — one survivor pins the whole 4KB page.
  • Freed pages return to the OS lazily, so RSS lags real use (usually normal, not a leak).

That last point is the classic surprise: after the cleaner frees objects, Go often keeps the pages — returning them is slow — and hands them back only under pressure, so top can show high memory long after the heap shrank.

When to reach for *

Here's the trap. The intuition "a pointer is smaller, so passing *MyStruct saves memory" is backwards in Go. A pointer doesn't make the struct vanish — the struct still lives somewhere. And taking & often forces that somewhere to be the heap, which is the expensive thing the whole GC exists to manage:

func makeValue() Point  { return Point{} }  // can stay on the STACK — no GC
func makePtr()   *Point { return &Point{} } // escapes → HEAP — the cleaner marks it forever

Copying a small struct on the stack is essentially free. A heap allocation is not — and worse, it's a recurring cost: the cleaner re-marks that object on every cycle until it's unreachable. So * isn't a memory optimisation. It's a semantics tool — for sharing and mutation — that happens to push data onto the heap.

&MyStruct{}take a pointerescapes?noSTACKfree · no GCyesHEAPGC marks it
Escape analysis, not the * symbol, decides the home. A pointer that never escapes still stays on the stack; a value that escapes still lands on the heap.

The rules that override the lean-toward-values default — break these and you have bugs, not just slower code:

Use a pointer when

  • You must mutate the original (a value receiver/param gets a copy; your changes vanish).
  • The struct holds a no-copy field — a sync.Mutex, WaitGroup, or atomic.* (go vet flags a copy).
  • nil must be a valid state ("absent"), as in (*T, error).
  • The struct is large enough that copying it costs more than one heap allocation + GC scan.

Prefer a value when

  • It's small and read-only — a value parameter that doesn't escape never touches the heap.
  • You're returning a fresh small/medium struct — return T{} can stay on the caller's stack; return &T{} forces the heap.
  • None of the pointer rules above apply. Then values keep data on the notepad, and the cleaner never sees it.

Don't guess where things land — ask the compiler. Escape analysis is printable:

go build -gcflags='-m' ./...
# ./main.go:12:10: &Point{} escapes to heap
# ./main.go:6:9:   &Point{} does not escape

Every escapes to heap line is GC pressure you can see — and often the value version allocates zero.

The whole chain ties back to one rule: keep data on the notepad when you can; use the pantry only when you must. The cheapest object the cleaner ever handles is the one you never put on the heap.

Sources

  1. A Guide to the Go Garbage Collector — go.dev
  2. runtime — GOGC & GOMEMLIMIT (package docs)
  3. Getting to Go: the journey of Go's garbage collector