Skip to Content
Course content

217: Escape Analysis and Stack vs Heap Allocation

Click on the "Edit" button in the top corner of the screen to edit your slide content.

If you've spent any time with C or C++, your brain is probably wired to scream when you see a function return a pointer to a variable created inside that same function. You've been taught that local variables live on the stack, and once the function returns, that stack frame is popped and gone. Returning a pointer to that memory would lead to a "dangling pointer" and a guaranteed crash or a security vulnerability. This is a habit that's hard to break, and it's the biggest misconception I see when people move to Go.

The "Dangling Pointer" Panic vs. Go's Reality

In many languages, returning a pointer to a local variable is a cardinal sin. But look at this Go code:

type User struct {
    Name string
    Age  int
}

func NewUser(name string, age int) *User {
    u := User{Name: name, Age: age}
    return &u 
}

If Go worked like C, u would be allocated on the stack, and the moment NewUser returned, that memory would be invalid. But in Go, this is not only legal—it's a standard pattern. Why? Because the Go compiler performs Escape Analysis.

The compiler doesn't blindly put everything "local" on the stack. It analyzes the scope of the variable. In the example above, the compiler sees that the address of u is being returned to the caller. Since the variable "escapes" the function's scope, the compiler automatically moves it to the heap. You don't have to tell it to do this; it just happens.

Stack Allocation vs. Heap Allocation

Before we dive into how to control this, we need to be clear on why we care. The stack is incredibly fast. It's just a pointer move in memory. When a function returns, the stack is cleaned up instantly. The heap, however, is a big messy pool of memory. Allocating there is slower, and more importantly, it creates work for the Garbage Collector (GC). If everything escaped to the heap, your Go program would spend half its time cleaning up memory instead of running your logic.

Generally, the compiler keeps a variable on the stack if:

  • The variable is not referenced outside the function.
  • The size of the variable is known at compile time.
  • The variable isn't too large (huge arrays will be pushed to the heap regardless).

Peeking Under the Hood with gcflags

I don't want you to just take my word for it. You can actually ask the compiler to tell you what it's doing. There's a specific flag you can pass to the build tool to see the escape analysis decisions.

Try running this in your terminal:

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

The -m flag tells the compiler to print optimization decisions. You'll see lines like moved to heap: u or u does not escape. I use this all the time when I'm profiling a hot loop in a production service. If I see a small object escaping to the heap inside a loop that runs a million times a second, I know exactly where my GC pressure is coming from.

One common "gotcha" that surprises people is using interfaces. Because interfaces are dynamic, the compiler often can't prove that a value won't escape, so it frequently moves values passed into fmt.Printf (which takes interface{}) to the heap. It's a small price to pay for flexibility, but it's a good reminder that "invisible" conversions can trigger heap allocations.




📋 Practical Task

Refactoring for Stack Allocation in a Metrics Logger

You are optimizing a high-throughput metrics system. Currently, the system creates a small Metric struct for every single event, and it's causing significant GC overhead because the structs are escaping to the heap.

Your Goal: Modify the provided code so that the Metric struct stays on the stack instead of escaping to the heap. You will know you've succeeded when go build -gcflags="-m" no longer reports that the metric "escapes to heap".

package main

import "fmt"

type Metric struct {
    Value float64
    ID    int
}

// This function currently causes the Metric to escape
func LogMetric(id int, val float64) {
    m := &Metric{ID: id, Value: val}
    fmt.Printf("Metric %d: %f\n", m.ID, m.Value)
}

func main() {
    for i := 0; i < 10; i++ {
        LogMetric(i, float64(i)*1.5)
    }
}

Hint: Think about how the Metric is being declared and how it's being passed to fmt.Printf. Try changing the pointer to a value and see if you can eliminate the heap allocation while keeping the logic identical.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.