Reducing Allocations and GC Pressure

Why allocations are the first thing to look at

In Go, CPU cost and memory cost meet in the allocator. Each heap allocation costs time directly (runtime.mallocgc, typically 10–50 ns for small objects), and indirectly: allocation rate decides how often the GC runs, every cycle costs CPU proportional to the live heap, and goroutines that allocate during a cycle pay mark assists inline, which turns GC cost into request latency (Garbage Collector). In a hot path, removing allocations is usually the single most effective optimization, and allocs/op is one of the most stable benchmark metrics — it does not vary with machine noise.

Measurements: Go 1.26.4, Apple M1 Pro. All results are from -benchmem benchmarks in the same package.

Find them first

  • Benchmarks with -benchmem / b.ReportAllocs().
  • Heap profile, alloc_space / alloc_objects — where the bytes and objects are allocated over time (not what is live): go tool pprof -sample_index=alloc_objects -top mem.out.
  • CPU profile: runtime.mallocgc, runtime.gcAssistAlloc, runtime.gcBgMarkWorker, and runtime.memclrNoHeapPointers high in the profile mean allocation is a CPU cost worth attacking.
  • Escape analysis (-gcflags=-m) for why a specific value is on the heap (Escape Analysis).

Technique 1: pre-size slices and maps

Collecting 10,000 int64 values:

Variant ns/op B/op allocs/op
var s []int64 + append 50,519 357,626 19
make([]int64, 0, n) + append 25,815 81,920 1
map[int64]int64{} + insert 340,533 591,482 79
make(map[int64]int64, n) + insert 103,829 295,554 33

Growing a slice by appending reallocates and copies about 19 times on the way to 10,000 elements and allocates 4.4x the final size in total. Growing a map is worse: every growth step rehashes entries into new tables. When the size is known — or can be estimated — pass it.

Technique 2: values instead of pointers

Building a slice of 1,000 small records (two int64s and a 16-byte name) that escapes to the caller:

Variant ns/op B/op allocs/op
[]*Rec, each &Rec{...} 14,269 40,192 1,001
[]Rec 4,735 32,768 1

One allocation instead of a thousand, contiguous memory (better cache behavior when iterating), and — because Rec contains no pointers — the GC never needs to scan the backing array at all. Use []*T when elements must be shared or individually referenced, or are large and moved around often; default to []T.

The same principle at larger scale: millions of small objects with pointers between them (trees, graphs, maps of structs with string fields) are the most expensive thing you can ask the GC to mark. Alternatives: indices into a slice instead of pointers; one big []byte arena with offsets instead of many small strings; a map[uint64]uint32 from hash to index instead of map[string]*Entry.

Technique 3: append-style APIs and buffer reuse

Encoding a small event struct to JSON three ways:

Variant ns/op B/op allocs/op
json.Marshal(&ev) 146 64 1
json.NewEncoder(pooledBuf).Encode(&ev) with sync.Pool 137 0 0
Hand-written appendEvent(buf[:0], &ev) with strconv.Append* 74 0 0
func appendEvent(dst []byte, e *Event) []byte {
    dst = append(dst, `{"id":`...)
    dst = strconv.AppendInt(dst, e.ID, 10)
    dst = append(dst, `,"user":`...)
    dst = strconv.AppendQuote(dst, e.User)
    // ...
    return append(dst, '}')
}

var buf []byte
for _, ev := range events {
    buf = appendEvent(buf[:0], &ev) // reuses buf's capacity after the first call
    w.Write(buf)
}

The pattern — the caller owns the buffer, the function appends to it — moves the allocation decision out of the hot function. It is how strconv, encoding/binary, time.AppendFormat, and high-performance encoders are built. (Note the hand-written encoder skips JSON string escaping rules that strconv.AppendQuote only approximates; in real code use a generated or well-tested encoder.)

Technique 4: sync.Pool, correctly

