Pointers, the heap, escape analysis & the GC
This is the lesson where Go's "team up" bet meets the machine head-on. C++ hands you the question every value forces — where does this live, and who frees it? — and demands an answer in the code. Go takes that question off your desk and answers it for you: the compiler decides stack or heap, and a garbage collector frees what's dead. That is a convenience the language deliberately bought, and this lesson is about reading the receipt — what a Go pointer is and isn't, who actually chooses where data lives, and exactly what the GC costs so you can bound it.
New capability: explain what a Go pointer can and cannot do versus C, state who decides stack-vs-heap (the compiler, not
new), read the output of escape analysis, and quantify the GC's cost well enough to defend the trade-off in a design review.GOGC), and how to bound its cost.1 · Why pointers exist, and what Go's pointer refuses to do
Lesson 02 established that Go copies by default: pass a struct to a function and the function gets its own copy. That default is a feature — it answers "who owns this?" with "you do, locally" — but it has two limits. First, the callee cannot change the caller's value; the copy is a dead end. Second, copying a large value costs CPU and memory proportional to its size. A pointer solves both: it is a value that holds the address of another value, so passing the pointer (one word, 8 bytes on a 64-bit machine) lets the callee both mutate the original and avoid the copy.
type Account struct {
Balance int
// ... imagine 200 bytes of other fields
}
func deposit(a Account, amount int) { a.Balance += amount } // mutates a COPY — caller unchanged
func depositP(a *Account, amount int) { a.Balance += amount } // mutates the original
func main() {
acct := Account{Balance: 100}
deposit(acct, 50) // acct.Balance still 100 — the copy was thrown away
depositP(&acct, 50) // acct.Balance now 150; & takes the address, * is implied by the . here
}
Two operators do all the work. &x takes the address of x (yields a *T); *p dereferences p to read or write the pointed-to value. Go quietly auto-dereferences for field and method access, so you write a.Balance rather than (*a).Balance — that ergonomic shortcut is why pointers feel lighter in Go than in C.
Now the deliberate subtraction. A C pointer is a number you can do arithmetic on: p + 1, walk off the end of an array, cast an int* to a char*, point at freed memory. That power is exactly the source of the bug classes Go wants gone. So a Go pointer is intentionally crippled:
unsafe package). No walking off the end of a buffer — the canonical C overflow bug simply has no syntax.&localVar from a function — the thing it points to stays alive as long as the pointer does (see §2/§3). The GC guarantees the target outlives the pointer, so use-after-free cannot happen.*Account points at an Account, period. No casting it to a *int to peek at the first field. The type system stays sound.*T is nil. Dereferencing nil panics (a clean, catchable runtime error) rather than corrupting memory like a wild C read.This is question 2 of the five — who owns this data, and how does it move? — made physical. A value is owned locally and copied; a pointer is a deliberate decision to share one piece of state across a boundary. Lesson 04 will show this exact choice resurface as value-vs-pointer receivers on methods, and Lesson 11 will show that shared-via-pointer state is precisely what a mutex must protect.
2 · Stack vs heap — and the keyword does not decide
Every value lives in one of two places. The stack is the per-goroutine region that grows and shrinks with function calls; allocating there is nearly free (bump a pointer) and reclaiming is automatic and instant (the frame is popped on return) — and crucially, stack memory is never the GC's problem. The heap is the shared region for values that must outlive the function that created them; allocating there costs more, and every heap object is something the GC must later trace and free.
Here is the fact that trips up programmers coming from C++: in Go, the keyword you write does not decide where the value lives. In C++, Account a; is on the stack and new Account is on the heap — the syntax is the decision. In Go, &Account{} and new(Account) and a plain var a Account are all just requests; the compiler runs escape analysis and decides for itself. The rule it follows is a safety rule, not a performance one: if a value's lifetime might outlive the function's frame, it must go on the heap; otherwise it can stay on the stack.
This is why returning the address of a local variable is safe in Go and a catastrophe in C. In C, return &local; hands back a pointer into a stack frame that's about to be destroyed. In Go, the compiler sees that the address escapes the function and silently allocates that value on the heap instead, so the pointer is always valid:
func newAccount(start int) *Account {
a := Account{Balance: start} // looks stack-local...
return &a // ...but its address escapes, so the compiler heap-allocates it
} // perfectly safe; the GC frees it when the last pointer is gone
The payoff: the correctness of where data lives is the compiler's job, and it cannot get it wrong. What's left for you is only a performance concern — keeping things on the stack when you can, so the GC has less to do — and that is a thing you tune with evidence, not a thing you can break.
3 · Escape analysis — see it, and steer it
Escape analysis runs at compile time and is conservative: when it can't prove a value stays local, it heap-allocates to be safe. The common reasons a value escapes:
return &a — the pointer outlives the frame, so the target must too (the §2 example).func that references a local keeps that local alive, so the local escapes to the heap.interface{} / any parameter (e.g. fmt.Println) usually forces a heap allocation — the callee's behavior isn't known at the call site. Relevant once you reach interfaces (Lesson 05).You don't have to guess — the compiler will tell you. Build with -gcflags=-m and it prints every escape decision:
$ go build -gcflags='-m' ./...
./acct.go:5:6: &a escapes to heap # the returned address forces a heap allocation
./acct.go:10:13: x does not escape # stays on the stack — free to allocate and reclaim
Why care, when correctness is already handled? Because heap allocation is the GC's input. A stack value costs the GC nothing. A heap value must be allocated, traced on the next cycle, and eventually freed. Cut allocations and you cut GC work directly — and the standard way to find them is go test -benchmem, which reports allocs/op and B/op per benchmark iteration (Lesson 15). The honest order of operations: write clear code first; measure with -benchmem and pprof; only then chase escapes in the proven hot path. Reaching for & everywhere to "avoid copies" often increases heap pressure and is a classic premature pessimization.
-benchmem, not by reflex.4 · The garbage collector — the priced convenience
Here is what Go bought with that money. Manual memory management is the largest single source of memory-safety CVEs in systems software. Go's bet — half (2) of the bargain — is that eliminating that entire bug class, and freeing every engineer from lifetime bookkeeping, is worth a runtime cost provided the cost stays small, bounded, and predictable. So the design goal of Go's GC was never "fastest throughput"; it was low, predictable latency — because an unpredictable multi-millisecond pause is exactly the kind of cost a team can't reason about.
The mechanism is a concurrent, tri-color, mark-and-sweep collector. Tri-color: every object is white (unproven, assume garbage), grey (reachable, not yet scanned), or black (reachable and fully scanned). The collector starts from the roots (stacks, globals), greys them, and works grey→black until none are grey; whatever is still white is unreachable and its memory is swept back to the allocator. Concurrent is the key word: the marking runs alongside your program on a background goroutine, not by freezing it. A write barrier catches the case where your running code rewires a pointer mid-mark, so correctness holds without a global pause.
The cost, with real numbers. There are two brief stop-the-world pauses per cycle (to start and to finish marking); since Go 1.8 (2016) these were engineered down to the sub-millisecond range — typically tens to a few hundred microseconds, and they do not grow with heap size, only with the number of goroutines whose stacks must be rescanned. The heavy lifting — the mark — happens concurrently, so the price you actually pay is not a long freeze but a modest CPU tax: the GC uses up to ~25% of CPU during a cycle, and to keep allocation from outrunning collection it may briefly enlist your allocating goroutine to help (mark assist). The slogan: Go trades a little throughput (those CPU cycles) to buy short, predictable latency.
There is essentially one knob, GOGC (default 100). It sets the heap-growth target: at GOGC=100 the next collection triggers when the live heap has grown 100% — doubled — since the last one. Raise it (GOGC=200) and you collect less often, spending more memory to save CPU; lower it (GOGC=50) and you collect more often, spending CPU to hold memory down. Since Go 1.19 you can also set a hard GOMEMLIMIT (a soft memory ceiling) so a service in a container collects harder as it approaches its limit instead of being OOM-killed. That is the whole tuning surface — deliberately tiny, because a knob no one understands is itself a complexity cost.
| What you control | Effect | The trade |
|---|---|---|
GOGC=100 (default) | Collect when live heap doubles | Balanced CPU vs memory |
GOGC=200 | Collect half as often | Less CPU/fewer pauses, more RAM |
GOGC=off | Disable GC entirely | No GC cost; you must not run out of memory (batch jobs only) |
GOMEMLIMIT=512MiB | Soft memory ceiling; GC works harder near it | Avoids OOM-kill in containers at the price of more GC CPU |
| Allocate less (§3) | Fewer heap objects to trace | The real lever — clear code first, then cut proven hot-path allocs |
And the honest cost, named as the brief demands: the GC is small and bounded, but it is not free, and "bounded pause" still means "pause." For 99.9% of services the sub-millisecond pause and ~25% transient CPU tax are invisible against network and disk latency. But at the extreme latency tail — high-frequency trading, hard real-time control, microsecond-budget request paths — that pause is real, and it is a reason such systems are written in C++ or Rust instead. The mature stance is not to deny the cost; it's to measure your tail (e.g. via the runtime/metrics pause-time histogram) and decide whether Go's bargain fits your latency budget.
Checkpoint exercise
func a() T) and one returns its address (func b() *T). Build with go build -gcflags='-m' . and confirm the compiler reports the value in b escaping to the heap while the one in a does not. Then write a benchmark for each and run go test -bench=. -benchmem; look at the allocs/op column and watch the pointer version report an allocation the value version doesn't. Now walk question 5 of the five — what did it cost, and who pays? — over your choice: the pointer let you share/mutate, but it handed the GC one more object to trace. When was that worth it, and when was the copy the cheaper answer?Where this points next
You now know what a pointer is, who decides where data lives, and what the GC costs — the complete memory model. Lesson 04 spends it. The first real design decision you'll make over and over in Go is whether a method takes a value receiver (func (a Account) ...) or a pointer receiver (func (a *Account) ...) — and that choice is nothing more than this lesson applied to behavior: a pointer receiver when the method must mutate the value or the value is large enough that copying it per call matters, a value receiver when it shouldn't and isn't. Structs, methods, and receivers are next, and they begin the four-lesson arc — values → methods → interfaces → composition — that is how Go builds abstraction without inheritance.
-gcflags=-m and -benchmem, on the proven hot path). The heap is fed to a concurrent tri-color mark-sweep GC tuned for low, predictable latency: sub-millisecond stop-the-world pauses (tens of microseconds, flat in heap size), a transient ~25% CPU tax, and essentially one knob (GOGC, default 100, plus GOMEMLIMIT). That is half (2) of the bargain in its clearest form — a small, bounded, known runtime price paid to delete an entire class of bugs — and the transferable habit is to always ask of any convenience, here automatic memory management: which complexity did it remove, and what is it costing, in numbers, to whom?Interview prompts
- How does a Go pointer differ from a C pointer, and why those restrictions? (§1 — no arithmetic, no reinterpret cast, no dangling, panics on nil deref; each removal deletes a memory-safety bug class — overflow, type confusion, use-after-free — which is "complexity must be earned" applied to the most dangerous power in C.)
- In Go, what decides whether a value is on the stack or the heap? (§2 — the compiler's escape analysis, not
newvs&vsvar; the rule is a safety rule: heap if the value's lifetime can outlive its function frame, stack otherwise.) - Why is returning the address of a local variable safe in Go but a bug in C? (§2 — escape analysis sees the address escapes the frame and heap-allocates the value, so the pointer stays valid; C returns a pointer into a destroyed stack frame.)
- Name three reasons a value escapes to the heap, and how you'd find them. (§3 — its address is returned, it's stored somewhere that outlives the call or sent on a channel, it's captured by an escaping closure, passed to an
interface{}, or too large; find them withgo build -gcflags='-m'andgo test -benchmem.) - Describe Go's GC and its cost in concrete numbers. (§4 — concurrent tri-color mark-and-sweep; two sub-millisecond stop-the-world pauses per cycle (tens of µs, flat in heap size), marking runs concurrently with a write barrier at up to ~25% CPU; tuned for low predictable latency over throughput.)
- What does
GOGCdo, and how would you tune a memory-constrained service? (§4 —GOGC(default 100) sets the heap-growth percentage that triggers a collection; raise it to trade RAM for less CPU, lower it for the reverse; setGOMEMLIMITas a soft ceiling so a container collects harder near its limit instead of being OOM-killed.) - "Use pointers everywhere to avoid copies." Push back. (§3 — for small structs, copying on the stack is often cheaper than heap-allocating and giving the GC another object to trace and a pointer to chase; pass by value for small immutable data, use a pointer to mutate or for genuinely large values, and decide by measuring
allocs/op, not reflex.)