Review & Verify Updated 2026-09 10 min read View as Markdown

The performance traps in generated Go

Go's performance problems are almost all allocations and unbounded concurrency — and the toolchain will tell you about both if you ask it.

Generated Go is usually clear and usually allocates more than it needs to. The good news is that Go has the best built-in performance tooling of any language here: benchmarks, allocation counts and profiling all ship with the compiler.

Allocation#

1. Slices and maps without capacity#

The most common allocation waste in generated Go.

go
var out []string                       // nil; grows by repeated reallocation
for _, u := range users {
    out = append(out, u.Name)
}

Each growth allocates a new backing array and copies. When you know the size, say so:

go
out := make([]string, 0, len(users))    // one allocation
for _, u := range users {
    out = append(out, u.Name)
}

Same for maps: make(map[string]int, len(items)). This is the single easiest win in the language and generated code omits it nearly every time.

2. String concatenation in a loop#

go
var s string
for _, part := range parts {
    s += part                          // a new string every iteration: quadratic
}

Strings are immutable, so += allocates and copies the whole accumulated string each time.

go
var b strings.Builder
b.Grow(estimatedSize)                  // optional but free
for _, part := range parts {
    b.WriteString(part)
}
s := b.String()

Or strings.Join(parts, "") when you already have the slice. Look for: += on a string inside any loop.

3. Unnecessary conversions between string and []byte#

go
for _, line := range lines {
    if bytes.Contains([]byte(line), needle) {   // allocates a copy per iteration

[]byte(s) and string(b) both copy. Pick one representation and stay in it. The compiler elides the conversion in a few specific cases (map lookups, range, comparisons) but not in general.

4. Interface boxing in hot paths#

Passing a small value as any allocates, because the value must escape to the heap to be stored in the interface. fmt.Sprintf in a hot loop is the usual culprit — it boxes every argument and does reflection.

go
// hot path: use strconv, not fmt
s := strconv.Itoa(n)                   // no allocation for small ints
s := fmt.Sprintf("%d", n)              // boxes, reflects, allocates

5. Escape analysis surprises#

shell
go build -gcflags='-m' ./... 2>&1 | grep 'escapes to heap'

Returning a pointer to a local, capturing a variable in a closure that outlives the function, or storing something in an interface all force a heap allocation. The compiler will tell you exactly which and why. Worth running once on a hot package — it is often surprising.

Concurrency#

6. Unbounded goroutines#

go
for _, url := range urls {             // 50,000 urls, 50,000 goroutines
    go fetch(url)
}

Goroutines are cheap, not free, and the resource they exhaust is usually the thing at the other end — file descriptors, connections, a rate-limited API. Generated fan-out code omits the limit almost always.

go
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(runtime.GOMAXPROCS(0) * 4)  // or a number matched to the downstream
for _, url := range urls {
    g.Go(func() error { return fetch(ctx, url) })
}
err := g.Wait()

One line — SetLimit — and it is the difference between a working batch job and a 429 storm.

7. Mutex contention where an atomic would do#

sync.Mutex around a counter increment. atomic.Int64 is dramatically cheaper under contention and reads more clearly.

For read-heavy shared state, sync.RWMutex or a copy-on-write pointer swap via atomic.Pointer both beat a plain mutex — but measure, because RWMutex has more overhead than Mutex at low contention.

8. Channels used as a queue for cheap work#

A channel per item adds synchronisation cost that can exceed the work being done. For fine-grained parallel work over a slice, partitioning the slice across N goroutines with no channel at all is usually much faster.

9. GOMAXPROCS in a container#

Go reads the number of host CPUs, not your container's CPU limit. On a 64-core node with a 2-CPU limit, the runtime creates 64 threads and then gets throttled, which shows up as terrible tail latency for no obvious reason.

Go 1.25 made the runtime container-aware, but if you are on anything older, set it explicitly or use automaxprocs. This is one of the highest-impact production Go issues and it has nothing to do with your code.

I/O and data#

10. N+1 queries#

go
for _, o := range orders {
    items, _ := db.ItemsForOrder(ctx, o.ID)    // one round trip per order
    o.Items = items
}

Correct, passes tests with three orders, 101 round trips in production. Fetch with WHERE order_id = ANY($1) and group in memory.

Catch it with: a test asserting query count. It is the only reliable defence, and it is the highest-value performance test in a service.

11. Unbuffered file and network I/O#

os.File.Read in a small loop, or writing per line without bufio.Writer. Generated I/O code is correct and unbuffered.

go
w := bufio.NewWriterSize(f, 64<<10)
defer w.Flush()                        // and check the error — see below

12. defer inside a loop#

go
for _, path := range paths {
    f, _ := os.Open(path)
    defer f.Close()                    // accumulates until the FUNCTION returns
}

Not a performance problem so much as a descriptor leak that presents as one. Move the body into its own function, or close explicitly.

13. JSON in a hot path#

encoding/json uses reflection. For a hot serialisation path it is often the bottleneck, and the fixes in order of effort are: reuse json.Decoder on a stream rather than Unmarshal on a full buffer, avoid map[string]any in favour of structs, and only then consider a code-generating alternative.

Benchmarks worth writing#

go
func BenchmarkFormatRows(b *testing.B) {
    rows := makeRows(1000)
    b.ReportAllocs()
    b.ResetTimer()
    for b.Loop() {                     // Go 1.24+; prevents the compiler eliding the work
        _ = FormatRows(rows)
    }
}
shell
go test -bench=. -benchmem -count=10 ./pkg > new.txt
benchstat old.txt new.txt              # is the difference real, or noise?

benchstat is the part people skip. A single benchmark run is noise; ten runs plus benchstat tells you whether a change is statistically real, which stops a lot of pointless optimisation.

The reviewer's shortcut

Scan the diff for loops and for go statements. For each loop: does it append without preallocating, concatenate a string, or make a database call? For each go statement: is the concurrency bounded? Those two questions catch items 1, 2, 6 and 10 — most of the real cost on this page.

Common questions#

Should I preallocate everywhere?#

Wherever you know the size, yes — make([]T, 0, n) is free to write and removes repeated growth. Where you genuinely do not know, append on a nil slice is fine and its growth strategy is reasonable. Do not invent a capacity you cannot justify.

Is sync.Pool worth using?#

Only for genuinely hot paths with large, reusable buffers, and only after profiling shows allocation is the bottleneck. It adds complexity and correctness risk (objects must be reset), and misused it can be slower than allocating. bytes.Buffer reuse in an HTTP handler is the classic legitimate case.

Do goroutines have a meaningful cost?#

A few kilobytes of stack each, so a hundred thousand is fine in itself. What is not fine is a hundred thousand simultaneous requests to something with a connection limit. Bound the concurrency to match the constrained resource, not the goroutine cost.

Why is my service slow in Kubernetes but fast locally?#

Check GOMAXPROCS against your CPU limit first. A runtime that thinks it has 64 cores inside a 2-CPU container creates far too many OS threads and gets CFS-throttled, which looks like mysterious tail latency and is not visible in any profile of your own code.

Get the Go agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Go. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.