How to Use Escape Analysis in Go (go build -gcflags="-m")

Run `go build -gcflags="-m"` to enable escape analysis output, which tells you whether variables are allocated on the stack or heap.

When the stack isn't enough

You are debugging a latency spike in a high-throughput service. The profiler shows garbage collection pauses eating up milliseconds. You suspect a function is allocating memory on the heap when it should stay on the stack. You need to see exactly where the compiler decides to move data. You run go build -gcflags="-m". This flag triggers escape analysis, revealing which variables escape to the heap and why.

Go manages memory automatically using two storage areas. The stack is fast. It grows and shrinks with function calls. Variables on the stack disappear when the function returns. The heap is slower. It holds data that must survive beyond the current function. The garbage collector reclaims unused heap memory. The compiler decides where to put each variable. This decision is escape analysis. If a variable's address could be stored somewhere that outlives the function, the variable escapes to the heap.

Think of the stack like a stack of trays in a cafeteria. You grab a tray, put food on it, eat, and return the tray. The tray is gone from your hands immediately. The heap is like a storage room. You request a box, put items in it, and leave the box there. A janitor comes later to clear out empty boxes. Escape analysis is the manager watching you. If you try to take a tray out of the cafeteria, the manager stops you and gives you a box from the storage room instead.

Minimal example: staying on the stack

Here's a slice that stays on the stack because it is used locally and discarded.

package main

import "fmt"

// main creates a slice and prints it locally.
func main() {
    // Allocate a slice of three integers.
    data := []int{1, 2, 3}
    // Print the slice. The function reads the data but does not store it.
    fmt.Println(data)
}

Run the build with escape analysis enabled:

go build -gcflags="-m" main.go

The compiler produces no escape output. The variable data lives and dies inside main. The compiler knows the slice header and its backing array can stay on the stack. The stack frame is reclaimed when main returns. No heap allocation occurs.

Walkthrough: reading the output

The -m flag prints escape analysis information to stderr during compilation. You see lines like ./main.go:7:6: data escapes to heap. The line number and column point to the variable declaration. The message tells you the variable moves to the heap.

Use -m=2 to get the specific reason. This adds a second line explaining the escape. For example:

./main.go:7:6: data escapes to heap:
    data is returned from function main

The detailed output helps you understand the mechanism. The compiler tracks variable lifetimes. If a variable is returned, stored in a global, captured by a goroutine, or passed to an interface, it might escape. The -m=2 output names the cause.

Escape analysis is a compile-time pass. You cannot query escape information from a running binary. The analysis happens during the build phase. If you try to use the flag with a tool that doesn't support it, the compiler rejects the command with an unrecognized argument error.

Realistic example: returning a slice

Here's a function that returns a slice, forcing the data to escape.

package main

import "fmt"

// createSlice returns a slice that must survive the function call.
func createSlice() []int {
    // Create a slice locally.
    data := []int{1, 2, 3}
    // Return the slice. The backing array must escape to the heap.
    return data
}

// main calls createSlice and prints the result.
func main() {
    // Capture the returned slice.
    result := createSlice()
    // Print the first element.
    fmt.Println(result[0])
}

Compile with -gcflags="-m":

go build -gcflags="-m" main.go

The output shows:

./main.go:8:9: data escapes to heap

The slice data is created inside createSlice. The function returns the slice to the caller. The caller might store the slice in a global variable or pass it to another function. The compiler cannot guarantee the slice dies when createSlice returns. The backing array must move to the heap.

Run with -m=2 to see the reason:

go build -gcflags="-m=2" main.go

The output adds the explanation:

./main.go:8:9: data escapes to heap:
    data is returned from function createSlice

The compiler marks the escape because the value crosses the function boundary. Returning a slice header is cheap. The header is a small struct with a pointer, length, and capacity. The pointer points to the backing array. If the array stays on the stack, the pointer becomes dangling when the function returns. The compiler moves the array to the heap to keep the pointer valid.

Realistic example: goroutine capture

Here's a goroutine capturing a local variable, which forces the variable to escape.

package main

import (
    "fmt"
    "time"
)

