Module 5 of 6
Module 5: Memory Model & Escape Analysis
Understand Go's stack vs. heap allocation, escape analysis, value vs. pointer receivers, sync.Pool, and profiling zero-alloc hot paths.
Module 5: Memory Model & Escape Analysis
The mental model shift
Go has a garbage collector, so you might think memory allocation is invisible. It isn't — not if you care about latency, throughput, or your cloud bill.
Go has two allocation zones: the stack and the heap. Stack allocations are effectively free — the compiler bumps a pointer, and the memory is automatically reclaimed when the function returns. Heap allocations go through the GC: slower to allocate, and every one of them adds pressure that eventually triggers a GC cycle.
The compiler decides where to allocate using escape analysis. A variable "escapes to the heap" when the compiler can't prove it won't be needed after the function returns. Common causes: returning a pointer, storing a value in an interface, passing to a goroutine, or storing in a slice or map that outlives the function.
Here's the counterintuitive part: value semantics isn't just a style preference — it directly affects where memory is allocated. Passing a struct by value often keeps it on the stack. Returning a pointer often forces a heap allocation. This is why the standard library is so deliberate about when it uses pointers.
You don't need to become an escape analysis wizard to write performant Go. But you should understand the basic forces at play, because they quietly shape every hot path in your code.
Key concepts
go build -gcflags="-m"prints the compiler's escape analysis decisions. Add-magain for extreme detail.- Value receivers operate on a copy — stack-friendly when the struct is small. Pointer receivers operate on the original — required for mutation, but may force heap allocation.
sync.Poollets you reuse heap-allocated objects, amortizing the allocation cost across many uses.go test -bench=. -benchmemshows allocations per operation. This should be in your muscle memory.go tool pprofwithruntime/pprofornet/http/pproffor production profiling.
Drill 1 — Reading escape analysis output
Run this file with go build -gcflags="-m" and actually read the compiler's decisions:
package main
func stackAlloc() int {
x := 42 // stays on stack — the compiler can see it won't escape
return x // returning a value, not a pointer
}
func heapAlloc() *int {
x := 42
return &x // "x escapes to heap" — the pointer outlives the function
}
type Big struct {
data [1024]byte
}
func makeBig() Big {
return Big{} // value return — copy, but no heap allocation
}
func makeBigPtr() *Big {
b := &Big{} // "&Big{} escapes to heap" — pointer returned
return b
}
func main() {
_ = stackAlloc()
_ = heapAlloc()
_ = makeBig()
_ = makeBigPtr()
}The output will surprise you. The compiler is smarter than you think. Some things you expect to escape won't. Some things you expect to stay on the stack will escape for subtle reasons. The only way to know is to check.
Exercise: Benchmark makeBig vs makeBigPtr with go test -bench=. -benchmem. For a 1KB struct, is the heap allocation actually slower? At what struct size does the difference become meaningful? The answers might change your mind about when to use pointers.
Drill 2 — Value vs pointer receivers
This isn't just about performance — it's about correctness:
package main
import "fmt"
type Counter struct {
count int
}
// Value receiver — gets a COPY of the struct
func (c Counter) IncrementBad() {
c.count++ // modifies the copy, not the original — this is a BUG
}
// Pointer receiver — gets the original struct
func (c *Counter) Increment() {
c.count++ // modifies the original
}
// Value receiver is fine when you only need to read
func (c Counter) Value() int {
return c.count
}
func main() {
c := Counter{}
c.IncrementBad()
fmt.Println(c.Value()) // 0 — your increment was silently discarded
// This is the kind of bug that passes code review because it looks right
c.Increment()
fmt.Println(c.Value()) // 1 — pointer receiver, mutation works
}My receiver rules (after many code reviews):
- If the method mutates the receiver → pointer receiver, always
- If the receiver is a large struct → pointer receiver, to avoid copying
- If any method on the type needs a pointer receiver → use pointer receivers for all methods, for consistency
- If the receiver is small and the method is read-only → value receiver is fine
Drill 3 — sync.Pool (the GC pressure relief valve)
package main
import (
"bytes"
"fmt"
"sync"
)
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
// Without pool: allocates a new buffer every single call
func buildStringNoPool(n int) string {
var buf bytes.Buffer
for i := 0; i < n; i++ {
fmt.Fprintf(&buf, "item%d,", i)
}
return buf.String()
}
// With pool: reuses buffers across calls and GC cycles
func buildStringWithPool(n int) string {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufPool.Put(buf)
for i := 0; i < n; i++ {
fmt.Fprintf(buf, "item%d,", i)
}
return buf.String()
}Run: go test -bench=. -benchmem and compare allocs/op. The pooled version should show dramatically fewer allocations.
But be careful: sync.Pool is not a cache. The GC can clear pooled objects at any time. Use it for objects that are expensive to allocate and frequently reused within a single GC cycle — buffers, scratch slices, temporary structs. Don't use it for anything that must persist.
Capstone — Zero-alloc hot path optimization
Here's your mission: optimize FormatLog(level, msg string, fields map[string]string) string, a function called 50,000 times per second.
Start with the naive implementation (string concatenation or fmt.Sprintf). Profile it. Find where allocations happen. Then iteratively optimize:
- Swap in
strings.Builder→ reduces allocations, not to zero - Add a
sync.Poolfor the builders → further reduction - Pre-compute and cache the format string → target: 0 allocs/op
Write a benchmark table comparing each version. The numbers will tell the story better than any explanation I could write. When you go from hundreds of allocs/op to zero, and see the ns/op drop by an order of magnitude — that's when this module stops being theory and becomes muscle memory.