Skip to content
gohacks.dev
Performance

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

8 min read
Summarize in AI
Flat illustration of a rising bar chart and a gauge.

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.Builder without a size hint.
  • strings.Builder with Grow(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.
  • append to 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).

main.goGo
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
		}
	})
}
Text
-- 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/op
bench.goGo
package 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)

Methodns/opB/opallocs/op
s += piece46,040336,009299
strings.Builder2,1018,44011
strings.Builder with Grow1,4502,3041
strings.Join2,5692,3041
bytes.Buffer2,07610,4328
fmt.Sprintf loop60,763346,097899
[]byte append1,83010,74412

30 pieces (210 bytes)

Methodns/opB/opallocs/op
s += piece8353,46429
strings.Builder2105046
strings.Builder with Grow1462241
strings.Join2602241
bytes.Buffer2266724
fmt.Sprintf loop2,7214,41889
[]byte append2247287

3 pieces (21 bytes)

Methodns/opB/opallocs/op
s += piece47.4402
strings.Builder59.1563
strings.Builder with Grow25.7241
strings.Join31.0241
bytes.Buffer41.8882
fmt.Sprintf loop224.21288
[]byte append70.3804

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 haveUse
2 to 4 values in an expressiona + b + c
A slice of strings and a separatorstrings.Join
Pieces produced in a loop, total known or estimablestrings.Builder with Grow
Pieces produced in a loop, total unknownstrings.Builder (zero value)
A mix of strings, numbers and bytesstrings.Builder with strconv.Append* into a scratch buffer, or fmt.Fprintf(&sb, ...)
Need to read back or use as an io.Writer and io.Readerbytes.Buffer
Anything in a loop with += or Sprintf accumulationstop

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.Builder is 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. Grow with a large estimate keeps the extra capacity alive as long as the string lives, since String() 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 += and Sprintf loops at the back, does not.

Checklist

  • Never accumulate with += or fmt.Sprintf inside a loop of unknown length.
  • Use strings.Builder; add Grow when the size is known.
  • Use strings.Join when 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.

Taggedbenchmark
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.