Understanding Memory in Go

Updated

September 13, 2026

Understanding Memory in Go

Go passes arguments by value and decides for you whether a variable lives on the stack (tied to a function call) or the heap (alive until nothing refers to it). The boring default is to write straight-line code and let the compiler place things. You look at escape analysis when a profile says allocation is the problem — not before.

Mental model

A stack frame is the scratch space for one call: parameters, locals, return slots. When the function returns, that frame is gone. The heap is the shared pool the garbage collector (GC) scans. If the compiler cannot prove a variable dies with the frame, it escapes: the variable is allocated on the heap.

Escape is not a moral failing. Returning &n is legal Go. The compiler prints moved to heap: n and the GC frees n later. C programmers sometimes treat that as a bug. It is the language working.

go build -gcflags='-m' prints the compiler’s notes: what inlined, what escaped. The first line is # command-line-arguments for a lone file, or # your/module inside a module.

Worked examples

Case 1: Pass by value copies the struct

Save as copy_order.go. bump receives its own Order. Adding 50 cents does not touch main’s o.

// copy_order.go
package main

import "fmt"

type Order struct {
    ID    int
    Table int
    Cents int
}

func bump(o Order) {
    o.Cents += 50
}

func main() {
    o := Order{ID: 9, Table: 4, Cents: 400}
    bump(o)
    fmt.Printf("id=%d cents=%d\n", o.ID, o.Cents)
}

Run:

go run copy_order.go

Output:

id=9 cents=400

A pointer parameter copies the pointer, not the struct. That is the next chapter. For a small Order, a copy is cheap and obvious.

Case 2: Locals that never escape

Save as stackish.go. No pointers, no interfaces, no fmt. The compiler inlines add into main and has nothing to heap-allocate.

// stackish.go
package main

func add(a, b int) int {
    s := a + b
    return s
}

func main() {
    _ = add(12, 8)
}

Build (not go run — we want the compiler notes, not a print):

go build -gcflags='-m' -o /tmp/stackish stackish.go

Output:

# command-line-arguments
./stackish.go:4:6: can inline add
./stackish.go:9:6: can inline main
./stackish.go:10:9: inlining call to add

No moved to heap. s is a number in a register or a frame. That is the stack story in practice: if the compiler can see the whole life of a value, it does not bother the GC.

Case 3: Returning an address forces the heap

Save as escape.go. heapTicket returns *int. n cannot live in heapTicket’s frame, because main still uses it afterward.

// escape.go
package main

import "fmt"

func localSum(a, b int) int {
    s := a + b
    return s
}

func heapTicket(id int) *int {
    n := id
    return &n
}

func main() {
    fmt.Println(localSum(12, 8))
    p := heapTicket(41)
    fmt.Println(*p)
}

Run:

go run escape.go

Output:

20
41

Now the notes:

go build -gcflags='-m' -o /tmp/escape escape.go

Output:

# command-line-arguments
./escape.go:6:6: can inline localSum
./escape.go:11:6: can inline heapTicket
./escape.go:17:22: inlining call to localSum
./escape.go:17:13: inlining call to fmt.Println
./escape.go:18:17: inlining call to heapTicket
./escape.go:19:13: inlining call to fmt.Println
./escape.go:12:2: moved to heap: n
./escape.go:17:13: ... argument does not escape
./escape.go:17:22: ~r0 escapes to heap
./escape.go:19:13: ... argument does not escape
./escape.go:19:14: *p escapes to heap

Read it in this order:

  • moved to heap: n — the local in heapTicket cannot stay on the stack.
  • does not escape — that value dies in the call (often an argument to fmt).
  • ~r0 escapes to heap — a return value the compiler named for itself, here because fmt.Println stores it in an ...any list.

fmt makes noisy notes. When you are hunting allocations, compile a function without printing, or look at go test -bench and pprof later. -m is a flashlight, not a dashboard.

Case 4: A pointer parameter that does not escape

Save as bump_ptr.go. o is a pointer so we can mutate. The pointer itself never leaves bump, so the compiler says o does not escape. The Order can still sit in main’s frame.

// bump_ptr.go
package main

import "fmt"

type Order struct {
    ID    int
    Cents int
}

func bump(o *Order) {
    o.Cents += 50
}

func main() {
    o := Order{ID: 9, Cents: 400}
    bump(&o)
    fmt.Printf("id=%d cents=%d\n", o.ID, o.Cents)
}

Run:

go run bump_ptr.go

Output:

id=9 cents=450
go build -gcflags='-m' -o /tmp/bump bump_ptr.go

Output:

# command-line-arguments
./bump_ptr.go:11:6: can inline bump
./bump_ptr.go:17:6: inlining call to bump
./bump_ptr.go:18:12: inlining call to fmt.Printf
./bump_ptr.go:11:11: o does not escape
./bump_ptr.go:18:12: ... argument does not escape
./bump_ptr.go:18:34: o.ID escapes to heap
./bump_ptr.go:18:40: o.Cents escapes to heap

o does not escape is the useful line. o.ID escapes to heap is fmt.Printf boxing numbers into any. Do not “fix” that by avoiding Printf in production logs; fix it when a profile says formatting is hot.

The trap

People coming from C refuse to return &n. They copy the struct into a new on purpose, or they take a pointer to a field of a global. In Go, return the address. The compiler already moved n in Case 3.

The other trap is Case 1: you passed a struct, mutated it, and wondered why the desk still shows the old price. That is not a memory leak. That is a copy. Use a pointer when you mean “change this one.”

The boring rule

  • Assume pass by value. Draw a pointer only when you need mutation or a shared identity.
  • Returning &local is fine. It allocates. It does not dangle.
  • Do not micro-manage stack vs heap. Read -gcflags='-m' when an allocation shows up in a profile.
  • Ignore most fmt escape notes. They are the printer, not your order type.
  • A pointer parameter that does not escape is still a copy of an address — cheap, and enough to mutate.

Try this

  1. In copy_order.go, change bump to take *Order and call bump(&o). Confirm cents become 450.
  2. In escape.go, stop returning &n. Return n as an int. Rebuild with -gcflags='-m' and see whether moved to heap: n disappears.
  3. Add fmt.Println(s) inside add in stackish.go (you will need import "fmt"). Rebuild with -m. Note the extra escape lines. That is the printer, not s becoming “bad.”
  4. Run go build -gcflags='-m=2' on escape.go for more detail. Skim; do not rewrite the program to silence every line.