IndexAdvanced Go PatternsPart 08

Performance & Memory

Escape analysis, sync.Pool, allocations, and profiling.

May 11, 20265 min readPerformancePart 08 of 11

Go gives you a garbage collector, so you can mostly ignore memory — until you can't. When a service is spending its time in GC, or latency spikes under load, you need to understand where allocations come from and how to remove the ones that matter.

The cardinal rule first: measure, don't guess. Then we'll cover escape analysis, the allocation patterns that bite, and sync.Pool.

Rule zero: profile before you optimize

The most common performance mistake in Go is optimizing the wrong thing. Your intuition about hotspots is usually wrong. Go ships world-class profiling tools — use them.

Benchmarks with allocation stats:

Go
func BenchmarkParse(b *testing.B) {
    data := loadTestData()
    b.ReportAllocs() // show allocs/op
    b.ResetTimer()   // exclude setup from timing
    for i := 0; i < b.N; i++ {
        _ = Parse(data)
    }
}
Code
$ go test -bench=Parse -benchmem
BenchmarkParse-8   1000000   1053 ns/op   320 B/op   4 allocs/op

B/op and allocs/op are often more actionable than ns/op — allocations drive GC pressure, and GC pressure drives tail latency.

Profiles for live or benchmarked code:

Go
go test -bench=. -cpuprofile cpu.out -memprofile mem.out
go tool pprof cpu.out      # then `top`, `list FuncName`, `web`

And -benchmem plus pprof's memory profile tell you where allocations happen. Compare benchmarks across changes with benchstat so you know a "speedup" is real and not noise.

Escape analysis: stack vs heap

Go decides automatically whether a value lives on the stack (cheap; freed when the function returns) or the heap (managed by the GC). A value "escapes" to the heap when the compiler can't prove its lifetime is bounded by the function — typically because a pointer to it outlives the call.

does a pointer to the value outlive the call? escape analysis no yes stackcheap · freed on returnno GC involvementt := Thing{} heapGC-managed · costs later"moved to heap"return &t
The compiler places each value · only escaping ones land on the heap

See the compiler's decisions:

Code
go build -gcflags='-m' ./...
# ./main.go:10:6: moved to heap: x
# ./main.go:15:13: ... escapes to heap

Common escape triggers:

Go
// Escapes: returning a pointer to a local.
func newThing() *Thing {
    t := Thing{} // t escapes — its pointer outlives newThing
    return &t
}

// Escapes: storing in an interface often forces heap allocation.
func log(v any) { ... }
log(myStruct) // myStruct may escape into the any

// Escapes: capturing by reference in a closure that outlives the scope.

You usually don't fight escape analysis directly — it's right far more often than you'd manage by hand, and "avoid pointers to reduce escapes" is the kind of micro-optimization that rarely matters. But when a profile points at a hot allocation, -gcflags='-m' tells you why it's on the heap, which is the first step to removing it.

Allocation patterns that matter

When allocations do show up in a profile, these are the usual culprits and fixes.

Preallocate slices when you know the size. Growing a slice reallocates and copies repeatedly. If you know (or can estimate) the final length, set the capacity up front:

Go
// Bad: may reallocate several times as it grows.
var result []int
for _, v := range input {
    result = append(result, transform(v))
}

// Good: one allocation.
result := make([]int, 0, len(input))
for _, v := range input {
    result = append(result, transform(v))
}

The same applies to maps: make(map[K]V, hint) avoids rehashing as it grows.

Build strings with strings.Builder, not +=. Concatenation in a loop allocates a new string every iteration (strings are immutable):

Go
// Bad: O(n²) allocations.
s := ""
for _, part := range parts {
    s += part
}

// Good: one growing buffer, then one final string.
var b strings.Builder
b.Grow(estimatedSize) // optional: preallocate if you know roughly how big
for _, part := range parts {
    b.WriteString(part)
}
s := b.String()

Avoid hidden copies of large structs. Passing or ranging over big structs by value copies them. Use pointers or index access for large elements:

Go
// Copies each (large) item per iteration.
for _, item := range bigItems { use(item) }

// Avoids the copy.
for i := range bigItems { use(&bigItems[i]) }

Beware []byte↔string conversions in hot paths. Each conversion normally copies. In tight loops this adds up; restructure to convert once, or use the APIs that accept the type you already have.

sync.Pool: reuse instead of reallocate

When you repeatedly allocate and discard the same kind of object in a hot path — buffers being the classic case — sync.Pool lets you recycle them, taking pressure off the GC:

sync.Poolrecycled buffers goroutine AGet → Reset → use goroutine BGet → Reset → use Get() Get() Put() Put() GCmay drop at any time
Goroutines borrow and return buffers · the GC can reclaim the pool whenever it likes
Go
var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}

func process(data []byte) string {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()                 // CRUCIAL: clear stale state from last use
    defer bufPool.Put(buf)      // return it for reuse

    buf.Write(data)
    buf.WriteString(" processed")
    return buf.String()
}

Things to know before reaching for it:

  • It's not a cache. The GC can and will drop pooled objects at any time. Pool is for transient, interchangeable objects, not for keeping things alive.
  • Always reset. Objects come back with whatever state they had. Forgetting to Reset() is a data-leak / correctness bug waiting to happen.
  • Measure that it helps. sync.Pool has its own overhead and adds complexity. It pays off under genuine allocation pressure (high-throughput servers, encoders, buffer-heavy code) and can be a net loss otherwise. Benchmark before and after.

Knowing when to stop

Performance work has sharply diminishing returns, and optimized code is usually harder to read. The discipline:

  1. Establish you have a problem — a real latency or throughput target you're missing, not a hunch.
  2. Profile to find the actual hotspot — CPU and memory profiles, not guesswork.
  3. Optimize the one thing that matters, and re-benchmark to confirm it helped.
  4. Stop once you've hit the target. Don't trade readability for nanoseconds nobody will notice.

Most Go code never needs any of this. The code that does needs it measured, precisely, in the few places that count.

The takeaway

The takeaway

Reach for performance work only with a profile in hand. Understand escape analysis to know why things land on the heap (-gcflags='-m'), kill the allocations that profiles flag — preallocate slices and maps, build strings with strings.Builder, avoid large-struct copies — and use sync.Pool to recycle transient objects under real allocation pressure (always resetting them). Then stop. Measured, targeted optimization beats clever code you can't read, every time.