Stack vs Heap in Go

How Allocation Decisions Are Made

Go uses escape analysis to automatically allocate variables to the stack or heap based on their lifetime.

When memory location stops being your problem

You write a function that calculates a user profile. It builds a struct, fills in the fields, and returns it. In JavaScript, you don't think about where that object lives. In C++, you might reach for new or worry about returning a reference to a local variable. Go sits in the middle. The compiler decides where your data lives before your program ever runs. You don't ask for stack or heap memory. You just write the code, and escape analysis figures out the rest.

Stop guessing where variables live. Let the compiler trace the data flow.

How stack and heap actually work in Go

Memory in Go splits into two main regions. The stack grows and shrinks with every function call. It is fast because the CPU manages it with simple pointer adjustments. When a function returns, the stack frame vanishes instantly. The heap is a larger pool of memory that survives function calls. The garbage collector sweeps it periodically to reclaim unused space. Accessing the heap costs more because it requires pointer indirection and occasional GC pauses.

Go's compiler runs escape analysis during compilation. It traces every variable to see where it goes. If a variable stays inside the function and never leaves, it lives on the stack. If you return a pointer to it, store it in a global map, or pass it to a goroutine that outlives the current call, it escapes to the heap. The decision is purely static. The compiler reads your code, follows the data flow, and picks the allocation site.

This design removes manual memory management from your daily work. You never call malloc or free. You never worry about dangling pointers. The compiler guarantees that heap-allocated objects are reachable until the garbage collector reclaims them. Stack-allocated objects vanish automatically when their scope ends. The tradeoff is that you surrender fine-grained control. In exchange, you get memory safety and predictable performance.

Write for correctness first. Allocation strategy follows naturally.

Minimal example

Here's the simplest case that shows the boundary. A function returns a value directly, and another returns a pointer.

package main

import "fmt"

// ByValue returns a struct directly.
// The struct stays on the stack because nothing holds a reference to it after the call.
// Copying small structs by value is cheap and avoids pointer indirection.
func ByValue() struct{ Name string } {
    return struct{ Name string }{Name: "Alice"}
}

// ByPointer returns a pointer to a struct.
// The struct must escape to the heap because the caller will use the pointer after this function returns.
// Returning a pointer to a local variable forces heap allocation.
func ByPointer() *struct{ Name string } {
    s := struct{ Name string }{Name: "Bob"}
    return &s
}

func main() {
    v := ByValue()
    p := ByPointer()
    fmt.Println(v.Name, p.Name)
}

The first function copies the struct into the caller's stack frame. The second function takes the address of a local variable and hands it out. The compiler sees that s must survive past ByPointer returns, so it allocates s on the heap. You get the same runtime behavior regardless of which path the compiler chooses. The difference only shows up in allocation counters and cache locality.

Trust the escape analysis. Don't second-guess the compiler's allocation site.

Walk through what happens at compile time

Run the compiler with the -m flag to watch escape analysis in action. The flag tells the compiler to print optimization notes, including allocation decisions.

go tool compile -m main.go

The output will contain lines like moves s to heap or s does not escape. The compiler isn't guessing. It follows strict rules. If a variable's address is taken and that address survives past the function return, the variable escapes. If the variable is copied by value and the original is discarded, it stays on the stack. You'll also see notes about interface conversions and closure captures. The compiler treats every escape as a heap allocation request.

Escape analysis happens at compile time, not runtime. This means the allocation strategy is fixed for a given build. You cannot change it with environment variables or runtime flags. The compiler's job is to guarantee memory safety without manual allocation calls. If you need to verify the behavior, trust the -m output over mental models. The compiler sees pointer aliases and escape paths that are easy to miss by hand.

Go developers follow a simple convention here: receiver names are usually one or two letters matching the type, like (b *Buffer) Write(...). This keeps method signatures readable when you're tracing pointer escapes across packages. The compiler doesn't care about the name, but your teammates will.

Read the -m output like a map. Follow the escape paths, not the line numbers.

Realistic example

Real code rarely returns bare structs. You usually build responses, cache data, or pass state between goroutines. Consider a function that prepares an HTTP response body. It reads a configuration, formats a message, and returns a byte slice.

package main

import (
    "bytes"
    "fmt"
)

// BuildResponse formats a greeting based on the provided name.
// The buffer escapes to the heap because the returned slice references its underlying array.
// Slices are just headers pointing to heap-backed arrays.
func BuildResponse(name string) []byte {
    var buf bytes.Buffer
    buf.WriteString("Hello, ")
    buf.WriteString(name)
    buf.WriteString("!")
    return buf.Bytes()
}

func main() {
    body := BuildResponse("World")
    fmt.Println(string(body))
}

The bytes.Buffer starts as a local variable. Its internal array lives on the stack initially. The moment buf.Bytes() returns a slice pointing to that array, the array must outlive the function. The compiler detects the slice reference and moves the buffer's backing store to the heap. You didn't ask for heap memory. The data flow forced it. This pattern appears constantly in Go. Slices, maps, and channels always allocate their backing structures on the heap. The header values (length, capacity, pointer) can live on the stack, but the underlying data never does.

Another common scenario involves goroutines. If you capture a loop variable in a closure and launch a goroutine, that variable escapes. The goroutine might outlive the function that created it, so the compiler promotes the captured data to the heap. Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. Context is plumbing. Run it through every long-lived call site.

The community mantra here is straightforward: accept interfaces, return structs. Interfaces carry a pointer to the underlying data and a type descriptor, which often forces heap allocation. Returning concrete structs keeps allocation decisions explicit and predictable.

Let the data flow dictate memory layout. Don't force heap allocation with unnecessary pointers.

Pitfalls and compiler signals

Developers coming from managed languages often treat heap allocation as a performance enemy. It is not. The Go garbage collector is generational and highly optimized. Short-lived heap objects are collected in nanoseconds. Prematurely optimizing allocation sites usually hurts readability and gains nothing. The real danger is stack overflow. The stack starts small and grows dynamically, but it has a hard limit. Recursive functions that allocate large structs on the stack will panic with runtime: stack overflow. The panic message is straightforward. It tells you the call chain exceeded the stack budget. Fix it by returning values instead of pointers, or by breaking recursion into iterative loops.

Another common trap is misreading -m output. The compiler prints notes for every function in the file, including standard library calls. Filter the output to your package. Look for escapes to heap or does not escape. If you see moved to heap, the variable started on the stack but was promoted. If you see does not escape, it stayed put. The compiler also prints leaking param to heap when a function parameter's address is stored somewhere long-lived. These messages are diagnostic, not errors. The program compiles and runs regardless.

Go's convention around pointers reinforces this. Pass pointers when you need to mutate the receiver or avoid copying large structs. Pass values when the data is small or immutable. Don't pass a *string. Strings are already cheap to pass by value. The compiler handles the rest. Don't add a pointer just to "save memory." Pointer indirection costs CPU cache misses that often outweigh the allocation savings. Trust the escape analysis. Write idiomatic code. Let the compiler optimize.

The worst allocation bug is the one you optimize away by accident. Profile before you refactor.

When to care about stack versus heap

Use a value type when the struct is small and you only need to read or copy it. Use a pointer type when the struct is large and you want to avoid copying it across function boundaries. Use a pointer type when you need to mutate the original data from multiple call sites. Use a slice or map when you need dynamic sizing, because their backing arrays always live on the heap anyway. Use the -m flag when you suspect a performance bottleneck and need to verify allocation sites. Use plain sequential code when allocation strategy doesn't matter: the simplest thing that works is usually the right thing.

Stop fighting the type system. Wrap the value or change the design.

Where to go next