Every value our program creates lives in one of two places: the stack or the heap. Where it lives changes how fast it is and who cleans it up.
In simple language: the stack is a fast scratchpad that gets wiped automatically when a function returns. The heap is a big shared storage room that the garbage collector has to clean up later.
Stack vs heap — the difference
- Stack — super fast. Allocating is just bumping a pointer. When the function returns, everything it put on the stack vanishes instantly. No GC involved. Each goroutine gets its own.
- Heap — slower. The runtime has to find space, and later the GC must track and free it. Anything that needs to outlive its function goes here.
So stack = cheap and auto-cleaned. Heap = costs GC effort. We want values on the stack when possible.
✓ freed automatically on return
✗ small + can't outlive the function
✗ GC must track + free it
✓ survives after the function returns
Who decides? The compiler — at compile time
In languages like C, we decide (malloc = heap). In Go, the compiler decides for us, before the program ever runs. This decision-making is called escape analysis.
In simple language: the compiler asks one question for every value — “does anything need this after the function returns?” If no, keep it on the cheap stack. If yes, it escapes to the heap.
It’s “escape” because the value escapes the lifetime of the function that created it.
What causes a value to escape?
A few common patterns push values onto the heap:
- Returning a pointer to a local variable — the local must live on after we return, so it can’t sit on the dying stack frame.
- Storing in an interface (interface boxing) — putting a concrete value into an
interface{}often forces it to the heap. - Closures capturing a variable — if a function literal captures a variable and outlives the parent, that variable escapes.
- Value too big for the stack — huge arrays/slices go to the heap.
- Sending a pointer to a channel — the runtime can’t prove when it’s done, so it escapes.
A quick escape example
// This pointer ESCAPES — u must survive after newUser returns.
func newUser(name string) *User {
u := User{Name: name} // looks local...
return &u // ...but we hand out its address, so it goes to the heap
}
// This does NOT escape — x is used and discarded inside the function.
func sum(a, b int) int {
x := a + b // lives and dies on the stack, never leaves
return x // we return a copy of the value, not its address
}
The first one looks like a classic C bug (returning a pointer to a local). In Go it’s totally safe — the compiler sees the escape and quietly moves u to the heap so the pointer stays valid.
See the decisions yourself
We don’t have to guess. The compiler will tell us with -gcflags="-m":
go build -gcflags="-m" ./...
# example output:
# ./main.go:5:2: moved to heap: u
# ./main.go:11:2: x does not escape
In simple language: -m prints the compiler’s escape-analysis reasoning. “moved to heap” = it escaped. “does not escape” = it stayed on the stack. This is gold for performance work — fewer heap escapes means less GC pressure and faster code.
Bonus: stacks grow
Each goroutine starts with a tiny stack (around 8KB). If it needs more, the runtime allocates a bigger stack and copies the frames over. That’s why we can spin up millions of goroutines cheaply — they don’t pre-reserve big stacks like OS threads do.
Interview soundbite
Go puts values on a fast, auto-freed per-goroutine stack when it can, and on the GC-managed heap when a value must outlive its function — the compiler decides this at compile time via escape analysis. Things like returning a pointer to a local, interface boxing, captured closures, and oversized values cause escapes, and we can inspect every decision with go build -gcflags="-m". Keeping values on the stack reduces GC pressure, which is a key performance lever.