Making Go Fast Enough

Measure before changing anything, then preallocate slices, reuse buffers with sync.Pool, and stop there.

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

Fast enough is a target, not a feeling. Decide what the service must do, measure what it does, and change code only where the measurement says to. Benchmarks give you numbers for one function; pprof tells you which function to look at. Without both, you are guessing, and most guesses about Go performance are wrong.

The order to work in

  1. Fix the algorithm or the query. One missing database index or one request inside a loop outweighs every trick below.
  2. Preallocate. When you know how many items you will append, write make([]T, 0, n). Growing a slice copies the whole backing array each time it runs out of room.
  3. Reuse buffers. A sync.Pool lends out values and takes them back, so a handler that needs a scratch buffer per request does not allocate one per request.
  4. Stop. Measure again. If the number is good enough, the next change only adds risk.

How sync.Pool behaves

Get returns some value previously put back, or calls your New function if the pool is empty. It is safe to use from many goroutines at once. The garbage collector may empty the pool at any time, so a pool is never a cache: you cannot rely on anything being in it.

What trips people up

A pooled value arrives with whatever the last user left in it, so reset it before use and copy out anything you want to keep before putting it back.

Example

Example · go
package main

import (
	"bytes"
	"fmt"
	"strconv"
	"sync"
)

// A pool of scratch buffers. New is called only when the pool is empty.
var bufs = sync.Pool{New: func() any { return new(bytes.Buffer) }}

// render borrows a buffer, uses it, and returns it for the next caller.
func render(ids []int) string {
	buf := bufs.Get().(*bytes.Buffer) // Get returns any, so assert the type
	buf.Reset()                       // it still holds the last caller's bytes
	defer bufs.Put(buf)
	for _, id := range ids {
		buf.WriteString("id=")
		buf.WriteString(strconv.Itoa(id))
		buf.WriteByte('\n')
	}
	return buf.String() // String copies, so the result survives the Put
}

// double preallocates: one allocation, no copying as it grows.
func double(in []int) []int {
	out := make([]int, 0, len(in)) // length 0, capacity len(in)
	for _, v := range in {
		out = append(out, v*2)
	}
	return out
}

func main() {
	fmt.Print(render([]int{1, 2}))
	fmt.Println(double([]int{1, 2, 3}))
}

// Output:
// id=1
// id=2
// [2 4 6]

When to use it

  • Cut allocations in a JSON endpoint that is spending more time in garbage collection than in your code.
  • Prove with a benchmark that a rewrite is faster before merging it.
  • Preallocate a result slice when a database query already told you the row count.
  • Replace string concatenation in an export loop that slows down as the export grows.

More examples

A benchmark that proves the preallocation helped

Two benchmarks of the same work, differing in one line, are how you justify a change to anyone including yourself. allocs/op is the number to watch here: the growing version reallocates every time it runs out of room, which for 1000 ints means twelve allocations (capacity doubles up to 256 elements, then grows by about a quarter each time: 1, 2, 4, ... 256, 512, 848, 1280). Write the pair before the optimisation, keep it after, and it also guards against someone undoing the change later.

Example · go
package render

import "testing"

var input = make([]int, 1000)

func BenchmarkGrow(b *testing.B) {
	b.ReportAllocs() // print allocations per operation, not just time
	// for b.Loop() was added in Go 1.24. The longhand, which still works and
	// is what you will see in older code, is: for i := 0; i < b.N; i++ { ... }
	for b.Loop() {
		var out []int // nil slice: append reallocates as it grows
		for _, v := range input {
			out = append(out, v*2)
		}
	}
}

func BenchmarkPrealloc(b *testing.B) {
	b.ReportAllocs()
	for b.Loop() {
		out := make([]int, 0, len(input))
		for _, v := range input {
			out = append(out, v*2)
		}
	}
}

// go test -bench=. -benchmem
// BenchmarkGrow-8      ... 12 allocs/op
// BenchmarkPrealloc-8  ...  1 allocs/op

Building a string without concatenating in a loop

String concatenation in a loop is the most common accidental hotspot in Go code, because the line looks harmless. strings.Builder with Grow turns it into one allocation. Reach for it when building CSV rows, SQL fragments or log lines; if you are only joining with a separator, strings.Join is shorter and does the same work.

Example · go
// Each += on a string allocates a new string and copies both halves,
// so this is quadratic in the number of items.
func slowJoin(parts []string) string {
	s := ""
	for _, p := range parts {
		s += p + ","
	}
	return s
}

// A Builder writes into one growing byte buffer and hands over the string
// at the end. Grow reserves the room up front when the size is known.
func fastJoin(parts []string) string {
	var b strings.Builder
	n := 0
	for _, p := range parts {
		n += len(p) + 1
	}
	b.Grow(n)
	for _, p := range parts {
		b.WriteString(p)
		b.WriteByte(',')
	}
	return b.String()
}

// When the separator is all you need, strings.Join already does this.

Pooling a per-request encoder on a real handler

This is the case a pool is actually for: a short-lived buffer needed once per request, on a path serving thousands of requests a second. Buffering also lets you set Content-Length and report an encoding failure properly. Do not pool things with long or uneven lifetimes, and never pool a value whose pointer escapes to another goroutine.

Example · go
type response struct {
	OK   bool   `json:"ok"`
	Item string `json:"item"`
}

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

func handle(w http.ResponseWriter, r *http.Request) {
	buf := bufs.Get().(*bytes.Buffer)
	buf.Reset()
	defer bufs.Put(buf)

	// Encode into the buffer first, so a marshalling error does not arrive
	// after a 200 status has already been written.
	if err := json.NewEncoder(buf).Encode(response{OK: true, Item: "shoe"}); err != nil {
		http.Error(w, "encode failed", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
	_, _ = buf.WriteTo(w) // copies the bytes out before the deferred Put
}

Discussion

  • Be the first to comment on this lesson.