Understanding Memory in Go
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.goOutput:
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.goOutput:
# 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.goOutput:
20
41
Now the notes:
go build -gcflags='-m' -o /tmp/escape escape.goOutput:
# 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 inheapTicketcannot stay on the stack.does not escape— that value dies in the call (often an argument tofmt).~r0 escapes to heap— a return value the compiler named for itself, here becausefmt.Printlnstores it in an...anylist.
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.goOutput:
id=9 cents=450
go build -gcflags='-m' -o /tmp/bump bump_ptr.goOutput:
# 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
&localis 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
fmtescape 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
- In
copy_order.go, changebumpto take*Orderand callbump(&o). Confirm cents become 450. - In
escape.go, stop returning&n. Returnnas anint. Rebuild with-gcflags='-m'and see whethermoved to heap: ndisappears. - Add
fmt.Println(s)insideaddinstackish.go(you will needimport "fmt"). Rebuild with-m. Note the extra escape lines. That is the printer, notsbecoming “bad.” - Run
go build -gcflags='-m=2'onescape.gofor more detail. Skim; do not rewrite the program to silence every line.