Tricky parts of Golang. Escape Analysis and Heap Allocations

Two functions. Nearly identical code. One puts your variable on the stack; the other sends it to the heap, adding GC pressure every time it runs. You cannot tell which is which by reading the code — you need to ask the compiler.

This article explains how Go decides where variables live, how to read the compiler’s decision, and which patterns silently force allocations you did not intend.

Stack vs. Heap: Why It Matters

Memory in a Go program lives in two places:

graph LR
    subgraph STACK["Stack"]
        s1["Per goroutine — starts ~2 KB, grows"]
        s2["Allocation: bump a pointer"]
        s3["Deallocation: at function return"]
        s4["GC: not involved"]
    end
graph LR
    subgraph HEAP["Heap"]
        h1["Shared across goroutines"]
        h2["Allocation: find free block"]
        h3["Deallocation: garbage collector"]
        h4["GC: must scan, mark, sweep"]
    end

Stack allocation is essentially free — it is a single pointer addition. Heap allocation involves the memory allocator finding a suitable block, and every heap-allocated object must eventually be collected by the GC. In a tight loop or a high-throughput server, the difference between stack and heap for a frequently-created value can be the difference between microseconds and milliseconds of GC pause.

Escape Analysis

Go’s compiler performs escape analysis at compile time: it traces every variable and decides whether the variable’s lifetime is bounded to the current function (stack-safe) or might outlive it (must go to the heap). If a variable “escapes” its function, the compiler allocates it on the heap.

You can see every decision with:

go build -gcflags="-m" ./...
# or for more detail:
go build -gcflags="-m=2" ./...

Each line of output tells you what escaped and why:

./main.go:12:6: moved to heap: x
./main.go:18:14: &y escapes to heap
./main.go:24:10: z does not escape

The =2 flag adds the reasoning chain — useful when the escape is indirect and you cannot see why it is happening.

The Four Common Escape Patterns

1. Returning a pointer to a local variable

func newUser(name string) *User {
    u := User{Name: name} // escapes: returned pointer outlives the function
    return &u
}

u must outlive newUser, so it goes to the heap. This is expected and usually the right trade-off — but it is worth knowing the cost when called in a hot loop.

Compare with returning a value:

func newUser(name string) User {
    u := User{Name: name} // does not escape
    return u              // copied on return, stays on stack
}

For small structs the copy cost is negligible and you avoid the allocation entirely.

2. Storing into an interface

func logValue(v any) {
    fmt.Println(v)
}

func process() {
    n := 42
    logValue(n) // n escapes: stored into interface{}
}

Converting a concrete value to an interface requires storing a pointer to the value in the interface’s data word. If the value is not a pointer, Go must allocate it on the heap first. This is one of the most common invisible allocations in Go programs — logging, error wrapping, and fmt calls are all affected.

./main.go:9:10: n escapes to heap (interface conversion)

When this shows up in a profile, the fix is usually to pass the concrete type directly or use a typed API instead of any.

3. Closures capturing variables

func makeCounter() func() int {
    count := 0            // escapes: captured by closure
    return func() int {
        count++
        return count
    }
}

count is shared between the outer function and the returned closure. The closure outlives the stack frame of makeCounter, so count must be on the heap. This is correct and expected — just be aware that each call to makeCounter produces a heap allocation for count.

4. Variables too large for the stack

Go has a configurable stack size limit (currently 8MB max for a goroutine, but the initial frames are much smaller). Very large local variables — large arrays in particular — are moved to the heap:

func process() {
    var buf [1 << 20]byte // 1 MB — escapes to heap
    // ...
}

If you need a large buffer in a hot path, consider sync.Pool to reuse heap allocations rather than creating new ones on every call.

Reading the Output in Practice

A typical session looks like this:

$ go build -gcflags="-m" ./internal/handler/...

./handler.go:34:15: &req escapes to heap
./handler.go:51:13: err does not escape
./handler.go:67:22: leaking param: w
./handler.go:89:10: moved to heap: buf
  • escapes to heap — this variable will be heap-allocated every time this code runs.
  • does not escape — stays on the stack.
  • leaking param — a parameter’s address is stored somewhere that outlives the call (a field, a closure, a channel); the caller’s allocation may be pinned to the heap.
  • moved to heap — the compiler explicitly relocated it (usually due to size or address-taken in a loop).

What To Do With This Information

Escape analysis output is not a to-do list. Most escapes are correct and intentional — returning pointers, building error values, using interfaces. The workflow is:

  1. Profile first. Use go tool pprof and look for runtime.mallocgc high in the CPU or allocation profile. That tells you heap pressure is a real problem, not just a theoretical one.
  2. Find the hot call sites. pprof shows which functions are allocating most. Run -gcflags="-m" on those packages specifically.
  3. Look for unintentional escapes. Interface conversions in inner loops, closures recreated on every iteration, fmt.Sprintf for error strings in the hot path.
  4. Measure after each change. Escape analysis is a compiler optimization; the compiler’s decisions can change between Go versions. Benchmark, don’t assume.

A Worked Example

Before:

// Called millions of times per second
func (s *Server) handleEvent(e Event) error {
    msg := fmt.Sprintf("event %d: %s", e.ID, e.Type) // allocates: fmt.Sprintf returns string
    s.logger.Info(msg)                                // allocates: msg escapes into logger
    return nil
}

Two heap allocations per call: one for the fmt.Sprintf string, one for the interface conversion inside logger.Info. Under load, this becomes significant GC churn.

After:

func (s *Server) handleEvent(e Event) error {
    s.logger.Info("event", "id", e.ID, "type", e.Type) // structured logging, no fmt
    return nil
}

Structured logging passes the values as concrete types; a good logger implementation (like log/slog) avoids the allocations entirely for common types.

Summary

The compiler allocates a variable on the heap whenever it cannot prove the variable’s lifetime ends with its function. The four patterns that trigger this most often are: returning a pointer to a local, storing into an interface, capturing in a closure that outlives the frame, and very large locals.

-gcflags="-m" makes the invisible visible. Use it after profiling confirms you have a real allocation problem — not as a general hygiene pass, because most escapes are intentional and correct.