How Stack vs Heap Allocation Works in Go

Go automatically places variables on the stack or heap based on their lifetime, which you can inspect using the -gcflags=-m compiler flag.

The stack frame dies, but your pointer lives

You write a function that creates a local variable and returns a pointer to it. In C, this is a bug waiting to happen. The stack frame dies when the function returns, the pointer dangles, and the program crashes or corrupts memory. In Go, the program runs fine. You check the memory address and it looks valid. You didn't do anything special. Go just made it work.

This magic isn't a runtime guess. It's a compile-time decision called escape analysis. The compiler looks at your code and decides whether a variable can stay on the fast stack or must move to the managed heap. You don't control this decision directly. You write idiomatic code, and the compiler optimizes the memory layout. Understanding escape analysis helps you write code that is both safe and performant without fighting the compiler.

Stack versus heap

Every variable in Go lives somewhere. The two places are the stack and the heap. The stack is like a stack of plates in a cafeteria. You add a plate, you take the top plate. It's fast and orderly. When a function starts, it grabs a stack frame. When the function ends, the frame is popped. Everything inside vanishes instantly. No cleanup is needed. The cost of stack allocation is moving a pointer.

The heap is a warehouse. You can store things there as long as you need them. The garbage collector walks through the warehouse and throws away boxes that nobody is pointing to anymore. Heap allocation costs more because the runtime has to track the object and the garbage collector must eventually visit it. The trade-off is speed versus lifetime. Stack allocation is nearly free. Heap allocation is flexible but managed.

Go decides where to put your variables before the program runs. If a variable's lifetime is strictly bounded by the function call, it stays on the stack. If the variable might be used after the function returns, it escapes to the heap. This rule prevents dangling pointers. The compiler guarantees that heap objects stay alive as long as any reference exists.

Seeing escape analysis in action

Here's the simplest way to see escape analysis in action. Compile with the escape analysis flag to watch the compiler decide.

package main

import "fmt"

// StackVar returns an int by value.
// The compiler can keep x on the stack because the value is copied.
func StackVar() int {
    x := 42 // x lives on the stack frame of StackVar
    return x // value is copied to the caller
}

// HeapVar returns a pointer to x.
// x must survive after HeapVar returns, so it escapes to the heap.
func HeapVar() *int {
    x := 42 // x escapes to heap because its address is taken
    return &x // pointer returned to caller
}

func main() {
    fmt.Println(StackVar()) // prints 42
    fmt.Println(*HeapVar()) // prints 42
}

Run the build command with the flag: go build -gcflags=-m. The output lists variables and their fate. You'll see ./main.go:10:6: x does not escape for the stack variable and ./main.go:16:6: x escapes to heap for the pointer return. The compiler tracks lifetimes precisely. If you return a value, the variable can stay on the stack because the result is copied. If you return a pointer, the variable must escape because the caller holds a reference to it.

How the compiler decides

Escape analysis runs during compilation. The compiler examines every variable. It checks if the variable's address is taken. It checks if the variable is stored in a global. It checks if the variable is passed to a function that might store it. The compiler is conservative. If it can't prove a variable stays local, it moves it to the heap. This safety net prevents subtle bugs.

The decision affects performance. Stack variables are cleaned up by popping the frame. Heap variables are cleaned up by the garbage collector. Excessive heap allocation increases GC pressure. However, modern Go allocators are fast. The cost of a heap allocation is often lower than the cost of copying a large struct by value. If you have a struct with ten fields, returning a pointer might be faster than returning the value because the copy is expensive. Escape analysis helps here too. The compiler balances allocation cost against copy cost.

You cannot force allocation directly. Go does not give you a keyword to force stack or heap allocation. There is no __stack or __heap attribute. This is intentional. The compiler is the expert. If you try to micro-manage memory placement, you write code that breaks when the compiler changes. The only control you have is through the structure of your code. Return a value, and the compiler might keep it on the stack. Return a pointer, and it goes to the heap. Store in a global, and it goes to the heap. Write the code for correctness and clarity. The compiler optimizes the rest.

Interfaces and the hidden cost

Interfaces add another layer. The interface value in Go is a pair of words. The first word is a pointer to the type information. The second word is a pointer to the data. When you pass a value to an interface, the compiler checks the size. If the value is small, it might be embedded. If the value is large, or if the interface escapes, the data is allocated on the heap and the interface points to it. This indirection is the cost of polymorphism. It allows any type to satisfy the interface, but it means the original value often cannot stay on the stack.

Here's a realistic pattern where a struct escapes because it implements an interface.

package main

import "fmt"

// Greeter is an interface type.
// Interface values contain a type pointer and a data pointer.
type Greeter interface {
    Greet() string
}

// User implements Greeter.
// Receiver name is short, following Go convention.
type User struct {
    name string
}

// Greet returns a greeting.
func (u User) Greet() string {
    return "Hello, " + u.name
}

// ProcessUser accepts a Greeter.
// Passing u to this function causes u to escape.
// The interface value must store a pointer to u on the heap.
func ProcessUser(g Greeter) {
    fmt.Println(g.Greet())
}

func main() {
    u := User{name: "Alice"}
    ProcessUser(u) // u escapes to heap
}

When you pass u to ProcessUser, the value escapes. The interface g holds a pointer to the data. If g escapes or if the interface is stored somewhere, the data must live on the heap. Follow the convention: receiver names should be short, like (u User), not (this User). The community expects this style. Also, run gofmt on your code. It enforces a standard layout so you can focus on logic, not indentation.

Goroutines capture, variables escape

Goroutines capture variables by reference. If a goroutine captures a variable, that variable must stay alive while the goroutine runs. This almost always causes an escape. The variable moves to the heap so the goroutine can access it after the function returns. This is why closures are a common source of heap allocations.

Here's how a goroutine forces an escape.

package main

import "fmt"

// StartWorker spawns a goroutine.
// The closure captures msg, so msg must escape.
func StartWorker(msg string) {
    go func() {
        // The goroutine runs asynchronously.
        // msg must survive on the heap for the goroutine to read.
        fmt.Println(msg)
    }()
}

func main() {
    msg := "Hello"
    StartWorker(msg) // msg escapes to heap
}

The variable msg escapes because the goroutine captures it. The compiler moves msg to the heap to ensure it remains valid. If you need to avoid this escape, pass the value as an argument to the goroutine function instead of capturing it in a closure. This gives the compiler more information and can sometimes allow stack allocation.

Pitfalls and compiler errors

Common mistakes involve assuming heap allocation is slow or trying to optimize prematurely. Modern Go uses a concurrent garbage collector and a fast allocator. The cost of a heap allocation is often negligible. Premature optimization by forcing stack allocation via value returns can hurt readability without helping performance. Trust the compiler.

Before Go 1.22, capturing a loop variable in a goroutine was a silent bug. The compiler now rejects this. If you try to capture a loop variable in a closure, the compiler complains with loop variable i captured by func literal. You must assign the variable to a new local variable inside the loop. This error saves you from race conditions and stale data.

Another pitfall is ignoring error returns. The community accepts the boilerplate if err != nil { return err } because it makes the unhappy path visible. Don't discard errors with _ unless you have a specific reason. Discarding errors hides bugs. The worst goroutine bug is the one that never logs. Always handle errors and respect context cancellation. Context is plumbing. Run it through every long-lived call site. Functions that take a context should respect cancellation and deadlines. The context always goes as the first parameter, conventionally named ctx.

Decision matrix

Use value types when the data is small and immutable, keeping allocation on the stack and avoiding garbage collector pressure. Use pointer types when you need to share mutable state or pass large structs without copying the entire payload. Use local variables for temporary data that dies with the function, keeping memory on the stack. Use heap allocation implicitly by returning pointers or storing values in interfaces when the data must survive beyond the current scope. Use the -gcflags=-m flag when you suspect an allocation is escaping and want to verify the compiler's decision. Use value returns for small results to allow stack allocation and zero-garbage collection overhead. Use pointer returns when the result must be shared or mutated by the caller, forcing heap allocation. Use interfaces sparingly when you need polymorphism, accepting that interface values often cause heap escapes.

Write clear code. Let the compiler handle the memory.

Where to go next