How to check if variables escape to the heap?

The Command

To view the compiler’s escape analysis decisions, you use the -gcflags flag with the -m option when building or testing your code.

go build -gcflags -m=2

-gcflags: Passes flags to the Go compiler.

-m: Triggers the printing of optimization decisions, including escape analysis and inlining.

• Level 2 (-m=2): Providing the flag twice (or using =2) increases the verbosity. While there are up to 4 levels, level 2 is recommended as it provides sufficient detail without being overwhelming.

Interpreting the Output

The output will list specific lines in the code and tell you the compiler’s decision. You should look for phrases like: • escapes to heapmoved to heap

Example log

The following garbage collector log came out of using the function SlowJoin that looks like this:

func SlowJoin(input []string) string {  
    for i, s := range input {  
       if i > 0 {  
          result += ", "  
       }  
       result += s  
    }  
    return result  
}

A part of GC output when calling the function:

<autogenerated>:1: inlining call to reflect.flag.ro
<autogenerated>:1: inlining call to reflect.flag.ro
./join.go:9:15: parameter input leaks to ~r0 for SlowJoin with derefs=1:
./join.go:9:15:   flow: {temp} input:
./join.go:9:15:   flow: s *{temp}:
./join.go:9:15:     from for loop (range-deref) at ./join.go:14:14
./join.go:9:15:   flow: result s:
./join.go:9:15:     from result += s (assign) at ./join.go:18:10
./join.go:9:15:   flow: ~r0 result:
./join.go:9:15:     from return result (return) at ./join.go:20:2
./join.go:9:15: leaking param: input to result ~r0 level=1

The compiler is tracing the life of the input slice like a detective:

  • flow: s ← *{temp}: It sees the function pulling strings out of the input slice.
  • flow: result ← s: It sees the function taking those strings and merging them into the result variable.
  • flow: ~r0 ← result: It sees the function returning that result to the outside world.

leaking param: input to result ~r0 level=1: Because the result (which contains data from input) is sent out of the function, the compiler “gives up” on keeping it on the stack. It must allocate memory on the heap to ensure the returned string doesn’t disappear when the function ends.

In a high-performance Go function, you want your variables to stay on the Stack (fast, zero-cleanup). In SlowJoin, the compiler is explicitly saying: “I cannot keep this on the stack.”

  • Every time that loop runs, the result += s line triggers a new heap allocation because the compiler has already flagged result as a “leaking” object.

Another example of a GC Log

This is a Go garbage collector (GC) log message indicating a forced garbage collection cycle.

GC forced  
gc 25 @721.284s 0%: 2.4+20+0.006 ms clock, 28+0/45/0+0.079 ms cpu, 84->84->82 MB, 167 MB goal, 0 MB stacks, 0 MB globals, 12 P

Let’s break down what each part means:

Key Components:

  • gc 25 - This is the 25th GC cycle since the program started.
  • @721.284s - Occurred 721 seconds (~12 minutes) after program start.
  • 0% - GC overhead: 0% of CPU time spent on garbage collection (very low, which is good).

Timing Breakdown:

2.4+20+0.006 ms clock

  • Wall clock time:
  • 2.4ms: Stop-the-world sweep termination.
  • 20ms: Concurrent mark/scan phase.
  • 0.006ms: Stop-the-world mark termination.

28+0/45/0+0.079 ms cpu

  • CPU time across all processors (12 P)

Memory Stats:

84->84->82 MB:

  • 84 MB: Heap size before GC.
  • 84 MB: Heap size after GC (before sweep).
  • 82 MB: Live heap after GC.

167 MB goal - Target heap size before next GC. 12 P - 12 processors available.

What “GC forced” Means:

The GC was manually triggered rather than automatically triggered by memory pressure. This could happen from:

  • runtime.GC() call in code.

  • External monitoring/profiling tools.

  • GODEBUG=gctrace=1 environment variable.

Common Reasons for Escaping

Sharing Up the Stack: Returning the address of a local variable (e.g., return &u) forces the variable to the heap because it must exist after the function returns.

Interface conversion: Passing a concrete value to a function that accepts an interface (like io.Reader or fmt.Println) often causes an escape because the compiler cannot determine at compile time how the value will be used.

Global Variables: Storing a pointer to a local variable in a global variable makes it reachable after the function ends, forcing an allocation.

Unknown Size: Variables with sizes not known at compile time (e.g., slices created with a variable length make([]byte, size)) must be allocated on the heap.

Knowledge Check
Which compiler flag should you use to see if a variable escapes to the heap?
-escape=true
-gcflags -m
-trace=heap
Correct! 🎉 `go build -gcflags -m=2` will print out exactly what the escape analysis engine is deciding and why variables are leaking.
Not quite. The correct answer is B. You pass the `-m` flag to the compiler via `-gcflags` to print optimization and escape analysis decisions.
Why does returning a pointer to a local variable (e.g., return &user) force it to escape to the heap?
Because the compiler automatically optimizes all structs to the heap.
Because the local variable's stack frame is destroyed when the function returns. If it stayed on the stack, the returned pointer would point to invalid memory.
Because pointers are strictly 64-bit and the stack only supports 32-bit variables.
Correct! 🎉 A function's stack frame is popped (destroyed) the moment the function finishes. Any pointers returned to the caller must point to the heap to survive!
Not quite. The correct answer is B. Stack memory is only valid for the lifetime of the function call. Escaping to the heap is required so the data survives after the function returns.
In the GC log 84->84->82 MB, what does the final 82 MB represent?
The amount of memory successfully freed by the garbage collector.
The maximum memory allowed by the OS before an OOM panic.
The size of the "live heap" remaining after the garbage collection cycle has finished sweeping.
Correct! 🎉 That format represents: [Size Before GC] -> [Size after mark phase] -> [Live heap remaining]. 82 MB is the actual in-use memory your program currently needs!
Not quite. The correct answer is C. The three numbers are: heap before GC, heap after GC (before sweep), and the live heap size.

Quiz Complete!

You scored 0 out of 3.

Comments

© 2025 Threads of Thought. Built with Astro.