Go’s escape analysis is surprisingly aggressive about putting things on the stack, even when you might expect them to live on the heap.

Let’s watch a simple function in action.

package main

import "fmt"

type Person struct {
	Name string
	Age  int
}

func main() {
	p := Person{Name: "Alice", Age: 30}
	fmt.Println(p)
}

func process(person Person) {
	// This person *might* escape, depending on how it's used.
	// If we just print it, it probably won't.
	fmt.Println(person)
}

When you compile this with go build -gcflags="-m", you’ll see output like this:

./main.go:10:6: can inline main
./main.go:13:2: moved to heap: person
./main.go:16:2: process p does not escape

Notice moved to heap: person on line 13. Even though p is declared inside main, the compiler decided it could escape. Why? Because main is being inlined into the final executable. When a function is inlined, its local variables can potentially "escape" from the scope of the original function and need to be allocated on the heap so they remain accessible.

The core problem escape analysis solves is determining the lifetime of a variable. If a variable’s lifetime extends beyond the scope of the function it’s declared in, it must be allocated on the heap. Otherwise, it can live on the stack, which is much faster.

Here’s the mental model:

  1. Stack Allocation: If a variable is guaranteed to be used only within the function it’s declared, and that function is not inlined, it can be allocated on the stack. Stack allocation is a simple bump of the stack pointer. When the function returns, the stack pointer is reset, and the memory is reclaimed. Fast, efficient, no garbage collection.

  2. Heap Allocation: If a variable might be accessed after the function returns, or if the function containing it is inlined and the variable’s lifetime needs to extend past the caller’s scope, it must be allocated on the heap. Heap allocation involves a more complex process: finding free memory, potentially involving the garbage collector for reclamation later. Slower, requires GC.

  3. Escape Analysis: The compiler’s escape analysis pass is the detective. It analyzes the data flow of variables. If a pointer to a variable is stored in a global variable, passed to a goroutine, or returned from a function, that variable "escapes" to the heap. Inlining complicates this, as a variable local to an inlined function might need to escape the caller’s scope.

Let’s look at a slightly more complex example:

package main

import "fmt"

type Data struct {
	Value int
}

func main() {
	d := &Data{Value: 100} // Pointer to Data
	processData(d)
	fmt.Println(d.Value) // Accessing d after processData
}

func processData(data *Data) {
	// data is a pointer. The Data struct it points to *might* escape.
	fmt.Println("Processing:", data.Value)
	// If we did something like:
	// globalData = data
	// or
	// go func() { fmt.Println(data.Value) }()
	// then data would definitely escape.
}

Compiling with go build -gcflags="-m":

./main.go:10:11: can inline main
./main.go:12:6: &Data{...} escapes to heap
./main.go:13:16: processData data does not escape
./main.go:15:13: fmt.Println calls fmt.Println

Here, &Data{Value: 100} escapes to the heap. Why? Because d is a pointer. Even though processData itself doesn’t cause d to escape from main’s perspective (i.e., processData doesn’t return the pointer or store it globally), the fact that d is a pointer means the data it points to is potentially accessible from outside main’s immediate scope if main were to be inlined or if d were passed elsewhere. The compiler errs on the side of caution for pointers. The Data struct itself is heap-allocated because d is a pointer that could escape.

The critical factor is whether a variable’s address is taken and that address might outlive the current function’s stack frame. This includes storing the address in a data structure that persists (like a global variable), passing it to another goroutine, or returning it. If a function is not inlined, and a variable declared within it is only used within that function and its address is never exposed in a way that could outlive the function, it stays on the stack. When a function is inlined, its local variables need to be considered in the context of the caller’s stack frame, which is why they might then need to escape to the heap.

The one thing most people don’t realize is how much the inlining decision by the compiler directly influences escape analysis. A variable that would never escape in a non-inlined function might be forced onto the heap simply because the compiler decides to inline the function containing it for performance, and that variable’s address needs to remain valid in the caller’s context.

The next problem you’ll run into is understanding how to force variables onto the stack when you know they’re safe, or how to avoid unnecessary heap allocations when dealing with complex pointer manipulations.

Want structured learning?

Take the full Golang course →