
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
['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:
| Benchmark | Before | After |
|---|---|---|
BenchmarkCreate | 4433 B/op, 285 allocs/op | 0 B/op, 0 allocs/op |
BenchmarkCreateBuildInfo | 216 B/op, 19 allocs/op | 0 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
Grow— one reservation instead of repeated buffer growth.Builder.String()— no extra final copy of the buffer.strconvfor scalars — no format parse, less escape pressure thanSprintf.
What You Actually Gain
| Dimension | Effect |
|---|---|
| Productivity | Lint hot packages (gocritic / modernize stringsbuilder as hints); leave cold code readable—same split Kubernetes documents. |
| Efficiency | Prometheus’s public before/after: same API, ~half the CPU, zero steady-state allocs. |
| Latency | Less heap churn → less GC work on the p99 path when traffic spikes. |
Checklist
- No
+=in hot loops — usestrings.Builder(k8s L50). Growwhen you can estimate size (k8s L344).- Skip
Sprintffor 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
- kubernetes/apimachinery
selector.go#L50—Requirements.String - kubernetes/apimachinery
selector.go#L344—Requirement.String+Grow - kubernetes/kubernetes
38a27df—stringsbuilderas a hint, not a hard ban - prometheus/common#148 — allocation-free
MetricFamilyToText - prometheus/common
expfmt/text_create.go—strconv.AppendInt/AppendFloat - gohugoio/hugo#4412 — investigate
strings.Builder - hugo
shortcode.go#L245—innerString - hugo
page.go#L456—pageState.String