The strings Builder in Go and Where Its Limits Are
How strings.Builder grows, what Grow, Reset and String do, the copy panic, and why Reset does not let you reuse the buffer, with measured allocation counts

strings.Builder is the standard answer to "build a string in a loop", and it deserves a closer look than "use it, it is fast". It is a thin wrapper around a byte slice with a few rules, and the rules decide whether it is as fast as it can be: how it grows, when Grow helps, what String and Reset do, and a self-check that panics when you misuse it. This article runs each of those on Go 1.25 and prints the capacities, the panic and the allocation counts, so the details come from the machine and not from memory.
Tested with Go 1.25.6 on Apple M1 Max (10 cores, 32 GB), macOS, median of 5 runs of 200 ms each
It extends string concatenation benchmarks, where the Builder is compared with the alternatives, and belongs with preallocating slices. Its allocation behaviour is the subject of reducing allocations. The cornerstone is the Go performance guide.
What it is
A strings.Builder holds a []byte and the address of itself (for the copy check). WriteString, Write, WriteByte and WriteRune append to the slice. String() converts the slice to a string without copying, using the same trick as unsafe.String, which is safe because the Builder only ever appends: bytes already written are never modified. Len and Cap report the slice's length and capacity. It implements io.Writer, so fmt.Fprintf(&sb, ...) works.
The lab
package main
import (
"bytes"
"fmt"
"strings"
"testing"
)
var sink string
func main() {
// 1. The zero value is ready to use, and the capacity grows as you write.
var sb strings.Builder
fmt.Println("zero value: len", sb.Len(), "cap", sb.Cap())
last := -1
var caps []int
for i := 0; i < 2000; i++ {
sb.WriteByte('x')
if sb.Cap() != last {
last = sb.Cap()
caps = append(caps, last)
}
}
fmt.Println("capacity after each reallocation while writing 2000 bytes:", caps)
// 2. Grow reserves room in one step.
var g strings.Builder
g.Grow(2000)
fmt.Println("after Grow(2000): len", g.Len(), "cap", g.Cap())
// 3. String does not copy, and later writes do not change a string already returned.
var w strings.Builder
w.WriteString("hello")
s1 := w.String()
w.WriteString(" world")
s2 := w.String()
fmt.Printf("s1 = %q, s2 = %q\n", s1, s2)
// 4. Reset drops the buffer, so a reused Builder starts small again.
w.Reset()
fmt.Println("after Reset: len", w.Len(), "cap", w.Cap())
// 5. Copying a non zero Builder is a bug the type detects.
func() {
defer func() { fmt.Println("copy of a used Builder:", recover()) }()
var a strings.Builder
a.WriteString("x")
b := a
b.WriteString("y")
}()
// 6. Allocation counts for the common operations.
check := func(name string, f func(*testing.B)) { bench(name, f) }
check("WriteString x10 (zero value)", func(b *testing.B) {
for b.Loop() {
var sb strings.Builder
for i := 0; i < 10; i++ {
sb.WriteString("abcdefgh")
}
sink = sb.String()
}
})
check("WriteString x10 after Grow(80)", func(b *testing.B) {
for b.Loop() {
var sb strings.Builder
sb.Grow(80)
for i := 0; i < 10; i++ {
sb.WriteString("abcdefgh")
}
sink = sb.String()
}
})
check("WriteByte x80", func(b *testing.B) {
for b.Loop() {
var sb strings.Builder
sb.Grow(80)
for i := 0; i < 80; i++ {
sb.WriteByte('a')
}
sink = sb.String()
}
})
check("WriteRune x40 (2 byte runes)", func(b *testing.B) {
for b.Loop() {
var sb strings.Builder
sb.Grow(80)
for i := 0; i < 40; i++ {
sb.WriteRune('é')
}
sink = sb.String()
}
})
check("fmt.Fprintf(&sb, ...) x10", func(b *testing.B) {
for b.Loop() {
var sb strings.Builder
sb.Grow(120)
for i := 0; i < 10; i++ {
fmt.Fprintf(&sb, "item-%d;", i)
}
sink = sb.String()
}
})
check("Reset and reuse one Builder x10", func(b *testing.B) {
var sb strings.Builder
for b.Loop() {
sb.Reset()
for i := 0; i < 10; i++ {
sb.WriteString("abcdefgh")
}
sink = sb.String()
}
})
check("bytes.Buffer, Reset and reuse x10", func(b *testing.B) {
var buf bytes.Buffer
for b.Loop() {
buf.Reset()
for i := 0; i < 10; i++ {
buf.WriteString("abcdefgh")
}
sink = buf.String()
}
})
}zero value: len 0 cap 0
capacity after each reallocation while writing 2000 bytes: [8 16 32 64 128 256 512 896 1408 2048]
after Grow(2000): len 0 cap 2048
s1 = "hello", s2 = "hello world"
after Reset: len 0 cap 0
copy of a used Builder: strings: illegal use of non-zero Builder copied by value
WriteString x10 (zero value) 124.0 ns/op 248 B/op 5 allocs/op
WriteString x10 after Grow(80) 50.1 ns/op 80 B/op 1 allocs/op
WriteByte x80 194.5 ns/op 80 B/op 1 allocs/op
WriteRune x40 (2 byte runes) 143.5 ns/op 80 B/op 1 allocs/op
fmt.Fprintf(&sb, ...) x10 482.2 ns/op 160 B/op 2 allocs/op
Reset and reuse one Builder x10 123.6 ns/op 248 B/op 5 allocs/op
bytes.Buffer, Reset and reuse x10 44.9 ns/op 80 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])
}Growth
The zero value works: var sb strings.Builder has length 0 and capacity 0. The program writes 2000 bytes one at a time and records the capacity each time it changes:
[8 16 32 64 128 256 512 896 1408 2048]
It is the same growth as a slice: doubling for small sizes, then roughly 1.25 times with rounding to allocator size classes (896 and 1408 are size classes). Writing 2000 bytes by single appends caused 10 allocations and copies. Grow(n) guarantees room for n more bytes without further allocation: Grow(2000) gave a capacity of 2048 (rounded up to a size class) in one step.
Grow is the single most useful method on the type. Ten WriteString("abcdefgh") calls into a zero value took 124.0 ns, 248 bytes and 5 allocations, and after Grow(80) they took 50.1 ns, 80 bytes and 1 allocation, 2.5 times faster. If you can compute or estimate the final size, call Grow first. If you cannot, do not guess wildly: an over large Grow keeps the extra capacity alive as long as the resulting string lives.
String is free, and safe
String() returned "hello"; the program then wrote " world" and called String() again, getting "hello world". The first string was still "hello". No copy was made and nothing was corrupted, because appending never changes bytes below the current length, and a string header only points at those bytes. If the append reallocates, the new bytes go to a new array and the old string keeps the old one.
This has a cost to remember: the string you get shares the Builder's buffer, so it keeps the whole buffer, including unused capacity, alive. A 20 byte result from a Builder that was grown to 1 MB holds 1 MB. strings.Clone(sb.String()) makes a right sized copy when that matters.
Reset drops the buffer
Reset is where intuition and behaviour part. For bytes.Buffer, Reset sets the length to zero and keeps the memory, so the next round reuses it. For strings.Builder, Reset releases it:
after Reset: len 0 cap 0
The reason is the safety rule above: strings previously returned by String() still point into the old buffer, and reusing that memory would overwrite them. So the Builder cannot reuse its buffer after handing out a string, and Reset sets the slice to nil. The consequence is measurable. A loop that builds ten pieces, calls String(), then Resets, took 123.6 ns, 248 bytes and 5 allocations, exactly what a new zero value Builder cost each time (124.0 ns). The Reset bought nothing. The bytes.Buffer version, which resets and reuses, took 44.9 ns, 80 bytes and 1 allocation: its buffer was reused and the one allocation was the copy that Buffer.String() makes to return an immutable string.
So, to reuse memory across many strings, use a bytes.Buffer (or a []byte you truncate with b = b[:0]) and pay one copy per result, or Grow a new Builder every time and let the allocator do its job. Do not expect Reset to pool a Builder's buffer. (If you hold a sync.Pool of Builders, they do not retain their buffers either; pool bytes.Buffer or []byte instead, see sync.Pool.)
Copying is detected
The Builder stores its own address on first use and checks it on every write. Copy a used Builder and write to the copy, and:
strings: illegal use of non-zero Builder copied by value
The program recovers the panic and prints it. A copy exists because the buffer slice header would be shared between the two values, and two writers appending to the same backing array would overwrite each other. The panic turns that silent bug into a loud one. Two practical consequences: pass a *strings.Builder when handing a builder to a function (or use it as an io.Writer), and do not put a Builder in a struct that you copy. A zero value Builder can be copied freely, since the check applies to non zero builders; Reset and Grow before first use are fine.
Writing different things
| Method | Cost in the lab |
|---|---|
WriteString | the baseline: 50 ns for 10 writes after Grow |
WriteByte x80 | 194.5 ns, 1 allocation |
WriteRune x40 (2 byte runes) | 143.5 ns, 1 allocation |
fmt.Fprintf(&sb, "item-%d;", i) x10 | 482.2 ns, 160 bytes, 2 allocations |
All of them allocated once, because the lab grew the builder first. WriteByte x80 took 194.5 ns for 80 bytes (2.4 ns per byte) against 5 ns per 8 byte WriteString: per call overhead dominates, so write in chunks when you can. Fprintf is the slowest of the four: it boxes the arguments, runs the formatting machinery and adds an allocation per call for the i, 482 ns for ten calls against 50 ns for ten plain writes. For numbers, sb.WriteString(strconv.Itoa(i)) or strconv.AppendInt into a scratch buffer avoids the boxing (the allocation article).
Limits
- Not safe for concurrent use. Two goroutines writing to one Builder race. Use a mutex or one Builder per goroutine.
- No read methods. You cannot read back or truncate. For that, use
bytes.Bufferor a[]byte. - Only grows. There is no way to drop the last few bytes (the standard workaround is to track what to trim and build the string after).
bytes.BufferhasTruncate. - It holds one contiguous buffer. For very large outputs (hundreds of megabytes), a stream (
bufio.Writerto a file or network) uses less memory than building a string. - Not a pool. See above.
Checklist
- Use the zero value; call
Growwith the final size when you know it. - Hand out
*strings.Builder, never copies. - Treat the string from
String()as sharing the Builder's buffer; clone when you keep a small result from a big builder. - Do not rely on
Resetfor reuse; usebytes.Bufferor a byte slice. - Write in chunks and format numbers with
strconv, notFprintf, in hot paths.
The type is documented in the strings package and the growth rules in the Go specification.


