Go’s garbage collector is good enough that most code should not start with allocation micro-optimization. That said, if you have ever looked at a heap profile and wondered why a tiny struct is being allocated on every request, you eventually bump into escape analysis.
This post is not a compiler internals deep dive. It is the mental model I use when a service is allocating more than it should.
Stack vs heap, without the folklore
If a value only lives as long as the function that created it, the compiler can keep it on the stack. When the function returns, it is gone. No GC involvement.
If the compiler cannot prove that, the value escapes and is allocated on the heap. Then the GC has to track it.
That is the whole game: can the compiler prove this value does not outlive the current stack frame?
If it cannot prove it, it is conservative. The value goes to the heap.
How to see what is escaping
go build -gcflags="-m=2" ./...
You will get a lot of noise. Filter it:
go build -gcflags="-m" . 2>&1 | grep -E "escapes|moved to heap|leaking param"
A typical line looks like:
./handler.go:42:6: moved to heap: req
./service.go:18:20: leaking param: ctx
./repo.go:55:27: &User{...} escapes to heap
leaking param does not mean a memory leak. It means the compiler cannot prove the parameter stays inside the function. Interfaces, goroutines, and returned pointers trigger this all the time.
For a more visual check, use pprof. Escape analysis tells you why. A heap profile tells you whether it matters.
The usual reasons things escape
1. You return a pointer to a local
func newUser(name string) *User {
u := User{Name: name}
return &u // u escapes
}
This is not automatically bad. Returning *User is a normal API. Just do not be surprised that it allocates.
2. The value is sent to another goroutine
go func() {
fmt.Println(u.Name)
}()
The compiler has to assume that goroutine can outlive the current function, so u often escapes.
3. It is stored in an interface
var err error = fmt.Errorf("boom") // the concrete value often escapes
This is why fmt.Errorf, logging with any fields, and fmt.Sprintf show up in allocation profiles more than people expect.
4. You take the address and pass it somewhere the compiler cannot see
json.Marshal(u) // method on interface, u may escape
fmt.Printf("%v", u) // same idea
Once a value crosses an interface method, the compiler is much more conservative.
5. Slices and maps grow
The backing array of a slice lives on the heap as soon as it is not tiny and stack-bound. Appending in a hot loop without a known capacity is a classic source of extra allocations.
// better when you know the size
out := make([]Result, 0, len(in))
A pattern that surprises people
func (s *Service) Handle(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
log.Printf("handling %s", id)
}
Looks harmless. Then you profile it and see allocations around the logger or the query parsing. The request itself does not escape, but intermediate strings, error values, and interface conversions do.
Another one I still catch in reviews:
func (u User) String() string {
return fmt.Sprintf("%s <%s>", u.Name, u.Email)
}
Fine for occasional debug output. Painful if you call it on every item in a hot loop.
When I bother changing the code
I only start rewriting for allocations when:
- a heap profile shows a clear hotspot under realistic load
- GC CPU or pause time is actually showing up in metrics
- the function is on a very hot path (per-request, per-message, per-row)
I do not rewrite a function because -m said something escaped. Escape analysis is a hint, not a task list.
When I do change something, the high-leverage moves are usually boring:
- preallocate slices
- avoid
fmt/errorwrapping on the hot path - reuse buffers with
sync.Poolonly after measuring - stop converting structs to interfaces just to log them
sync.Pool is useful for buffers you already understand (JSON encoding, byte slices). It is not a default tool. Pools have their own lifetime surprises, especially with []byte that later get retained.
A small example that is worth knowing
func sum(nums []int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
nums does not escape. total does not escape. This is the kind of code the compiler is good at.
Now this:
func asAny(nums []int) []any {
out := make([]any, len(nums))
for i, n := range nums {
out[i] = n // each int is boxed into an interface
}
return out
}
Every element becomes a heap allocation. If you see this in a serializer or a metrics library, that is often the real cost — not the loop itself.
How I use this in practice
- Look at metrics first (CPU, GC, allocs/sec, RSS).
- Take a heap profile (
inuse_spaceandalloc_space). - Only then run
-gcflags=-mon the package that showed up. - Change the obvious thing, benchmark it, keep the clearer version if the gain is tiny.
The last point matters. A slightly faster function that is harder to read is a bad trade unless the function is actually hot.
Closing
Escape analysis is useful once you already know you have an allocation problem. It is not a style guide.
Write the straightforward version first. When the profile says a value is dying on the heap in a tight loop, then ask why the compiler could not keep it on the stack. Most of the time the answer is an interface, a pointer return, or a goroutine — not some mysterious compiler bug.