DEV Community

Pavel Sanikovich
Pavel Sanikovich

Posted on

Go From Zero to Depth — Part 3: Stack vs Heap & How Escape Analysis Actually Works

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. You see how small changes in code shape memory behavior. And most importantly, you learn to write Go the way the compiler expects — which results in faster, cleaner, more predictable programs.

Let's walk through this from the ground up.


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.

A simple example:

func sum(a, b int) int {
    c := a + b
    return c
}
Enter fullscreen mode Exit fullscreen mode

If we ask the compiler to show escape analysis:

$ go build -gcflags="-m"
<no escape>
Enter fullscreen mode Exit fullscreen mode

Nothing escapes. Everything is on the stack. The compiler is even free to inline the function, meaning the variables may never exist as “variables” at all — they become 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
}
Enter fullscreen mode Exit fullscreen mode

Compiler output:

./main.go:4:9: &x escapes to heap
Enter fullscreen mode Exit fullscreen mode

This has nothing to do with the size of x. The compiler simply sees that the caller needs a reference to x after the function returns. The stack frame cannot hold it anymore.

A beginner often doesn’t realize that the pointer itself is not expensive — 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
    }
}
Enter fullscreen mode Exit fullscreen mode

Compiler output:

./main.go:6:13: func literal escapes to heap
./main.go:5:5: moved to heap: x
Enter fullscreen mode Exit fullscreen mode

Why?
Because the returned function continues to exist after counter finishes. It needs access to x.
Therefore, x must move to the heap, where its lifetime is no longer tied to the stack frame.

Many beginners write closure-based code without realizing they are allocating memory every time.


Two Constructors, Two Lifetimes, Two Allocation Patterns

This is one of the clearest ways to see escape analysis in action.

Version 1 — returning a value:

type User struct {
    Name string
}

func newUser(name string) User {
    return User{Name: name}
}
Enter fullscreen mode Exit fullscreen mode

And now version 2 — returning a pointer:

func newUserPtr(name string) *User {
    return &User{Name: name}
}
Enter fullscreen mode Exit fullscreen mode

For the first version, the compiler often places User directly into the caller’s stack frame.
For the pointer version, the allocation must occur on the heap.

Same data. Same fields. Same size.
Different lifetime = different memory behavior.

This is why experienced Go developers say: prefer returning values unless you need shared mutable state.


Escape Analysis Loves Clear Ownership

Here’s a subtle example where a tiny rewrite prevents a heap escape:

func sumSlice(nums []int) *int {
    total := 0
    for _, v := range nums {
        total += v
    }
    return &total
}
Enter fullscreen mode Exit fullscreen mode

Compiler:

./main.go:7:12: &total escapes to heap
Enter fullscreen mode Exit fullscreen mode

But if we write it like this:

func sumSlice(nums []int) int {
    total := 0
    for _, v := range nums {
        total += v
    }
    return total
}
Enter fullscreen mode Exit fullscreen mode

Now:

<no escape>
Enter fullscreen mode Exit fullscreen mode

Exact same logic. Different lifetime semantics.

This is the power of understanding escape analysis: your intuition becomes aligned with the compiler.


A Surprising Case: Heap Allocations Without Pointers

Sometimes a heap escape happens even though you don’t return a pointer. A classic example:

func run() {
    for i := 0; i < 3; i++ {
        go func() {
            fmt.Println(i)
        }()
    }
}
Enter fullscreen mode Exit fullscreen mode

Compiler:

./main.go:5:10: func literal escapes to heap
./main.go:4:6: moved to heap: i
Enter fullscreen mode Exit fullscreen mode

Why?
Because the goroutine runs after the loop iteration completes.
It needs access to i.
Therefore i cannot live on the stack.

This is the precise moment when a beginner realizes that concurrency also changes lifetimes.


What Escape Analysis Is Actually Doing

It’s not trying to optimize your code.
It’s proving safety.

If the compiler can prove a value is local → stack.
If it cannot prove → heap.

It’s a conservative algorithm. A value will escape even when theoretically safe, simply because proving otherwise would require solving undecidable problems. Go plays it safe; that’s what keeps programs correct.


How to See Escape Analysis in Your Code

You can observe everything the compiler decides:

go build -gcflags="-m=2"
Enter fullscreen mode Exit fullscreen mode

Or for even more detail:

go build -gcflags="-m -m"
Enter fullscreen mode Exit fullscreen mode

This becomes addictive. You begin scanning your code and predicting escapes before the compiler prints them.

Once you reach that level, Go feels like a language that explains itself to you.


Why This Matters for Beginners

Understanding escape analysis is not about premature optimization.
It’s about forming the mental model that Go expects you to have.

Once you understand lifetimes:

– you choose value vs pointer intentionally
– you design APIs that minimize hidden allocations
– your code scales better under load
– you avoid concurrency pitfalls
– you become predictable to the compiler

This is exactly where beginners stop being beginners.


Next: Part 4 — Pointers in Go, Without Fear

In the next chapter we’ll explore pointers, not as something “low-level,” but as the mechanism that shapes ownership and lifetime in Go. We’ll explain why pointers are often misunderstood, why they don’t work like C pointers, and why the value/pointer distinction is the foundation of Go’s design.

Memory model → escape analysis → pointers → concurrency → scheduler
You’re now in the deep part of the pool.

Want to go further?

This series focuses on understanding Go, not just using it.

If you want to continue in the same mindset, Educative is a great next step.

It’s a single subscription that gives you access to hundreds of in-depth, text-based courses — from Go internals and concurrency to system design and distributed systems. No videos, no per-course purchases, just structured learning you can move through at your own pace.

👉 Explore the full Educative library here

Top comments (0)