Stack vs Heap and Escape Analysis
Every value your Go program creates needs a place to live in memory, and unlike C or C++, you never call malloc or free to decide where. The Go compiler decides automatically whether each value belongs on the fast, short-lived stack or the slower, garbage-collected heap, through a compile-time process called escape analysis. Understanding how that decision is made lets you read what the compiler is actually doing to your code, explain why returning a pointer to a local variable is perfectly safe in Go, and write code that allocates less and puts less pressure on the garbage collector.
Overview: How the Stack and Heap Work
Every goroutine in a running Go program has its own stack: a contiguous, growable block of memory used to hold local variables and function-call bookkeeping. When a function is called, a new stack frame is pushed on top holding its parameters, local variables, and return address. When the function returns, that frame is popped off — instantly, with no bookkeeping beyond moving a stack pointer. Allocating on the stack is essentially free: there is no search for free space, no metadata to update, and no cleanup work for the garbage collector, because the memory is automatically reclaimed the moment the function returns. Modern Go goroutine stacks start small (a few kilobytes) and grow or shrink by being copied to a larger or smaller contiguous block as needed, which is one reason goroutines are so cheap to create.
The heap, by contrast, is one shared region for the whole program. Any goroutine can allocate memory there, and any goroutine can read a heap value long after the function that created it has returned. That flexibility has a cost: the runtime has to find free space, the garbage collector has to track every heap object to know when it is safe to reclaim, and periodic garbage-collection cycles scan and sweep that memory. Heap allocation and its associated GC work are measurably slower than stack allocation.
Because a stack frame disappears as soon as its function returns, the compiler must never place a value on the stack if any reference to it could outlive that function call. Deciding this, for every single variable in your program, is exactly what escape analysis does. It happens entirely at compile time, before any machine code is generated: the compiler traces how the address of each variable is used throughout the function and, transitively, through every function it calls (inlining helps here, since an inlined callee’s behavior becomes visible to the analysis). If it can prove a value’s address never leaves the function — it is never returned, never stored somewhere longer-lived, and never captured by something that could run later — the value is allocated on the stack. If the compiler cannot prove that, it takes the safe route and allocates the value on the heap, where its lifetime can safely extend past the current function call. This is a conservative, static analysis: the compiler does not run your program to check, it proves safety ahead of time, and when it cannot prove safety it defaults to the heap.
This is also why a classic C habit does not apply in Go. In C, returning the address of a local variable is a bug — the stack frame is gone by the time the caller dereferences the pointer, so the pointer dangles. In Go, return &localVar is completely safe, precisely because escape analysis detects that the address escapes the function and moves that variable’s storage to the heap for you. You get pointer semantics without ever worrying about dangling pointers; the tradeoff is that this safety sometimes costs you a heap allocation you didn’t explicitly ask for.
Syntax: Reading the Compiler’s Escape Decisions
You don’t write escape analysis yourself — it is something the compiler does automatically on every build. But you can ask the compiler to show you its reasoning with a build flag:
go build -gcflags="-m" yourfile.go
- -gcflags passes flags through to the Go compiler itself (as opposed to the linker or
gotool). - “-m” tells the compiler to print its optimization decisions, including inlining choices and escape analysis results. Repeating it (
-m -m) prints more detailed reasoning for each decision.
The output is a list of lines like ./main.go:10:2: moved to heap: p or ./main.go:20:16: &c escapes to heap, one per variable the compiler had to make a decision about. The exact line numbers depend on your file, but the vocabulary is consistent: “escapes to heap” and “moved to heap” both mean the value could not be proven stack-safe.
The table below summarizes the situations that most commonly force a value to escape:
| Trigger | Why it forces heap allocation |
|---|---|
Returning &localVar from a function |
The pointer’s target must outlive the function’s stack frame. |
| Storing a pointer in a struct field, slice, or map that is passed out of the function | The container may be read long after this function has returned. |
| Capturing a variable in a closure that is returned, stored, or run as a goroutine | The closure can execute after the enclosing function returns. |
Assigning a value to an interface variable (any, error, etc.) |
The interface holds a pointer to the underlying data, and the compiler often can’t prove the interface value itself stays local. |
A value whose size isn’t known until runtime (e.g. make([]T, n) with a variable n) |
The compiler can’t reserve a fixed amount of stack space for it ahead of time. |
| A value large enough to make the stack frame unreasonably big | The runtime prefers the heap once a single value would bloat every call’s stack frame. |
Examples
The three examples below go from a value that clearly never escapes, to one that obviously does, to a more realistic case with a slice of pointers.
Example 1: A Value That Stays on the Stack
package main
import "fmt"
func square(n int) int {
result := n * n
return result
}
func main() {
x := square(7)
fmt.Println("Square:", x)
}
Output:
Square: 49
result inside square is never referenced anywhere except inside square itself: its value is copied out via the ordinary return, and no pointer to it is ever created. The compiler can prove result‘s lifetime is fully contained within the call to square, so it allocates result on the stack (in practice, for a case this simple, the compiler will likely inline square entirely and there may be no separate stack slot at all — but conceptually, nothing here can escape).
Example 2: A Value That Escapes to the Heap
package main
import "fmt"
type Point struct {
X, Y int
}
func newPoint(x, y int) *Point {
p := Point{X: x, Y: y}
return &p
}
func main() {
pt := newPoint(3, 4)
fmt.Println("Point:", pt.X, pt.Y)
}
Output:
Point: 3 4
Here newPoint takes the address of its local variable p and returns it. Once newPoint returns, its stack frame would normally be gone — so if p stayed on the stack, pt in main would point at reclaimed memory. Escape analysis catches exactly this: because &p is returned, p‘s address escapes the function, and the compiler allocates p on the heap instead. The heap-allocated Point survives as long as anything (here, pt) still references it, and Go’s garbage collector reclaims it automatically once nothing does. Running go build -gcflags="-m" on this file would print a line such as moved to heap: p pointing at the declaration inside newPoint.
Example 3: A Realistic Case — a Slice of Pointers
package main
import "fmt"
type Counter struct {
value int
}
func (c *Counter) Increment() {
c.value++
}
func makeCounters(n int) []*Counter {
counters := make([]*Counter, 0, n)
for i := 0; i < n; i++ {
c := Counter{value: i}
counters = append(counters, &c)
}
return counters
}
func main() {
counters := makeCounters(3)
for _, c := range counters {
c.Increment()
}
for _, c := range counters {
fmt.Println("Counter value:", c.value)
}
}
Output:
Counter value: 1
Counter value: 2
Counter value: 3
Inside the loop, c := Counter{value: i} declares a brand-new Counter on every iteration (it’s scoped inside the loop body, not the loop header), and &c is appended to counters, which is itself returned from makeCounters. Every one of those three Counter values has its address stored in a slice that outlives the function, so all three escape to the heap. This is a very common real-world pattern — building up a collection of pointers to return or store — and it’s a good example of escape analysis doing exactly what it should: keeping each Counter alive as long as the slice that references it is alive.
How It Works Step by Step
Walking through Example 3’s compilation and execution:
- At compile time, the compiler analyzes
makeCountersand sees that the address of the local variablec(taken fresh each loop iteration) is appended tocounters, and thatcountersis the function’s return value. Because the caller can hold onto that slice indefinitely, every&cstored in it must point at memory that outlives the call tomakeCounters. - The compiler therefore marks each
Countercreated inside the loop as heap-allocated, rather than giving it stack space insidemakeCounters‘s frame. - At run time, each loop iteration allocates a new
Counteron the heap, initializes itsvaluefield, and appends the pointer to the growingcountersslice. - When
makeCountersreturns, its own stack frame is popped as usual — but the threeCountervalues are unaffected, because they were never on that frame to begin with. - Back in
main, the returned slice of pointers is used to callIncrementon eachCounter, mutating the heap values in place through their pointers. - The heap-allocated
Countervalues remain reachable (referenced bycountersinmain) for the rest of the program, so the garbage collector leaves them alone; oncecountersitself goes out of scope and nothing references those values anymore, they become eligible for collection.
Common Mistakes
Mistake 1: Capturing a Loop Variable Instead of Passing It In
A classic Go bug is starting a goroutine inside a loop and referencing the loop variable directly inside the closure, instead of passing it in as a parameter:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i)
}()
}
wg.Wait()
}
On Go versions before 1.22, every goroutine’s closure captured the same variable i by reference, so by the time the goroutines actually ran, the loop had often already finished and every closure saw the final value — typically printing 3 three times instead of 0, 1, 2. Go 1.22 changed the loop variable to be per-iteration, which fixes this specific case going forward, but the underlying lesson still matters: whenever a closure or goroutine outlives the current iteration, any variable it references is forced to escape to the heap so it can be shared safely, and relying on “the loop variable happens to still have the value I expect” is fragile. The portable, defensive fix is to pass the value in explicitly as a parameter:
package main
import (
"fmt"
"sort"
"sync"
)
func main() {
var wg sync.WaitGroup
results := make([]int, 3)
for i := 0; i < 3; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
results[i] = i * i
}(i)
}
wg.Wait()
sort.Ints(results)
fmt.Println(results)
}
Output:
[0 1 4]
Passing i as a parameter to the goroutine’s function literal gives each goroutine its own copy, taken at the moment the goroutine is launched, removing any dependency on loop-variable semantics.
Mistake 2: Reaching for Pointers on Small Structs “For Performance”
It’s tempting to assume pointers are always faster because they avoid copying, but for small structs a pointer often costs more: it forces an otherwise stack-safe value to escape to the heap, adds a level of indirection, and creates work for the garbage collector.
package main
import "fmt"
type Point struct {
X, Y int
}
func distanceSquared(a, b *Point) int {
dx := a.X - b.X
dy := a.Y - b.Y
return dx*dx + dy*dy
}
func main() {
p1 := &Point{X: 0, Y: 0}
p2 := &Point{X: 3, Y: 4}
fmt.Println(distanceSquared(p1, p2))
}
Output:
25
This compiles and runs correctly, but because p1 and p2 are created as pointers and passed around as pointers, the compiler is far more likely to have to heap-allocate them (especially once real code stores or passes them further). A two-field struct of ints is tiny and doesn’t need mutation here, so passing it by value keeps everything eligible for the stack:
package main
import "fmt"
type Point struct {
X, Y int
}
func distanceSquared(a, b Point) int {
dx := a.X - b.X
dy := a.Y - b.Y
return dx*dx + dy*dy
}
func main() {
p1 := Point{X: 0, Y: 0}
p2 := Point{X: 3, Y: 4}
fmt.Println(distanceSquared(p1, p2))
}
Output:
25
The rule of thumb: use a pointer receiver or pointer parameter when you need to mutate the original or the struct is large; otherwise prefer plain values, since they give the compiler the best chance of keeping data on the stack.
Best Practices
- Use
go build -gcflags="-m"to see the compiler’s actual escape decisions instead of guessing — intuition about escape analysis is frequently wrong. - Prefer passing small structs by value when you don’t need to mutate the original; it keeps data eligible for the stack and avoids an extra pointer indirection.
- Be deliberate about storing pointers in long-lived containers (structs, package-level slices, maps, caches) — every stored pointer is a promise that its target must survive as long as the container does.
- Remember that boxing a value into an interface (
any,error, or a custom interface) commonly forces a heap allocation for the underlying value; avoid unnecessary interface conversions in hot loops. - Always pass loop variables into goroutines and closures explicitly as parameters, even on Go 1.22+, so the code’s intent is unambiguous and portable across Go versions.
- Don’t hand-optimize away allocations before you’ve measured; profile with
pprofor-gcflags="-m"first, then optimize the allocations that actually matter.
Practice Exercises
- Write a function that builds and returns a
[]intof squares from1tonusingmakeandappendinside the function. Rungo build -gcflags="-m"on the file and find the line confirming the backing array escapes to the heap. - Take the
makeCountersexample from this lesson and change it to return[]Counter(a slice of values) instead of[]*Counter. Rungo build -gcflags="-m"again — does the underlying data still escape? (Hint: think about what has to happen to the slice’s backing array itself, regardless of what type its elements are.) - Write a small program with a goroutine launched inside a
forloop that captures the loop variable directly (without passing it as a parameter), then rewrite it using the explicit-parameter fix from Mistake 1. Compare the two versions’ behavior and explain in a comment why the fix is still worth writing even on Go 1.22+.
Summary
- Go’s compiler decides, at compile time, whether each value lives on the stack or the heap — this is escape analysis, and it happens automatically on every build.
- A value stays on the stack when the compiler can prove its lifetime never exceeds the function that created it; otherwise it “escapes” to the heap.
- Common escape triggers include returning a pointer to a local variable, storing a pointer in a longer-lived struct/slice/map, capturing a variable in an outliving closure or goroutine, and boxing a value into an interface.
- Escape analysis is why
return &localVaris always safe in Go — unlike C, there is no dangling-pointer risk, because the compiler moves the variable to the heap whenever its address escapes. - Use
go build -gcflags="-m"to see the compiler’s real escape decisions instead of relying on intuition. - Minimizing unnecessary escapes reduces garbage-collector pressure, but always profile before optimizing — correctness and clarity come first.
