Why Your String Concatenation Is Killing Performance? The Hidden O(N²) Trap in Go
You've probably written code like this at some point:
result := ""
for _, item := range items {
result += item + ","
}
It looks harmless. It works. But at scale, this innocent loop becomes a performance nightmare — consuming 149 MB of memory and taking 481x longer than necessary for just 5,000 items.
Let me show you exactly why this happens and how to fix it.
Why Strings in Go Are Expensive to Concatenate
Go strings are immutable. Once created, a string's contents cannot be changed. This design choice has benefits (thread safety, simpler reasoning), but it comes with a cost.
Every time you write result += item, Go must:
- Allocate a new byte array large enough for both strings
- Copy the contents of
resultinto this new array - Copy the contents of
itemafter it - Create a new string header pointing to this array
- Leave the old string's memory for garbage collection
In a loop of N iterations, you're copying increasingly large strings. The first iteration copies a few bytes. The hundredth iteration copies hundreds of bytes. The thousandth iteration copies thousands of bytes — for every single append.
This is textbook O(N²) behavior hiding in plain sight.
The Benchmark: Proof in Numbers
I ran benchmarks comparing five different approaches to concatenating 1,000 strings, each containing "item-value":
BenchmarkConcatPlus-4 1994038 ns/op 5829032 B/op 1000 allocs/op
BenchmarkConcatBuilder-4 21918 ns/op 46576 B/op 15 allocs/op
BenchmarkConcatBuilderPrealloc-4 9452 ns/op 12288 B/op 1 allocs/op
BenchmarkConcatJoin-4 11389 ns/op 12288 B/op 1 allocs/op
BenchmarkConcatBuffer-4 23838 ns/op 44992 B/op 10 allocs/op
The + operator is 91x slower than the optimal solution and allocates 474x more memory.
But the real story emerges when we scale up:
| Items | Plus Operator | strings.Builder | Speedup |
|---|---|---|---|
| 10 | 587 ns | 243 ns | 2.4x |
| 100 | 29,695 ns | 2,062 ns | 14x |
| 1,000 | 2.2 ms | 22.6 µs | 99x |
| 5,000 | 54 ms | 112 µs | 481x |
At 5,000 items, the plus operator consumes 149 MB of memory while strings.Builder uses just 212 KB. The gap grows quadratically — at 10,000 items, you'd be looking at 200+ milliseconds versus sub-millisecond performance.
The Solutions
Solution 1: strings.Builder (The Go-To Choice)
func concatWithBuilder(items []string) string {
var sb strings.Builder
for _, item := range items {
sb.WriteString(item)
sb.WriteByte(',')
}
return sb.String()
}
strings.Builder maintains an internal byte slice that grows as needed. When the buffer fills up, it doubles in size. This amortization strategy reduces allocations from N to log₂(N).
In our benchmark, 1,000 items resulted in only 15 allocations instead of 1,000.
Solution 2: strings.Builder with Preallocation (Optimal)
func concatWithBuilderPrealloc(items []string) string {
// Calculate exact size needed
totalLen := len(items) // space for separators
for _, item := range items {
totalLen += len(item)
}
var sb strings.Builder
sb.Grow(totalLen)
for _, item := range items {
sb.WriteString(item)
sb.WriteByte(',')
}
return sb.String()
}
When you know (or can estimate) the final size, calling Grow() upfront eliminates all intermediate allocations. This achieved a single allocation in our benchmarks and was the fastest option at 9.4 µs.
Solution 3: strings.Join (When You Have a Slice)
result := strings.Join(items, ",")
If your data is already in a slice and you're joining with a delimiter, strings.Join is hard to beat. It calculates the exact size needed internally and performs a single allocation. At 11.4 µs, it's nearly as fast as the preallocated Builder with zero cognitive overhead.
Solution 4: bytes.Buffer (For Mixed Content)
func concatWithBuffer(items []string) string {
var buf bytes.Buffer
for _, item := range items {
buf.WriteString(item)
buf.WriteByte(',')
}
return buf.String()
}
bytes.Buffer is the older approach, predating strings.Builder. It's still useful when you're mixing []byte and string operations, but strings.Builder is generally preferred for pure string work — it avoids an allocation when calling .String() by directly returning a string backed by its internal buffer.
When the Plus Operator Is Fine
Don't over-optimize. The plus operator is perfectly acceptable when:
- Concatenating 2-3 strings in a single expression:
fullName := first + " " + last - Building short strings outside hot paths
- Code clarity matters more than nanoseconds
The compiler can sometimes optimize simple concatenations. The problem is loops, where the cost compounds.
Quick Decision Guide
| Situation | Use This |
|---|---|
| Loop with known/estimable size | strings.Builder + Grow() |
| Joining a slice with delimiter | strings.Join() |
| Loop with unknown size | strings.Builder |
| Mixing strings and []byte | bytes.Buffer |
| 2-3 strings, once | + operator |
How to Find This in Your Codebase
Run escape analysis and benchmarks to identify problematic patterns:
# See allocations and escape analysis
go build -gcflags="-m" ./...
# Profile memory allocations
go test -bench=. -benchmem -memprofile=mem.out
go tool pprof -http=:8080 mem.out
Look for hot loops containing string concatenation. In large codebases, a simple grep can surface candidates:
grep -rn '+=' --include='*.go' | grep -i string
The Takeaway
String concatenation in loops is one of those patterns that works fine in development with small datasets, passes code review because it looks clean, and then melts your production servers when real traffic hits.
The fix is simple: use strings.Builder. For maximum performance, call Grow() with your expected size. For joining slices, use strings.Join().
Your future self — and your infrastructure budget — will thank you.
Benchmarks run on Intel Xeon @ 2.60GHz, Go 1.23, Linux. Your numbers may vary, but the ratios will be similar.
Member discussion