Stack vs Heap Allocation in Go: How Escape Analysis Works
You write a function that creates a struct, does some work, and returns a pointer to it. In C, this crashes. The function returns, the stack frame is destroyed, and the pointer dangles into freed memory. In Go, the code compiles and runs perfectly. You might assume Go is copying everything to the heap just to be safe, and you worry about garbage collection overhead killing your performance. The truth is more efficient. Go performs escape analysis at compile time. It tracks the lifetime of every variable and decides exactly where to allocate it. If a value stays within the function, it lives on the stack. If it needs to survive longer, it moves to the heap. You write the logic; the compiler handles the memory.
The desk and the filing cabinet
Think of the stack as your desk and the heap as a shared filing cabinet. When you start a task, you grab papers from the desk. You work on them, and when you finish, you clear the desk. The stack works the same way. Variables are allocated by moving a pointer, which is incredibly fast. When the function returns, the pointer moves back, and the memory is reclaimed instantly. No cleanup needed.
The heap is the filing cabinet. You put a file in, get a label, and the file stays there until you explicitly discard it. Accessing the heap is slower. You have to find the slot, manage the label, and eventually a janitor (the garbage collector) comes around to throw away files that no one is using.
Escape analysis is the compiler checking your papers before you leave the desk. If you're taking a paper home, the compiler moves it to the filing cabinet automatically. If you're just using it for the task, it stays on the desk. The compiler makes this decision based on whether the variable's lifetime extends beyond the current function scope.
The compiler decides. You write code. Trust the analysis.
Minimal example: returning a pointer
Here's the simplest case: a function returns a pointer to a local variable. The compiler must ensure the variable survives the return.
package main
import "fmt"
// MakeConfig returns a pointer to a newly created Config.
func MakeConfig() *Config {
// c is declared locally. The compiler sees the return address and knows c must survive past this function.
c := Config{Port: 8080}
// Returning &c forces c to escape to the heap because the caller needs access after this function returns.
return &c
}
type Config struct {
Port int
}
func main() {
// main calls MakeConfig and holds the pointer. The value must remain valid after MakeConfig returns.
cfg := MakeConfig()
fmt.Println(cfg.Port)
}
Run this with go build -gcflags="-m". The compiler reports ./prog.go:6:13: &c escapes to heap. This output confirms that c is allocated on the heap. The compiler detected that the address of c is returned, so c cannot live on the stack.
If you change the function to return Config by value, c might stay on the stack. The compiler is smart. It doesn't allocate on the heap just because you used a pointer. It allocates on the heap because the lifetime requires it.
Go convention favors returning structs over pointers when mutation isn't needed. "Accept interfaces, return structs." Returning a struct often allows the compiler to keep the value on the stack or optimize the copy. If you return a pointer, you signal that the value might be mutated or is large. Use pointers intentionally.
Closures force escape
Closures capture variables from their surrounding scope. This is a powerful feature, but it forces escape analysis to move captured variables to the heap.
Here's a closure that captures a local counter. The counter must live as long as the closure exists.
package main
import "fmt"
// MakeCounter returns a closure that captures a local variable.
func MakeCounter() func() int {
// count is declared locally. The closure captures count, so count must escape to the heap.
count := 0
// The returned function holds a reference to count. count cannot be destroyed when MakeCounter returns.
return func() int {
count++
return count
}
}
func main() {
// main calls MakeCounter and stores the closure. The closure keeps count alive.
counter := MakeCounter()
// Invoke the closure. count is accessed via the heap allocation.
fmt.Println(counter())
fmt.Println(counter())
}
The compiler reports count escapes to heap. The closure is a function value that points to a heap allocation containing count. Every time you call counter(), it increments the heap value. This pattern is common in Go, and the compiler handles it seamlessly. The performance cost is a heap allocation, which is usually negligible compared to the logic inside the closure.
Interfaces and escape
Interfaces are another escape trigger. An interface value is a pair: a type descriptor and a pointer to the data. When you pass a concrete value to an interface parameter, the compiler often copies the value to the heap so the interface can hold a pointer to it.
Here's a function that accepts any. Passing a struct to it often causes the struct to escape.
package main
import "fmt"
// LogValue accepts any type via an interface.
func LogValue(v any) {
fmt.Println(v)
}
func main() {
// s is a local struct. Passing s to LogValue converts it to an interface.
s := struct{ Name string }{Name: "test"}
// The conversion to any often causes s to escape to the heap because the interface stores a pointer.
LogValue(s)
}
The compiler reports s escapes to heap. The interface v holds a pointer to a copy of s on the heap. This is necessary because the interface might outlive the function call, or the runtime needs a stable address for the data.
This behavior reinforces the convention to "accept interfaces, return structs." If you accept an interface, the caller passes the value, and the value might escape into the interface. But the function signature encourages abstraction. You pay a small allocation cost for flexibility.
Pitfalls and compiler signals
Pointers do not force heap allocation. A pointer can point to stack memory. If you take the address of a local variable but never return it or store it in a global, the variable stays on the stack.
package main
// UsePointer demonstrates that a local pointer does not force escape.
func UsePointer() int {
// x is a local int.
x := 42
// p points to x. p is local. x is local.
p := &x
// Dereference p. x stays on the stack because neither x nor p escapes.
return *p
}
The compiler reports no escape for x. The pointer p is just a local helper. The compiler sees that x is only used within UsePointer, so it keeps x on the stack.
Large values can force escape even without lifetime issues. Stacks have a size limit. If you allocate a massive array on the stack, the compiler might move it to the heap to avoid stack overflow. The compiler warns with a message like allocation of large array on stack or silently escapes the value. If you hit runtime: goroutine stack exceeds 1000000000-byte limit, you're recursing too deep or allocating too much on the stack. Reduce the size or use a slice backed by the heap.
Loop variables used to be a source of confusion. In Go versions before 1.22, capturing a loop variable in a goroutine or closure could lead to bugs because all closures shared the same variable. The compiler now creates a new variable per iteration, and the error loop variable i captured by func literal is no longer raised. The semantics changed to match developer intuition.
The compiler is conservative. If it escapes, it escapes. Optimize logic, not allocation.
When to use what
Use value types for small data that stays local and doesn't need to outlive the function. Use pointers when you need to mutate a value across function boundaries or share ownership. Use go build -gcflags="-m" when profiling shows unexpected heap pressure and you need to track down escape sites. Use struct returns when the caller doesn't need to modify the result or when the struct is small enough to fit comfortably on the stack. Use interface parameters when you need polymorphism, accepting that arguments may escape to the heap. Use a slice or map when you need a dynamic collection, knowing the backing array or map node always lives on the heap.
Trust the compiler. Measure before optimizing.