41. Stack, heap and the garbage collector
You never call free in Go, and you never decide where a value lives. But
knowing what the compiler and the collector are doing turns "my program is
slow" from a mystery into a checklist.
Two places a value can live
The stack is per-goroutine scratch space. Function called: frame pushed. Function returns: frame popped, everything in it gone. Allocation is literally moving a pointer, and cleanup is free.
The heap is shared memory for values that must outlive the function that created them. Allocation costs more, and the garbage collector has to track it.
goroutine stack heap
┌────────────────┐ ┌──────────────────────┐
│ main() │ │ │
│ x = 42 │ │ Config{...} ◄──────┼── still referenced
│ c ───────────┼───────►│ │
├────────────────┤ │ []byte{...} │
│ helper() │ │ │
│ tmp = 7 │ │ (unreachable) ─────┼── collectable
└────────────────┘ └──────────────────────┘
pop = free GC finds what's reachable
Go decides which one automatically, per value, using escape analysis.
Escape analysis
The rule is simple to state: if the compiler can prove a value doesn't outlive its function, it goes on the stack. If it can't, the value "escapes" to the heap.
func stays() int {
x := 42 // stack: never leaves
return x // the VALUE is copied out
}
func escapes() *int {
x := 42 // heap: its address leaves the function
return &x
}
func alsoEscapes() {
buf := make([]byte, 1024)
process(buf) // if process stores buf somewhere, it escapes
}
You can see the decisions the compiler made:
$ go build -gcflags='-m' main.go
./main.go:8:2: moved to heap: x
./main.go:14:13: make([]byte, 1024) escapes to heap
-gcflags='-m' is the tool to reach for when you're chasing allocations. It
tells you exactly which line escaped and why.
Common causes of escape:
- returning a pointer to a local
- storing a pointer in a longer-lived structure (a global, a struct field, a channel)
- passing a value to an
interface{}/anyparameter —fmt.Println(x)boxesx, and that boxing escapes - a size the compiler can't know at compile time (
make([]byte, n)) - closures capturing a variable that outlives the function
The garbage collector, briefly
Go's GC is concurrent and tri-colour mark-and-sweep. In practice:
- It runs alongside your program, on all cores, most of the time.
- It pauses every goroutine only briefly — sub-millisecond in normal programs, and the pause doesn't grow with heap size.
- It starts a cycle when the heap has roughly doubled since the last one
(tunable with
GOGC, default 100). - It frees whatever is unreachable from your goroutines' stacks and globals.
You do not tune this. runtime.GC() exists and you should not call it. The
one knob worth knowing is GOMEMLIMIT (a soft ceiling for containers), and
you reach for it when a container is being OOM-killed, not before.
What you actually control: allocation count
The GC's cost scales with how much garbage you make. So the practical performance lever isn't the collector, it's producing less work for it:
package main
import (
"fmt"
"strings"
)
func main() {
words := []string{"go", "is", "efficient", "when", "you", "avoid", "garbage"}
// 1. pre-size a slice you'll fill
out := make([]string, 0, len(words))
for _, w := range words {
out = append(out, strings.ToUpper(w))
}
// 2. build strings with a Builder, not +=
var b strings.Builder
for _, w := range out {
b.WriteString(w)
b.WriteByte(' ')
}
// 3. reuse a buffer instead of allocating per iteration
buf := make([]byte, 0, 64)
total := 0
for _, w := range words {
buf = buf[:0]
buf = append(buf, w...)
total += len(buf)
}
fmt.Println(strings.TrimSpace(b.String()))
fmt.Println("bytes processed:", total, "with one buffer")
}
Three habits, all from earlier modules, now with a reason attached:
- Pre-size with
make([]T, 0, n)— skips the doubling copies. strings.Builderinstead of+=— one growing buffer instead of a new string per concatenation.buf[:0]to reuse a buffer — keeps the array, resets the length, allocates nothing.
Pointers aren't automatically faster
A common wrong instinct is "pointers avoid copying, so pointers are faster." Often they're slower:
// value: stays on the stack, no GC involvement
func sumRect(r Rectangle) float64 { return r.W * r.H }
// pointer: likely escapes, adds a heap allocation and GC work
func sumRectPtr(r *Rectangle) float64 { return r.W * r.H }
A small struct passed by value is copied into a register or a few stack words — cheap, cache-friendly, and invisible to the GC. Turning it into a pointer can force a heap allocation that costs far more than the copy you avoided.
Choose pointers for the semantics (mutation, "unset", non-copyable types).
Choose them for performance only after measuring — module 12 shows you how
with go test -bench and -benchmem.
Memory leaks in a GC language
Go can absolutely leak. The collector frees what's unreachable, so a leak is anything you're still holding by accident:
package main
import "fmt"
func main() {
huge := make([]byte, 10_000_000)
for i := range huge[:100] {
huge[i] = byte(i)
}
leaky := huge[:10]
safe := make([]byte, 10)
copy(safe, huge[:10])
fmt.Println("both hold the same 10 bytes:", leaky[5] == safe[5])
fmt.Println("but 'leaky' pins all", len(huge), "bytes from being freed")
}
The three real leak sources in Go programs:
- Sub-slices of big arrays, as above — the whole backing array stays alive. Copy the piece you need.
- Goroutines that never exit — a goroutine blocked forever on a channel is unreachable-but-alive, and everything it references stays too. (Next module.)
- Unbounded caches and maps — a
mapthat only ever grows is a leak with extra steps. Note that deleting keys shrinks the map's length but doesn't return the buckets to the OS.
Your turn
Fix the leak: firstTen currently returns a sub-slice that pins the whole
10-million-byte array. Return an independent copy instead, so the big array
can be collected:
10 10
independent: true
package main
import "fmt"
func firstTen() []byte {
huge := make([]byte, 10_000_000)
for i := 0; i < 10; i++ {
huge[i] = byte(i)
}
return huge[:10] // pins all 10 MB — fix this
}
func main() {
out := firstTen()
fmt.Println(len(out), cap(out))
fmt.Println("independent:", cap(out) == 10)
}
package main
import "fmt"
func firstTen() []byte {
huge := make([]byte, 10_000_000)
for i := 0; i < 10; i++ {
huge[i] = byte(i)
}
out := make([]byte, 10)
copy(out, huge[:10])
return out
}
func main() {
out := firstTen()
fmt.Println(len(out), cap(out))
fmt.Println("independent:", cap(out) == 10)
}
That's memory. Next module is the one Go is famous for: concurrency.