Heap allocations drive GC cost; knowing what escapes lets you fix hot paths with evidence instead of guesses.
Go doesn't let you choose stack or heap — the compiler does, through escape analysis. A value stays on the goroutine's stack, freed for free when the function returns, if the compiler can prove no reference to it outlives the call. Otherwise it escapes to the heap and becomes the garbage collector's problem.
See the decisions with go build -gcflags=-m ./... (add a second -m for reasons). You'll see lines like moved to heap: x, &T{...} escapes to heap and p does not escape.
Common reasons a value escapes:
- Its address is returned, or stored in a global, a heap object, or a channel.
- It's converted to an interface and the callee isn't analysable — passing values to
fmt.Printlnis the classic case. - A closure captures it and the closure itself escapes (e.g. it's started with
go). - Its size isn't known at compile time, like
make([]byte, n)for variablen(beyond small cases), or it's very large.
Function inlining matters: when a small constructor is inlined, its result may no longer escape. -gcflags=-m also reports what was inlined.
Goroutine stacks start small and grow by copying to a bigger stack, so the runtime adjusts pointers into the stack. That's one reason you can't hand stack pointers to C.
Techniques for hot paths, once a profile proves they matter:
- Let callers pass in buffers (
Append-style APIs such asstrconv.AppendInt) instead of returning new slices. - Preallocate slices and maps with known sizes.
- Reuse temporary objects with
sync.Pool. - Avoid interface conversions and
fmtin inner loops.
Measure with -benchmem or testing.AllocsPerRun; don't distort clean code for allocations that don't show up in a profile.
Example
// go build -gcflags=-m escape.go
package main
import (
"fmt"
"strconv"
)
type point struct{ x, y int }
func newPoint() *point { return &point{1, 2} } // &point{...} escapes to heap
func sum() int {
p := point{3, 4} // stays on the stack
return p.x + p.y
}
func main() {
p := newPoint() // inlined here: &point{...} does not escape
fmt.Println(p.x, sum()) // args escape: converted to interface
buf := make([]byte, 0, 32) // constant size, does not escape
buf = strconv.AppendInt(buf, 42, 10)
println(string(buf)) // 42
}Remember
- The compiler puts a value on the heap only if it may outlive its function
-gcflags=-mshows escape and inlining decisions- Interfaces, captured variables and returned pointers are common escape causes
Watch out for
- Micro-optimising away allocations that never appear in a profile, making code harder to read for no gain.