How it works
The explanation, with examples you can run.
Watching the decisions
package main import "fmt" type point struct{ x, y int } func sum(p point) int { return p.x + p.y } // p stays on the stack func newPoint() *point { p := point{1, 2} return &p // outlives the call: moved to the heap} func main() { fmt.Println(sum(point{3, 4}), newPoint().x)}$ go build -gcflags=-m ../main.go:7:6: can inline sum./main.go:9:6: can inline newPoint./main.go:10:2: moved to heap: p./main.go:15:17: ~r0 escapes to heap...(trimmed output from Go 1.26). moved to heap: p is the escape. The escapes to heap lines for main come from fmt.Println: its parameters are ...any, and a value stored in an interface that the callee may keep must live on the heap. Add a second -m for the reasoning behind each decision.
Returning &p is not a bug in Go, as it would be in C. There are no dangling pointers to locals; the cost is an allocation.
What makes values escape
- Returning or storing a pointer to a local where it can outlive the call: a global, a field of a heap object, a channel, a closure that outlives the function.
- Converting a value to an interface when the compiler can't see that the callee won't keep it.
fmtand logging calls are the usual source. - Sizes unknown at compile time, such as
make([]byte, n)for a variablen, and values too large for the stack. - Calls the compiler can't see through, such as calls through function values and interface methods, unless inlining or devirtualisation reveals the target.
Inlining matters: once newPoint is inlined into its caller, a pointer that doesn't leave the caller may not need the heap at all. The exact rules change between releases, so read -m output instead of memorising them, and measure with go test -bench . -benchmem, which reports allocations per operation.
Goroutine stacks grow
An operating-system thread typically reserves a fixed stack of a megabyte or more. A goroutine starts with a few kilobytes. Each function's prologue checks whether there is enough room; if not, the runtime allocates a stack twice the size, copies the frames over, and adjusts pointers that point into the stack. Stacks can also shrink during garbage collection. This is why stack-allocated values are only reachable from their own goroutine, and why Go can't let C code hold pointers into a goroutine's stack.
Because growth is a copy, very deep recursion is slower than it looks. Recursion is bounded by a maximum stack size (1 GB on 64-bit systems by default, adjustable with debug.SetMaxStack), and a runaway recursion ends with runtime: goroutine stack exceeds 1000000000-byte limit and fatal error: stack overflow rather than a segmentation fault.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.