sync.Pool caches temporary objects for reuse across goroutines. It keeps a private slot and a lock-free shared list per P, so Get/Put are cheap and mostly contention-free. Since Go 1.13, objects survive one GC cycle in a victim cache and are dropped after the second, so a pool never holds memory forever.

var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

func render(w io.Writer, v *Page) error {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()                       // always reset: you get someone else's leftovers
    defer func() {
        if buf.Cap() <= 64<<10 {      // don't keep giant buffers alive
            bufPool.Put(buf)
        }
    }()
    if err := tmpl.Execute(buf, v); err != nil {
        return err
    }
    _, err := w.Write(buf.Bytes())   // finish using buf BEFORE Put
    return err
}

Rules:

  • Pool pointers, not values: Put([]byte) converts the slice header to any and allocates on every call; pool *bytes.Buffer or *[]byte.
  • Reset every object after Get.
  • Cap what you return. One request that grows a buffer to 50 MB would otherwise keep a 50 MB buffer in the pool for every P.
  • Never use a buffer after Put — another goroutine may already be writing to it. This is a data race the race detector may only catch under the right interleaving.
  • Pools are for cheap-to-recreate temporaries. Connections, file handles, and anything needing cleanup belong in a purpose-built pool with explicit lifetime management.
  • Measure: for small objects, the allocator is fast, and a pool can be slower than allocating. Pools pay off for objects that are large (≥ a few KB) or expensive to initialize.

Technique 5: avoid hidden allocations

Source Fix
Passing values as any (fmt, loggers, errors.New with formatting) Typed APIs (slog.Int), level checks before building log args, strconv instead of fmt.Sprintf for numbers
fmt.Sprintf("%s:%d", host, port) in a hot path net.JoinHostPort, strconv.AppendInt, strings.Builder with Grow
string(b) / []byte(s) conversions that escape Keep one representation through the hot path; rely on compiler-optimized cases (map lookups, comparisons)
Closures capturing variables that escape Pass values as arguments; avoid storing closures in long-lived structures in hot paths
time.After in a loop time.NewTimer + Reset (and since Go 1.23 unreferenced timers are collected, but each call still allocates)
defer in a loop body Move the body into a function
Converting method values (f := obj.Method) Call directly in hot loops
Growing bytes.Buffer / strings.Builder repeatedly Grow(n) once

Technique 6: reduce what is live, not only what is allocated

The cost of each GC cycle is proportional to the live heap that must be marked. Halving the live heap of a cache-heavy service can cut GC CPU as much as halving allocations:

  • Store compact representations (fixed-size arrays instead of strings for IDs, integer timestamps instead of time.Time — 8 bytes instead of 24 including a pointer).
  • Keep pointer-free data in separate allocations from pointer data so the GC skips it.
  • Bound caches by size, and remember maps never shrink (Memory Layout of Go Values).

When to stop

Allocation optimization makes code longer and easier to break (pool misuse, aliasing bugs). Apply it where profiles say allocation matters: the per-request or per-record path of a hot service. Outside that, clarity wins. When you do optimize, pin the result with testing.AllocsPerRun so it cannot regress silently.

Common pitfall

Returning a pooled buffer while a slice of it is still in use. An HTTP handler gets a *bytes.Buffer from a pool, encodes the response into it, defers bufPool.Put(buf), and returns buf.Bytes() to a caller that writes it to the connection after the handler returns. By then another goroutine has taken the buffer from the pool and is encoding a different response into the same backing array, and clients occasionally receive another user's data. The bug shows up only under concurrency, intermittently, and sometimes as a security incident. Pooled memory must be fully consumed before Put; if bytes need to outlive the function, copy them out or don't pool.

Review questions

  1. Why does allocation rate affect request latency, not only CPU usage?
  2. Why did []Rec need 1 allocation where []*Rec needed 1,001, and what does the GC skip in the []Rec case?
  3. What makes an Append-style API cheaper than one that returns a new []byte?
  4. Why should you pool *bytes.Buffer rather than []byte, and why cap the size of buffers you put back?
  5. A pool made a benchmark slower. How is that possible?