Go From Zero to Depth — Part 3: Stack vs Heap & How Escape Analysis Actually Works
Source: Dev.to
If you’ve ever profiled a Go program and wondered why a simple function allocates memory, or why a tiny struct suddenly ends up on the heap, you’ve seen the effects of escape analysis. Beginners often learn stack and heap as if they were fixed rules — small things go on the stack, large things go on the heap — but Go doesn’t work like that at all. Size is irrelevant. What matters is lifetime.
A value stays on the stack only if the compiler can prove it never outlives the function that created it. The moment the lifetime becomes ambiguous, that value “escapes,” and Go places it on the heap. This is not a heuristic and not guesswork; it is a strict safety rule.
Understanding this rule gives you x‑ray vision into your Go programs. You start predicting allocations before they happen, see how small changes in code shape memory behavior, and learn to write Go the way the compiler expects — resulting in faster, cleaner, more predictable programs.
The Stack: Fast, Local, Temporary
A stack frame exists only during the execution of a function. When the function returns, the frame disappears. If a value can be proven to stay within that frame, it is stack‑allocated.
func sum(a, b int) int {
c := a + b
return c
}
Ask the compiler to show escape analysis:
go build -gcflags="-m"
Nothing escapes. Everything is on the stack. The compiler may even inline the function, turning the variables into registers or constants. This is the ideal path: pure stack behavior, no GC pressure, no heap work.
The Heap: For Values with Extended Lifetime
A value must live on the heap if something outside the current stack frame needs to reference it. Returning a pointer is the most common example.
func makePtr() *int {
x := 42
return &x
}
Compiler output:
./main.go:4:9: &x escapes to heap
The size of x is irrelevant; the compiler sees that the caller needs a reference to x after the function returns, so the stack frame cannot hold it any longer. The pointer itself is cheap; it’s the lifetime extension that forces the escape.
Closures: When Variables Quietly Escape
Closures are a classic place where beginners accidentally create heap allocations.
func counter() func() int {
x := 0
return func() int {
x++
return x
}
}
Compiler output:
./main.go:6:13: func literal escapes to heap
./main.go:5:5: moved to heap: x
counter finishes, but the returned closure still needs access to x. Therefore x must move to the heap, where its lifetime is no longer tied to the stack frame. Many developers write closure‑based code without realizing they allocate memory on each call.
Two Constructors, Two Lifetimes, Two Allocation Patterns
Returning a value
type User struct {
Name string
}
func newUser(name string) User {
return User{Name: name}
}
Returning a pointer
func newUserPtr(name string) *User {
return &User{Name: name}
}
For the first version, the compiler often places User directly into the caller’s stack frame. The data, fields, and size are identical, yet the pointer version forces a heap allocation. This is why experienced Go developers say: prefer returning values unless you need shared mutable state.
Escape Analysis Loves Clear Ownership
A tiny rewrite can prevent a heap escape:
func sumSlice(nums []int) *int {
total := 0
for _, v := range nums {
total += v
}
return &total
}
Compiler output:
./main.go:7:12: &total escapes to heap
Rewrite to return the value instead of a pointer:
func sumSlice(nums []int) int {
total := 0
for _, v := range nums {
total += v
}
return total
}
Now the compiler reports no escape messages. The logic is identical; only the lifetime semantics differ. Understanding escape analysis aligns your intuition with the compiler’s decisions.
A Surprising Case: Heap Allocations Without Pointers
Heap escapes can happen even when you don’t return a pointer. Consider a goroutine inside a loop:
func run() {
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i)
}()
}
}
Compiler output:
./main.go:5:10: func literal escapes to heap
./main.go:4:6: moved to heap: i
The loop variable i must survive after each iteration because the goroutine may execute later. It cannot live on the stack, so it is moved to the heap. This illustrates how concurrency changes lifetimes.
What Escape Analysis Is Actually Doing
Escape analysis isn’t an optimization pass; it’s a conservative safety algorithm:
- If the compiler can prove a value is local → stack.
- If proving non‑escape would require solving undecidable problems, the compiler assumes the value escapes.
Go plays it safe, which keeps programs correct.
How to See Escape Analysis in Your Code
You can observe everything the compiler decides:
go build -gcflags="-m=2"
For even more detail:
go build -gcflags="-m -m"
Scanning these messages becomes addictive—you begin predicting escapes before the compiler prints them, giving you deeper insight into Go’s memory model.
Why This Matters for Beginners
Understanding escape analysis isn’t about premature optimization. Once you grasp lifetimes, you can:
- Choose between returning a value or a pointer intentionally.
- Write code that aligns with the compiler’s expectations, reducing unnecessary heap allocations and GC pressure.
This is the point where beginners stop being beginners.
Next: Part 4 — Pointers in Go, Without Fear
In the next chapter we’ll explore pointers—not as “low‑level” tricks, but as the mechanism that shapes ownership and lifetime in Go. We’ll explain why pointers are often misunderstood, how they differ from C pointers, and why the value/pointer distinction is foundational to Go’s design.
Memory model → escape analysis → pointers → concurrency → scheduler
This series focuses on understanding Go, not just using it.