// process starts a goroutine that captures a local variable.
func process() {
    // Create a buffer.
    buf := make([]byte, 1024)
    // Start a goroutine. The goroutine might outlive process.
    go func() {
        // Access buf inside the goroutine.
        fmt.Println(len(buf))
    }()
    // Wait briefly to let the goroutine run.
    time.Sleep(time.Millisecond)
}

// main calls process.
func main() {
    process()
}

Compile with escape analysis:

go build -gcflags="-m" main.go

The output shows:

./main.go:10:9: buf escapes to heap

The variable buf is created inside process. A goroutine captures buf in its closure. Goroutines run independently. The compiler cannot guarantee the goroutine finishes before process returns. The buffer must live on the heap. The escape analysis flags buf because it is captured by a goroutine.

Goroutine capture is a common escape trigger. Any variable used inside a goroutine that is not defined inside that goroutine escapes. The compiler moves the variable to the heap to ensure the goroutine can access it safely.

Realistic example: interface allocation

Here's a struct passed to an interface, which can cause the struct to escape.

package main

import "fmt"

// Stringer defines a method to convert to string.
type Stringer interface {
    String() string
}

// person holds a name.
type person struct {
    name string
}

// String returns the name.
func (p person) String() string {
    return p.name
}

// main creates a person and passes it to an interface.
func main() {
    p := person{name: "Alice"}
    // Pass p to a function expecting an interface.
    printStringer(p)
}

// printStringer accepts a Stringer interface.
func printStringer(s Stringer) {
    fmt.Println(s.String())
}

Compile with escape analysis:

go build -gcflags="-m" main.go

The output shows:

./main.go:22:5: p escapes to heap

The variable p is a struct. It is passed to printStringer, which accepts a Stringer interface. Interface values can be stored anywhere. The compiler assumes the interface might be stored in a global variable or returned from the function. The underlying value must escape to the heap.

Interface allocation is a subtle escape trigger. Passing a value to an interface often forces the value to escape. The compiler conservatively assumes the interface value might outlive the current scope. If you want to avoid the escape, pass the concrete type instead of the interface, or use a pointer if the interface method set requires it.

Pitfalls and conservative analysis

Escape analysis is conservative. The compiler sometimes escapes variables that could theoretically stay on the stack. This happens when the analysis is too complex to prove safety. Or when the heap allocation is cheaper than the stack management overhead. You cannot force a variable to stay on the stack. The compiler makes the final call.

Inlining adds noise to the output. The compiler inlines small functions by default. Inlining merges the code of the caller and callee. Escape analysis runs on the merged code. Variables from the caller might appear to escape inside the callee. This makes the output hard to trace. Add -l to disable inlining. The compiler keeps functions separate. The escape output points to the original function boundaries. This makes debugging much easier. You trade some optimization for clarity.

The compiler might optimize away allocations at runtime. Escape analysis shows potential allocation sites. The compiler might eliminate an allocation if it proves the value is unused. Or it might reuse stack space. If you see an escape you think is wrong, profile the heap. If the allocation doesn't show up in the profile, the compiler might be optimizing it away.

Convention aside: gofmt is mandatory. Run gofmt before analyzing escape output. Messy code makes the line numbers harder to match. Most editors run gofmt on save. Trust the tool. Also, if err != nil is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Escape analysis helps see the invisible cost of allocations. Use both tools to write clear and efficient code.

Decision: when to use escape analysis

Use go build -gcflags="-m" when you want a quick list of variables that escape to the heap in your package.

Use go build -gcflags="-m=2" when you need the specific reason a variable escapes, such as being returned or captured by a closure.

Use go build -gcflags="-m -l" when you want to disable inlining to get cleaner escape analysis output.

Use heap profiling with pprof when you need to measure the actual runtime cost of allocations, since escape analysis only shows potential allocation sites.

Use the compiler's default behavior when performance is acceptable: the optimizer handles most allocation decisions correctly.

Escape analysis is a map, not the territory. Profile the runtime to find real bottlenecks. The compiler escapes to be safe. Trust the profiler, not the flags.

Where to go next