Hidden Allocations in Go Hot Paths


July 12, 2026

6 min read

gogolangperformancememoryopen-sourcestrconv

Hidden Allocations in Go Hot Paths

Go makes string work look cheap. It isn’t—not in a loop that runs a million times.

In hot paths, result += chunk and fmt.Sprintf("%d", n) quietly allocate, copy, and feed the GC. Large open-source projects do not ban those APIs. They stop using them where frequency hurts, and they leave readable += alone everywhere else.

This post explains the cost, shows the receipts (with line numbers), then gives you a pattern you can paste into your own code.

Why Go Strings Allocate

A Go string is a two-word header: a pointer to an immutable byte array, plus a length.

Go String Header

Pointer (Ptr)
Length (Len)

['H','e','l','l','o'] — immutable byte array

You never mutate a string in place. Concatenation allocates a new backing array, copies the old bytes, appends the new ones, and returns a new header. Once in a while, that is fine. In a scrape handler, a label formatter, or a shortcode renderer, it is a tax on every request.

Two Hot-Path Anti-Patterns

Loop +=

func BadConcatenate(items []string) string {
    var result string
    for _, item := range items {
        result += item // new allocation each iteration
    }
    return result
}

With 1,000 items you do not build one string—you build ~1,000, keep one, and leave 999 for the GC. That is the classic trap.

Casual fmt.Sprintf for scalars

userID := fmt.Sprintf("user_id_%d", id)

Sprintf is a general formatter: it parses the format string and pays for interface/reflection machinery. For "%d" / "%s" in a tight loop, that overhead is unnecessary. Prefer strconv (or Append*) when the shape is fixed.

Evidence From the Wild

None of these projects ban += or Sprintf globally. They reserve stricter style for code that runs constantly—and Kubernetes even documents that choice in its linter config.

Kubernetes — builders on label selectors

In pkg/labels/selector.go, selectors are stringified for scheduling, listing, and logging. The file treats that as a hot path.

Line 50 — join requirements without loop +=:

func (r Requirements) String() string {
    var sb strings.Builder

    for i, requirement := range r {
        if i > 0 {
            sb.WriteString(", ")
        }
        sb.WriteString(requirement.String())
    }

    return sb.String()
}

Line 344 — estimate size, Grow once, then write:

func (r *Requirement) String() string {
    var sb strings.Builder
    sb.Grow(
        len(r.key) +
            len(r.operator) + 2 + // worst case: " in " / " notin "
            5*len(r.strValues)) // heuristic: ~5 chars per value
    if r.operator == selection.DoesNotExist {
        sb.WriteString("!")
    }
    sb.WriteString(r.key)
    // ... WriteString for operators, values, parens ...
    return sb.String()
}

The same file grows before each key=value in ValidatedSetSelector.String(), and keeps shared Everything / Nothing selectors as package-level immutables—“Sharing this saves 1 alloc per use.”

Kubernetes also has a modernize hint to replace += with strings.Builder, and disables it as a hard rule because outside hot paths += can be more readable (commit 38a27df). The lesson is judgment, not dogma.

Prometheus — zero-alloc metric text

When exposition text allocates on every scrape, you feel it. prometheus/common#148 made MetricFamilyToText allocation-free in the steady state:

BenchmarkBeforeAfter
BenchmarkCreate4433 B/op, 285 allocs/op0 B/op, 0 allocs/op
BenchmarkCreateBuildInfo216 B/op, 19 allocs/op0 B/op, 0 allocs/op

Roughly 2× faster, and the alloc column goes to zero. The mechanism is in expfmt/text_create.go: pool a buffer, append with strconv, write, put it back.

// Equivalent to fmt.Fprint(int64), without the fmt allocations.
func writeInt(w enhancedWriter, i int64) (int, error) {
    bp := numBufPool.Get().(*[]byte)
    *bp = strconv.AppendInt((*bp)[:0], i, 10)
    written, err := w.Write(*bp)
    numBufPool.Put(bp)
    return written, err
}

That is the production answer to “should I Sprintf("%d", n) here?”—not in this loop.

Hugo — builders where content is joined

Hugo treated strings.Builder as an explicit performance investigation (#4412). Today the pattern is in the tree.

shortcode.go L245 — assemble inner shortcode text:

func (s shortcode) innerString() string {
    var sb strings.Builder

    for _, inner := range s.inner {
        sb.WriteString(inner.(string))
    }

    return sb.String()
}

page.go L456 — stable page identity for logs and tests:

func (ps *pageState) String() string {
    var sb strings.Builder
    if ps.File() != nil {
        // Forward slashes even on Windows → stable tests / log positions.
        sb.WriteString(filepath.ToSlash(ps.File().Filename()))
        if ps.File().IsContentAdapter() {
            sb.WriteString(":")
            sb.WriteString(ps.Path())
        }
    } else {
        sb.WriteString(ps.Path())
    }
    return sb.String()
}

The Pattern to Copy

Same ideas, portable:

func GoodConcatenate(items []string) string {
    if len(items) == 0 {
        return ""
    }

    totalLen := 0
    for _, item := range items {
        totalLen += len(item)
    }

    var sb strings.Builder
    sb.Grow(totalLen)
    for _, item := range items {
        sb.WriteString(item)
    }
    return sb.String()
}
userID := "user_id_" + strconv.Itoa(id)

In Prometheus-style loops (many numbers, same shape), prefer strconv.AppendInt into a reused []byte—that is what zeroed the alloc column above.

Why it works

  1. Grow — one reservation instead of repeated buffer growth.
  2. Builder.String() — no extra final copy of the buffer.
  3. strconv for scalars — no format parse, less escape pressure than Sprintf.

What You Actually Gain

DimensionEffect
ProductivityLint hot packages (gocritic / modernize stringsbuilder as hints); leave cold code readable—same split Kubernetes documents.
EfficiencyPrometheus’s public before/after: same API, ~half the CPU, zero steady-state allocs.
LatencyLess heap churn → less GC work on the p99 path when traffic spikes.

Checklist

  • No += in hot loops — use strings.Builder (k8s L50).
  • Grow when you can estimate size (k8s L344).
  • Skip Sprintf for simple scalars in tight loops — strconv / Append* (Prometheus expfmt).
  • Optimize where frequency justifies it; do not “ban” readability everywhere.
  • When you cite a big project, link the file or PR.

References

  1. kubernetes/apimachinery selector.go#L50Requirements.String
  2. kubernetes/apimachinery selector.go#L344Requirement.String + Grow
  3. kubernetes/kubernetes 38a27dfstringsbuilder as a hint, not a hard ban
  4. prometheus/common#148 — allocation-free MetricFamilyToText
  5. prometheus/common expfmt/text_create.gostrconv.AppendInt / AppendFloat
  6. gohugoio/hugo#4412 — investigate strings.Builder
  7. hugo shortcode.go#L245innerString
  8. hugo page.go#L456pageState.String
Share: