String Concatenation in Go With Plus Builder and Join
Benchmark seven ways to join strings in Go at 3, 30 and 300 pieces: += took 46 µs and 299 allocations at 300, a sized Builder 1.4 µs and one

Building a string out of pieces is one of the first things every Go program does, and the form people reach for first, s += piece in a loop, is the one that does not scale. Strings are immutable, so each += allocates a new string and copies everything built so far. This article measures seven ways to join 3, 30 and 300 pieces, plus the case the compiler handles specially (joining a fixed number of variables with +), reports time, bytes and allocations, and gives a decision table.
Tested with Go 1.25.6 on Apple M1 Max (10 cores, 32 GB), macOS, median of 5 runs of 200 ms each, 7 byte pieces
It is a companion to the strings.Builder article, preallocating slices and maps and reducing allocations, where a += loop accounted for 36.8 MB of 37 MB in the allocation profile (allocation profiles). The cornerstone is the Go performance guide.
The contenders
s += piece: the naive loop.strings.Builderwithout a size hint.strings.BuilderwithGrow(total): the total is computed first.strings.Join(parts, "").bytes.Buffer.fmt.Sprintf("%s%s", s, piece)in a loop, a pattern that appears in real code.appendto a[]byte, converted once to a string at the end.
The benchmark joins slices of 3, 30 and 300 pieces of 7 bytes each (piece-0 to piece-9).
package main
import (
"bytes"
"fmt"
"strings"
"testing"
)
var out string
func plusEquals(parts []string) string {
s := ""
for _, p := range parts {
s += p
}
return s
}
func builder(parts []string) string {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p)
}
return sb.String()
}
func builderGrow(parts []string) string {
n := 0
for _, p := range parts {
n += len(p)
}
var sb strings.Builder
sb.Grow(n)
for _, p := range parts {
sb.WriteString(p)
}
return sb.String()
}
func join(parts []string) string { return strings.Join(parts, "") }
func buffer(parts []string) string {
var b bytes.Buffer
for _, p := range parts {
b.WriteString(p)
}
return b.String()
}
func sprintf(parts []string) string {
s := ""
for _, p := range parts {
s = fmt.Sprintf("%s%s", s, p)
}
return s
}
func byteAppend(parts []string) string {
var b []byte
for _, p := range parts {
b = append(b, p...)
}
return string(b)
}
func main() {
for _, n := range []int{3, 30, 300} {
parts := make([]string, n)
for i := range parts {
parts[i] = "piece-" + fmt.Sprint(i%10)
}
fmt.Printf("-- %d pieces of 7 bytes\n", n)
for _, c := range []struct {
name string
f func([]string) string
}{{"s += piece", plusEquals}, {"strings.Builder", builder}, {"strings.Builder with Grow", builderGrow}, {"strings.Join", join}, {"bytes.Buffer", buffer}, {"fmt.Sprintf with %s%s", sprintf}, {"[]byte append, then string", byteAppend}} {
f := c.f
bench(c.name, func(b *testing.B) {
for b.Loop() {
out = f(parts)
}
})
}
}
// A fixed number of operands is a different case: the compiler concatenates them in one step.
a, b, c := "alpha", "beta", "gamma"
fmt.Println("-- three variables joined with +")
bench("a + b + c", func(t *testing.B) {
for t.Loop() {
out = a + b + c
}
})
}-- 3 pieces of 7 bytes
s += piece 47.4 ns/op 40 B/op 2 allocs/op
strings.Builder 59.1 ns/op 56 B/op 3 allocs/op
strings.Builder with Grow 25.7 ns/op 24 B/op 1 allocs/op
strings.Join 31.0 ns/op 24 B/op 1 allocs/op
bytes.Buffer 41.8 ns/op 88 B/op 2 allocs/op
fmt.Sprintf with %s%s 224.2 ns/op 128 B/op 8 allocs/op
[]byte append, then string 70.3 ns/op 80 B/op 4 allocs/op
-- 30 pieces of 7 bytes
s += piece 835.3 ns/op 3464 B/op 29 allocs/op
strings.Builder 209.6 ns/op 504 B/op 6 allocs/op
strings.Builder with Grow 146.0 ns/op 224 B/op 1 allocs/op
strings.Join 259.8 ns/op 224 B/op 1 allocs/op
bytes.Buffer 225.5 ns/op 672 B/op 4 allocs/op
fmt.Sprintf with %s%s 2721.3 ns/op 4418 B/op 89 allocs/op
[]byte append, then string 223.6 ns/op 728 B/op 7 allocs/op
-- 300 pieces of 7 bytes
s += piece 46040.3 ns/op 336009 B/op 299 allocs/op
strings.Builder 2100.7 ns/op 8440 B/op 11 allocs/op
strings.Builder with Grow 1449.9 ns/op 2304 B/op 1 allocs/op
strings.Join 2569.1 ns/op 2304 B/op 1 allocs/op
bytes.Buffer 2075.8 ns/op 10432 B/op 8 allocs/op
fmt.Sprintf with %s%s 60763.2 ns/op 346097 B/op 899 allocs/op
[]byte append, then string 1829.5 ns/op 10744 B/op 12 allocs/op
-- three variables joined with +
a + b + c 23.8 ns/op 16 B/op 1 allocs/oppackage main
import (
"flag"
"fmt"
"sort"
"testing"
)
func init() {
testing.Init()
flag.Set("test.benchtime", "200ms")
}
// bench runs f five times and prints the median, which damps the noise of a laptop.
func bench(name string, f func(b *testing.B)) {
const runs = 5
ns := make([]float64, 0, runs)
bs := make([]int64, 0, runs)
as := make([]int64, 0, runs)
for i := 0; i < runs; i++ {
r := testing.Benchmark(f)
ns = append(ns, float64(r.T.Nanoseconds())/float64(r.N))
bs = append(bs, r.AllocedBytesPerOp())
as = append(as, r.AllocsPerOp())
}
sort.Float64s(ns)
sort.Slice(bs, func(i, j int) bool { return bs[i] < bs[j] })
sort.Slice(as, func(i, j int) bool { return as[i] < as[j] })
fmt.Printf("%-38s %12.1f ns/op %9d B/op %6d allocs/op\n", name, ns[runs/2], bs[runs/2], as[runs/2])
}Results
300 pieces (2100 bytes)
| Method | ns/op | B/op | allocs/op |
|---|---|---|---|
s += piece | 46,040 | 336,009 | 299 |
strings.Builder | 2,101 | 8,440 | 11 |
strings.Builder with Grow | 1,450 | 2,304 | 1 |
strings.Join | 2,569 | 2,304 | 1 |
bytes.Buffer | 2,076 | 10,432 | 8 |
fmt.Sprintf loop | 60,763 | 346,097 | 899 |
[]byte append | 1,830 | 10,744 | 12 |
30 pieces (210 bytes)
| Method | ns/op | B/op | allocs/op |
|---|---|---|---|
s += piece | 835 | 3,464 | 29 |
strings.Builder | 210 | 504 | 6 |
strings.Builder with Grow | 146 | 224 | 1 |
strings.Join | 260 | 224 | 1 |
bytes.Buffer | 226 | 672 | 4 |
fmt.Sprintf loop | 2,721 | 4,418 | 89 |
[]byte append | 224 | 728 | 7 |
3 pieces (21 bytes)
| Method | ns/op | B/op | allocs/op |
|---|---|---|---|
s += piece | 47.4 | 40 | 2 |
strings.Builder | 59.1 | 56 | 3 |
strings.Builder with Grow | 25.7 | 24 | 1 |
strings.Join | 31.0 | 24 | 1 |
bytes.Buffer | 41.8 | 88 | 2 |
fmt.Sprintf loop | 224.2 | 128 | 8 |
[]byte append | 70.3 | 80 | 4 |
Reading the tables
+= is quadratic. Going from 30 to 300 pieces multiplied the work by ten, and the time by 55 (835 ns to 46,040 ns), with allocated bytes growing from 3.5 KB to 336 KB. The loop allocated 159 times the size of the string it produced. The cost per piece grows with the string, which is the definition of quadratic. At 3 pieces it is not a problem (47 ns, two allocations, slightly faster than an unsized Builder), which is why it survives in code reviews: the small case works and the large case, arriving later, does not.
A sized Builder is the floor. strings.Builder with Grow allocates once and copies each piece once: 1,450 ns for 300 pieces, 4.8 ns per piece. It is the fastest in every size here, because it makes exactly one allocation. The cost is computing the total length first (a loop over the pieces), which you can often do cheaply, or estimate.
An unsized Builder is close. Without Grow the buffer starts small and doubles, so 300 pieces needed 11 allocations and 8 KB for a 2 KB result, and ran 45 percent slower than the sized one. For most code that is entirely acceptable, and the zero value Builder is the right default when the total is not known.
strings.Join is not the fastest. It allocates once, like the sized Builder, but it was slower in this run: 2,569 ns against 1,450 ns at 300 pieces and 260 against 146 at 30. Join handles a separator and computes the length itself, so there is overhead a hand written loop with no separator avoids. When you have a slice already and a separator, Join is still the clearest code, and 1 allocation is the right count. When you are producing pieces in a loop, the Builder avoids building the slice at all.
bytes.Buffer and []byte append land near the unsized Builder: 2,076 and 1,830 ns. The []byte path pays for a final copy when converting to string. A Builder returns its internal buffer as a string without copying. bytes.Buffer makes sense when you need its other methods (reading, ReadFrom); for building a string, the Builder is the purpose built tool.
fmt.Sprintf in a loop is the worst. 60,763 ns and 899 allocations at 300 pieces: it has the quadratic copying of +=, plus three allocations per call for boxing and formatting. If you find s = fmt.Sprintf("%s%s", s, x), replace it.
The case the compiler handles
a + b + c with a fixed number of operands is not a loop. The compiler turns it into a single call that measures all the operands, allocates once and copies. It took 23.8 ns and 1 allocation for three variables in the lab. A chain of + in one expression is fine; it is the += inside a loop that grows without bound. For a handful of pieces, readability wins and + is right; fmt.Sprintf is fine for one off messages where clarity matters more than 200 ns.
Decision table
| You have | Use |
|---|---|
| 2 to 4 values in an expression | a + b + c |
| A slice of strings and a separator | strings.Join |
| Pieces produced in a loop, total known or estimable | strings.Builder with Grow |
| Pieces produced in a loop, total unknown | strings.Builder (zero value) |
| A mix of strings, numbers and bytes | strings.Builder with strconv.Append* into a scratch buffer, or fmt.Fprintf(&sb, ...) |
Need to read back or use as an io.Writer and io.Reader | bytes.Buffer |
Anything in a loop with += or Sprintf accumulation | stop |
For formatted output into a builder, fmt.Fprintf(&sb, "%d,%s", i, name) is convenient and allocates for boxing the arguments; sb.WriteString(strconv.Itoa(i)) avoids that. The allocation profile article shows the effect on a record formatter: 3,478 allocations falling to 2.
Pitfalls
- Concurrency. A
strings.Builderis not safe for concurrent use. - Copying a Builder. A non zero Builder must not be copied by value; it panics on the next write (the Builder article).
- Memory held by the result.
Growwith a large estimate keeps the extra capacity alive as long as the string lives, sinceString()returns the buffer without trimming. If you over estimate by a lot and keep the result, clone it:strings.Clone(sb.String()). - Benchmarks. These numbers are for 7 byte pieces on one machine. Shorter or longer pieces move the crossovers. The ranking, with a sized Builder at the front and
+=andSprintfloops at the back, does not.
Checklist
- Never accumulate with
+=orfmt.Sprintfinside a loop of unknown length. - Use
strings.Builder; addGrowwhen the size is known. - Use
strings.Joinwhen you already have a slice and a separator. - Use plain
+for a fixed handful of operands. - Benchmark with
-benchmem: the allocation count tells the story.
The types are documented in the strings package and the benchmarking helper in the testing package.


