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.
GOGC sets the gap between cleanups, measured as a percentage of what's still alive.
The knob is a dial between two costs
You can't minimise both memory and CPU — the knob trades one for the other:
- 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.
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.
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.
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.
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.
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, oratomic.*(go vetflags a copy). nilmust 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