Skip to content
gohacks.dev
Performance

Reduce Allocations in Go Hot Paths Without Ugly Code

Cut a Go report job from 2,171 allocations to 0 in four steps: ns per run fell from 187,784 to 84,482 and each step kept the output byte for byte equal

13 min read
Summarize in AI
Flat illustration of a flame graph with one hot path.

"Reduce allocations" is the most repeated piece of Go performance advice and the least specific. Allocations are not slow in themselves, a Go allocation costs tens of nanoseconds. They are slow in aggregate, because every allocated byte must eventually be found and freed by the collector, and because a function that allocates a lot also touches a lot of memory. The useful version of the advice is a method: find where the allocations come from, remove the ones that exist for no reason, and check after each change that the program still does the same thing. This article applies it to one job, in four measured steps, from 2,171 allocations to none, and says honestly which step bought what.

Tested with Go 1.25.6 on Apple M1 Max (10 cores, 32 GB), macOS arm64, 2,000 log lines per run, median of 5 runs of 150 ms, each version checked to produce an identical report

It uses the profiling tools from allocation profiles to find the sites, escape analysis to understand why they allocate, and the building blocks in strings.Builder, preallocating slices, sync.Pool and string and byte conversions. The effect on the collector is covered in GOGC tuning and GC pauses. The cornerstone is the Go performance guide.

The job

Read 2,000 access log lines of the form 203.0.113.7 GET /library/post-12 200 4521, total the response bytes per path, and render a report of path total lines sorted by path. It is the kind of code that lives in every service: parse text, aggregate, format. There are 40 distinct paths, so the report is 40 lines.

The first draft is what most people write the first time: strings.Split each line, Atoi the size, add to a map, collect and sort the keys, and concatenate fmt.Sprintf results into the output. Three more versions follow, each one a change that has a reason. The program checks that all four return exactly the same report by comparing a SHA-256 of each output with the first draft's. A performance rewrite that is not checked this way is a hope.

main.goGo
package main

import (
	"bytes"
	"crypto/sha256"
	"flag"
	"fmt"
	"sort"
	"strconv"
	"strings"
	"testing"
)

func init() {
	testing.Init()
	flag.Set("test.benchtime", "150ms")
}

// The job: read access log lines, total the bytes per path, render a sorted report.
// Every version below must produce the same report, which main checks with a hash.

func makeLog(n int) []string {
	lines := make([]string, n)
	for i := range lines {
		lines[i] = "203.0.113." + strconv.Itoa(i%250) + " GET /library/post-" + strconv.Itoa(i%40) + " 200 " + strconv.Itoa(1000+i%7000)
	}
	return lines
}

