ToolPopToolPop
Go · Lesson 11 of 22

The memory model, stack vs heap, and escape analysis

8 min readUpdated 25 Jun 2026

C programmers obsess over stack and heap because they manage both. Go programmers can pretend the distinction does not exist, right up until a hot path on Razorpay's payment gateway starts allocating 200 MB per second and the GC pauses tank p99 latency. That is when you learn escape analysis.

This lesson is the memory model from a senior's view: enough to reason about performance, debug allocation pressure, and answer the interview question "what is escape analysis?" without bluffing.

Stack vs heap, the 30-second version

Every goroutine gets its own stack (starts at ~2 KB, grows on demand). Function locals live on the stack by default. When the function returns, the stack frame is popped and the memory is reclaimed for free. No GC involvement, no bookkeeping, just a pointer move.

The heap is the shared region where things live longer than the function that created them. Heap allocations cost an actual allocator call, and every heap object becomes work for the garbage collector later.

go
func add(a, b int) int {
    sum := a + b // stack: vanishes when add returns
    return sum
}

Senior rule: stack is free, heap is not. If you can keep a value on the stack, you should. But "should" rarely means "rewrite your code", it means "do not actively force escape unless you need to".

Why Go has a garbage collector

Go targets the same workloads as Java and C#: backend services where the developer should not be writing free() everywhere. The GC is the price you pay for not managing memory manually. Since Go 1.14 the GC is concurrent and low-latency, with typical pauses under a millisecond even on multi-gigabyte heaps. The compiler and runtime cooperate via the tricolor mark-and-sweep algorithm.

The point: in 2026, GC pauses are almost never your problem. Allocation rate is. Every heap allocation is one more pointer the GC has to scan on the next cycle. Reduce allocations, and the GC almost disappears.

Escape analysis: the compiler's stack-or-heap decision

The Go compiler runs escape analysis at compile time. For each value, it asks "does this still need to exist after the function returns?". If yes, heap. If no, stack.

You can see the decision yourself.

bash
go build -gcflags="-m" ./...

Output lines like ./main.go:12:6: moved to heap: user are the compiler telling you exactly what escaped and why. This is the single most useful command for performance work in Go and most engineers never run it.

Patterns that force a value onto the heap

The four classic escape triggers every senior should recognise.

1. Returning a pointer to a local variable.

go
func newUser(name string) *User {
    u := User{Name: name}
    return &u // u escapes: caller still holds the pointer
}

The local u cannot live on the stack frame because the frame is gone after return. The compiler moves it to the heap. This is fine and idiomatic, just know it allocates.

2. Capturing in a closure that outlives the function.

go
func counter() func() int {
    n := 0
    return func() int { n++; return n } // n escapes
}

The returned closure holds a reference to n, so n cannot die when counter returns.

3. Storing in an interface (interface boxing).

go
var x any = 42 // 42 is boxed; the int escapes to the heap
fmt.Println(x)

Interface values are a (type, pointer) pair. To put a concrete value into an interface, Go usually has to put it somewhere addressable, which means the heap. This is why fmt.Println(i) with an int does an allocation that direct strconv.Itoa(i) followed by raw writes does not.

4. Slices and maps that grow beyond what the compiler can prove.

go
func build(n int) []int {
    s := make([]int, n) // n is dynamic, escapes
    return s
}

If the compiler cannot statically prove the backing array's size and lifetime, it heap-allocates.

Why this matters for hot-path code

For a CRUD endpoint that handles 50 RPS, you do not care. For a goroutine handling 50,000 UPI notifications per second, the difference between zero allocations per request and twenty allocations per request is the difference between one box and ten.

The classic pattern: use sync.Pool for reusable buffers in JSON encoders and HTTP handlers, pre-size slices and maps with make([]T, 0, capacity), avoid interface{} in tight loops, and prefer value receivers when the struct is small (the compiler may inline and keep things on the stack).

go
buf := bufPool.Get().(*bytes.Buffer)
defer bufPool.Put(buf)
buf.Reset()
// use buf for the duration of the request

Interview trap: "Should I always avoid heap allocations?" The right answer is no. Optimising allocations in the 95% of code that is not hot is wasted effort and makes the code uglier. Profile first.

GC tuning, the short version

The main knob is GOGC (default 100). It means "trigger the next GC cycle when the heap has grown by 100% since the last cycle". Lowering it makes GC more frequent (less memory, more CPU). Raising it makes GC less frequent (more memory, less CPU). Most teams never touch it.

Go 1.19 added GOMEMLIMIT for a soft memory ceiling, very useful when running in a Kubernetes pod with a hard limit, because it makes the runtime collect more aggressively as you approach the cap instead of getting OOM-killed.

Memory model terms

stack
Per-goroutine memory region for function locals. Free to allocate, free to reclaim, no GC involvement.
heap
Shared memory region for values that outlive their creating function. Managed by Go's garbage collector.
escape analysis
Compile-time analysis that decides whether each value goes on the stack or the heap.
GOGC
Environment variable controlling GC trigger frequency. Default 100. Lower = more GCs, smaller heap.
GOMEMLIMIT
Soft memory ceiling introduced in Go 1.19. Useful in container deployments with hard memory caps.
sync.Pool
Free list for reusable allocations. Lets you avoid heap churn for short-lived objects like buffers.

Senior rule: don't optimise allocations until pprof tells you to, but know what makes things escape. The first half keeps you sane. The second half keeps you employed.

Common questions

Q.Why does Go have both a stack and a heap?
A.The stack is fast (push/pop, no GC) but bounded to the function lifetime. The heap is for things that need to outlive their function. Go uses escape analysis at compile time to decide where each allocation goes, so most short-lived locals stay on the stack.
Q.How do I see what escapes to the heap?
A.Build with `go build -gcflags="-m"`. The compiler prints lines like "x escapes to heap" for every escaping allocation. Useful when chasing GC pressure.
Chai0/1 done

Watching quietly. Tap me if you want a tip.

Go Playground

Go cannot run natively in a browser. Run copies your code and opens go.dev/play ; paste and click Run there.

Try this (0 of 1 done)

  1. 1

    Predict the output.

    hint

    The variable escapes to the heap so it survives the function return.

    show answer
    // run the editor; the compiler does the escape analysis silently.