Back to blog
Aug 20, 2026
6 min read

Escape Analysis in Go: What Actually Matters

A practical look at escape analysis — when allocations move to the heap, how to see it, and when it is worth caring about.

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 / error wrapping on the hot path
  • reuse buffers with sync.Pool only 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

  1. Look at metrics first (CPU, GC, allocs/sec, RSS).
  2. Take a heap profile (inuse_space and alloc_space).
  3. Only then run -gcflags=-m on the package that showed up.
  4. 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.