// v1: the first draft, written without thinking about the allocator.
func v1(lines []string) string {
	totals := map[string]int{}
	for _, l := range lines {
		parts := strings.Split(l, " ")
		n, _ := strconv.Atoi(parts[4])
		totals[parts[2]] += n
	}
	var keys []string
	for k := range totals {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	out := ""
	for _, k := range keys {
		out += fmt.Sprintf("%s %d\n", k, totals[k])
	}
	return out
}

// v2: size the map and the key slice up front, and build the output in a Builder.
func v2(lines []string) string {
	totals := make(map[string]int, 64)
	for _, l := range lines {
		parts := strings.Split(l, " ")
		n, _ := strconv.Atoi(parts[4])
		totals[parts[2]] += n
	}
	keys := make([]string, 0, len(totals))
	for k := range totals {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	var sb strings.Builder
	for _, k := range keys {
		sb.WriteString(k)
		sb.WriteByte(' ')
		sb.WriteString(strconv.Itoa(totals[k]))
		sb.WriteByte('\n')
	}
	return sb.String()
}

// v3: stop splitting the line into five strings; slice the two fields needed.
func v3(lines []string) string {
	totals := make(map[string]int, 64)
	for _, l := range lines {
		_, rest, _ := strings.Cut(l, " ")   // address
		_, rest, _ = strings.Cut(rest, " ") // method
		path, rest, _ := strings.Cut(rest, " ")
		_, size, _ := strings.Cut(rest, " ") // status
		n, _ := strconv.Atoi(size)
		totals[path] += n
	}
	keys := make([]string, 0, len(totals))
	for k := range totals {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	var sb strings.Builder
	sb.Grow(len(keys) * 24)
	for _, k := range keys {
		sb.WriteString(k)
		sb.WriteByte(' ')
		sb.WriteString(strconv.Itoa(totals[k]))
		sb.WriteByte('\n')
	}
	return sb.String()
}

// v4: reuse what the caller can keep: one scratch struct holds the map, the keys and the buffer.
type reporter struct {
	totals map[string]int
	keys   []string
	buf    []byte
}

func newReporter() *reporter {
	return &reporter{totals: make(map[string]int, 64), keys: make([]string, 0, 64), buf: make([]byte, 0, 2048)}
}

func (r *reporter) run(lines []string) []byte {
	clear(r.totals)
	r.keys = r.keys[:0]
	r.buf = r.buf[:0]
	for _, l := range lines {
		_, rest, _ := strings.Cut(l, " ")
		_, rest, _ = strings.Cut(rest, " ")
		path, rest, _ := strings.Cut(rest, " ")
		_, size, _ := strings.Cut(rest, " ")
		n, _ := strconv.Atoi(size)
		r.totals[path] += n
	}
	for k := range r.totals {
		r.keys = append(r.keys, k)
	}
	sort.Strings(r.keys)
	for _, k := range r.keys {
		r.buf = append(r.buf, k...)
		r.buf = append(r.buf, ' ')
		r.buf = strconv.AppendInt(r.buf, int64(r.totals[k]), 10)
		r.buf = append(r.buf, '\n')
	}
	return r.buf
}

func median(f func(b *testing.B)) (float64, int64, int64) {
	const runs = 5
	xs := make([]float64, 0, runs)
	var r testing.BenchmarkResult
	for i := 0; i < runs; i++ {
		r = testing.Benchmark(f)
		xs = append(xs, float64(r.T.Nanoseconds())/float64(r.N))
	}
	sort.Float64s(xs)
	return xs[runs/2], r.AllocedBytesPerOp(), r.AllocsPerOp()
}

var sinkS string
var sinkB []byte

func main() {
	lines := makeLog(2000)
	rep := newReporter()
	want := sha256.Sum256([]byte(v1(lines)))
	check := func(name string, got []byte) {
		h := sha256.Sum256(got)
		fmt.Printf("  %-34s output hash %x  matches v1: %v\n", name, h[:6], bytes.Equal(h[:], want[:]))
	}
	fmt.Printf("total bytes per path over %d log lines, then render a sorted report\n", len(lines))
	check("v1 first draft", []byte(v1(lines)))
	check("v2 presize and Builder", []byte(v2(lines)))
	check("v3 Cut instead of Split", []byte(v3(lines)))
	check("v4 reused scratch space", rep.run(lines))
	fmt.Println()
	fmt.Printf("  %-34s %10s %10s %8s\n", "version", "ns/op", "B/op", "allocs")
	for _, c := range []struct {
		name string
		f    func(b *testing.B)
	}{
		{"v1 first draft", func(b *testing.B) {
			b.ReportAllocs()
			for b.Loop() {
				sinkS = v1(lines)
			}
		}},
		{"v2 presize and Builder", func(b *testing.B) {
			b.ReportAllocs()
			for b.Loop() {
				sinkS = v2(lines)
			}
		}},
		{"v3 Cut instead of Split", func(b *testing.B) {
			b.ReportAllocs()
			for b.Loop() {
				sinkS = v3(lines)
			}
		}},
		{"v4 reused scratch space", func(b *testing.B) {
			b.ReportAllocs()
			for b.Loop() {
				sinkB = rep.run(lines)
			}
		}},
	} {
		ns, by, al := median(c.f)
		fmt.Printf("  %-34s %10.0f %10d %8d\n", c.name, ns, by, al)
	}
}
Text
total bytes per path over 2000 log lines, then render a sorted report
  v1 first draft                     output hash dcc250db84ee  matches v1: true
  v2 presize and Builder             output hash dcc250db84ee  matches v1: true
  v3 Cut instead of Split            output hash dcc250db84ee  matches v1: true
  v4 reused scratch space            output hash dcc250db84ee  matches v1: true

  version                                 ns/op       B/op   allocs
  v1 first draft                         187784     187476     2171
  v2 presize and Builder                 177861     167784     2052
  v3 Cut instead of Split                 89190       5496       45
  v4 reused scratch space                 84482          0        0

Results

VersionTimeBytes per runAllocations
v1: first draft187,784 ns187,4762,171
v2: presize and Builder177,861 ns167,7842,052
v3: Cut instead of Split89,190 ns5,49645
v4: reused scratch space84,482 ns00

All four printed the same report hash and matches v1: true.

Step 1: presize and use a Builder (5 percent)

The first draft grew a map from empty, built a key slice by appending, and made the output by out += fmt.Sprintf(...) for each of 40 lines, which allocates a new, longer string each time (quadratic in the output size, though 40 lines is small). Version 2 gives the map and the key slice their final sizes and uses a strings.Builder with WriteString and strconv.Itoa. This removed 119 allocations and 19.7 KB, and bought only 5 percent of the time (177,861 ns against 187,784 ns).

This is the honest result of the standard first three tips. They are correct, they are cheap, and here they were small because the report is 40 lines and the work is elsewhere. Do not stop here and declare victory, and do not skip them: they are the right habits, they cost nothing, and they matter more in code that builds large outputs.

Step 2: stop splitting what you will not use (the big one)

strings.Split(l, " ") allocates a slice of five strings for every line. The strings share the line's memory, but the slice header array is a new heap object per call. With 2,000 lines that is 2,000 allocations, and an alloc_objects profile of v2 puts strings.genSplit at the top, which is the first thing to look for. The job needs only two of the five fields, the path and the size.

Version 3 uses strings.Cut, which returns the part before and the part after the first separator without allocating, and walks past the fields it does not want:

Go
_, rest, _ := strings.Cut(l, " ")   // address
_, rest, _ = strings.Cut(rest, " ") // method
path, rest, _ := strings.Cut(rest, " ")
_, size, _ := strings.Cut(rest, " ") // status
n, _ := strconv.Atoi(size)
totals[path] += n

That took the job to 89,190 ns, 5,496 bytes and 45 allocations: 2.1 times faster than the draft, 97 percent less memory and 98 percent fewer allocations. 2,007 allocations disappeared in one change, which is the pattern of every allocation hunt: one or two sites account for almost all of them. The remaining 45 are the 40 map entries (the keys, which must outlive the line), the key slice, the builder and the result string.

Step 3: reuse what the caller can keep (the last 45)

Version 4 moves the map, the key slice and the output buffer into a struct that the caller creates once. Each run clears the map (clear(m) keeps its table), truncates the slices to length zero, and appends the report with strconv.AppendInt into the reused byte slice. It returns the buffer, which is valid until the next call. The result: 84,482 ns, 0 bytes and 0 allocations per run.

The time gain over v3 is 5 percent, and the allocation gain is all of the remainder. This is the step with a cost in design: the function is now a method on a stateful value, the result is a slice that the next call overwrites, and the value is not safe to share between goroutines. It is worth it in a loop that runs millions of times, and not worth it for a function called once per request. Decide by the profile, not by the table.

What the numbers say

The time did not fall in proportion to the allocations. Allocations fell by 100 percent and time by 55 percent. After v3 the job spent its time scanning bytes, parsing integers and hashing keys for the map. Removing the last 45 allocations saved 5 percent. Allocations are one cost among several, and the profile tells you which is biggest.

Memory fell more than time did. 187 KB of garbage per run became zero. For a service running this 1,000 times a second, that is 187 MB a second less for the collector to find, and it is where the benefit shows in production: lower GC share and a steadier tail latency, which a one function benchmark understates (GC pauses).

Most of the work was one decision. Choosing Cut over Split was worth more than everything else combined. Allocation hunts reward looking at the profile before applying a list of tips.

The method

  1. Measure. Write a benchmark of the real job with b.ReportAllocs(), on realistic input. Note ns/op, B/op and allocs/op (benchmarking).
  2. Profile allocations. go test -bench . -memprofile mem.out -memprofilerate 1, then go tool pprof -sample_index=alloc_objects -top mem.out. Look at the top three sites, not the tail (allocation profiles).
  3. Ask why each one allocates. go build -gcflags=-m shows what escapes to the heap and why. A value escapes when it outlives the call, is stored in an interface, is captured by a closure that escapes, or is too big or of unknown size.
  4. Remove the biggest site with the smallest change that preserves meaning.
  5. Prove behaviour is unchanged. A hash of the output, a test against the old implementation, a fuzz test. This step is the one people skip.
  6. Re-measure, with benchstat if the change is small (benchmarking).
  7. Stop when the profile is flat or when the next change costs readability for a gain you cannot see.

Techniques, in the order of usual payoff

  • Avoid creating the intermediate. Cut instead of Split, bytes.Index and slicing instead of regexp captures, iterating strings.SplitSeq (Go 1.24) instead of building the slice of pieces, and for range over an integer or a function instead of building a list.
  • Presize slices and maps when you know the length (preallocating slices).
  • Build strings with strings.Builder or Append functions, not += in a loop and not fmt.Sprintf for simple pieces (strings.Builder).
  • Keep values off the heap by passing and returning structs by value, avoiding interfaces on hot paths, and avoiding closures that capture large state (escape analysis).
  • Reuse buffers in the caller, in a struct, or with a sync.Pool when the buffers are large (sync.Pool).
  • Avoid conversions between string and []byte where each copies (conversions).
  • Return less. Sending fewer fields, fewer rows or smaller results makes every later step cheaper.

Locking the gain in with a test

An allocation count is a property you can assert, which makes it better protected than a speed, since speed varies with the machine and allocations do not. testing.AllocsPerRun runs a function many times and returns the average number of allocations:

Go
func TestReportAllocations(t *testing.T) {
	lines := makeLog(2000)
	rep := newReporter()
	rep.run(lines) // warm the map and the buffers
	got := testing.AllocsPerRun(100, func() { rep.run(lines) })
	if got > 0 {
		t.Fatalf("report allocates %.0f times per run, want 0", got)
	}
}

Run the function once before measuring so the reused structures have their final size, set the bound to the value you measured (zero here, or a small constant like 50 for v3), and the test fails the day someone swaps Cut back for Split in a refactor. This is the cheapest regression test for performance work, it needs no benchmark machinery, and it does not flake. Keep the bound exact where the count is deterministic and give a little slack where it depends on map growth or the runtime version.

Where allocations come from in everyday code

Most of the sites an allocation profile reports fall into a short list, and knowing it makes the profile faster to read:

  • fmt functions. fmt.Sprintf allocates the result and boxes each argument into an interface, which allocates for values that do not fit in a word. In a hot path, prefer strconv and Append functions.
  • Conversions. string(b) and []byte(s) copy, unless the compiler can prove the result does not escape.
  • Slices that grow. An append loop without a capacity reallocates at 1, 2, 4, 8 and so on, and each growth is an allocation and a copy.
  • Maps that grow, for the same reason, with rehashing on top.
  • Closures that capture variables which then escape to the heap, and function values stored in structs.
  • Interfaces holding non pointer values: converting an int or a struct to any allocates in most cases.
  • Defer in loops and errors.New or fmt.Errorf on the success path: error values built for a case that did not happen.
  • strings.Split, strings.Fields, regexp.FindAllString and friends, which return slices of pieces.
  • Reading input into new buffers: ioutil.ReadAll, bufio.Reader.ReadString, json.Unmarshal into any.
  • Time and formatting: time.Format, time.Now().String() in logs.

Each has a standard replacement in the list above, and each is worth doing only where the profile points at it.

Working in a team

Performance changes that remove allocations tend to make code more specific and less obvious. Three practices keep that manageable. Write the plain version first and keep it, with the fast one beside it and a test that runs both on the same input, as the hash check does here. Comment the reason on the fast path ("avoids a slice per line; see benchmark X") so the next reader does not undo it. And keep the benchmark and the allocation test in the repository, so a claim like "this is 2.1 times faster" can be rerun by anyone.

When not to bother

Do not reduce allocations in code that runs rarely, in code that waits on the network, or in code where the profile does not show allocation or the collector as a cost. A request that takes 20 ms in the database will not notice 100 allocations. Do not trade correctness for it: v4's returned slice is valid only until the next call, which is a footgun when the caller forgets. And do not trade readability for 5 percent, as the v4 step does: take it only where the loop is hot enough to justify a stateful type.

The tension is real. Allocation free code is more constrained and more fragile, and the Go culture has a healthy suspicion of it. The version of this advice that holds up is the one in this article: find the one or two sites that dominate, fix those plainly, and leave the rest.

Checklist

  • Measure allocs/op and B/op, not just ns/op.
  • Profile with alloc_objects and fix the top site first.
  • Presizing and Builders gave 5 percent; removing one Split gave 2.1 times.
  • Reuse state only in hot loops, and document the lifetime of what you return.
  • Verify the output is identical after every change.
  • Stop when the profile is flat.

The tools are documented in the pprof guide and the escape analysis flags.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.