Last Updated: May 17, 2026
Strings in Go are immutable, so every += in a loop allocates a brand-new string and copies the old contents into it. For a handful of pieces that's fine. For a thousand pieces it quadruples the work, then quadruples it again. strings.Builder fixes that by keeping a growable []byte buffer internally and handing back the final string at the end with no extra copy. This chapter walks through why the naive approach is quadratic, how Builder avoids it, and the handful of rules that come with using it.
A Go string is a read-only sequence of bytes. You cannot change a single byte in place. Once a string exists, it exists exactly as it was built; any "modification" produces a new string.
That immutability is great for sharing strings across goroutines and for passing them to functions without copies. It's bad news for += in a loop, because the only way to "extend" a string is to allocate a fresh one and copy both pieces in.
Watch what happens when you build a comma-separated tag list with +=:
The output looks right. The performance underneath is wrong. Each result += tag does roughly this:
len(result) + len(tag) bytes.result bytes into the new buffer.tag bytes after them.result.The old result is now garbage. The next iteration repeats the whole dance, only with a slightly larger result to copy.
Cost: Each += is O(n) in the current size of the accumulator, not O(len(piece)). The growth is what makes the total work quadratic.
Here is what the byte copying looks like over a few iterations. Imagine appending the strings "a", "b", "c", "d" one at a time.
The total bytes copied for n single-byte appends is 1 + 2 + 3 + ... + n, which is n * (n+1) / 2. That's O(n²). Double the input, and the work quadruples. At 100 pieces you do ~5,000 byte copies. At 1,000 pieces it's ~500,000. At 10,000 pieces it's ~50 million. None of those numbers are about producing the output; they're about producing intermediate strings that you immediately throw away.
The garbage collector also has to chase down every one of those dead intermediate strings. So the cost isn't just CPU time spent copying. It's also pressure on the allocator and the GC, which shows up as longer pause times in real programs.
You might wonder if the compiler can spot the loop and turn it into something smarter. It can't, in general. The compiler doesn't know how many iterations there will be, what other code reads result partway through, or whether someone took its address. The safe transformation is what it does today: rebuild the string on every assignment.
strings.Builder is the standard answer. It's a tiny struct in the strings package that holds a []byte internally and exposes a handful of methods for appending to it. When you're done, you call .String() to get the accumulated bytes back as a regular Go string.
Same output, very different machine work. Each WriteString appends bytes to the Builder's internal slice the same way append extends any slice: write into spare capacity if there's room, otherwise grow the backing array (Go typically doubles it) and continue. So the total work is amortized O(n), not O(n²).
The two details that matter:
strings.Builder is ready to use. var b strings.Builder works without new() or make(). The internal []byte starts as nil, and the first Write* call sizes it as needed. This is the same convention as sync.Mutex and bytes.Buffer: useful types whose zero value is the right starting state.b.String() returns the accumulated bytes as a string. In most string conversions, Go has to copy the bytes because the destination string must own its memory (strings are immutable, slices are not). strings.Builder uses an internal unsafe trick that reinterprets its []byte as a string header without copying. This is safe because the Builder gives up ownership of the buffer the moment you call .String(); subsequent writes would corrupt the returned string, so the Builder is designed to keep using a fresh buffer if you write again afterward.Cost: b.String() is effectively zero-allocation in the common case where you call it once at the end. That's why Builder beats every alternative for the "many writes, one final string" pattern.
Here's the picture of what the buffer looks like across iterations. Builder reuses one growing array, not a fresh array per write.
The green boxes are cheap: there's spare room, so the bytes go straight into it. The orange box is the occasional growth: the runtime allocates a larger array, copies the existing contents over, and continues. Crucially, that growth happens O(log n) times across the whole loop, not on every iteration. The amortized cost per write is O(1).
Contrast that with the += model, where every iteration is an "orange box": a fresh allocation, a full copy of the existing contents, and a discard of the previous string. Builder turns 10,000 forced copies into a handful of opportunistic ones.
Builder exposes a small, focused API. Five Write* methods cover every kind of byte you might want to append. Each one returns a value or error to satisfy a standard interface, which we'll come back to.
| Method | Signature | Use For |
|---|---|---|
WriteString | (s string) (int, error) | Append a string |
WriteRune | (r rune) (int, error) | Append one Unicode code point, UTF-8 encoded |
WriteByte | (c byte) error | Append one ASCII byte |
Write | (p []byte) (int, error) | Append a byte slice |
String | () string | Return the accumulated string |
Len | () int | Bytes written so far |
Cap | () int | Current backing array capacity |
Reset | () | Empty the Builder so it can be reused |
Grow | (n int) | Pre-allocate at least n more bytes of capacity |
The most common method is WriteString. Use it whenever you have a string to append.
WriteByte is the right choice when you have exactly one byte. Adding a single character via WriteString(" ") works but allocates a one-byte string header for no reason.
WriteRune is for code points above ASCII. A rune in Go is an int32 representing a Unicode code point, and WriteRune encodes it as UTF-8 bytes (one to four bytes) before appending. If you're building text that includes characters like €, →, or any non-Latin script, this is the method you want.
Write takes a []byte. Use it when you already have bytes in hand, typically from a file read or a network buffer, and don't want to convert them to a string just to append them.
Len returns how many bytes are currently in the Builder. Cap returns how much room the backing array has total. The difference between them is your headroom before the next growth.
The capacity numbers depend on the runtime's growth strategy, so don't write code that depends on specific values. What you can rely on is that Cap() grows in jumps, and each jump roughly doubles the previous capacity.
Reset clears the Builder so the same variable can be reused for a new string. This is useful in loops where you build one string per iteration.
Notice the fmt.Fprintf(&b, "%d: ", i+1) call. Builder satisfies the io.Writer interface, so any function that writes to an io.Writer can write into it directly. We'll come back to that.
Reset keeps the Builder's identity but throws away the internal buffer. After Reset, Len is 0 and Cap may be 0 too. The next write will grow a fresh slice, so if you're going to reset and refill many times to the same approximate size, consider a Grow call right after to skip the early growth steps.
When you know how many bytes the final string will be, or have a reasonable upper bound, Grow(n) lets you allocate the backing array once instead of through a chain of doublings.
Grow(n) ensures the Builder can write n more bytes without another allocation. If the current spare capacity is already n or more, it does nothing. If not, it allocates a new buffer at least that much larger and copies the existing contents over once.
Cost: Grow saves you from O(log n) reallocations. The total bytes copied for those doublings would still be O(n), but the constant factor is lower and the allocator gets fewer requests, which matters under high throughput.
The analogy here is make([]T, 0, n) for slices. If you know the final size of a slice, you say so up front; same with Builder.
Two patterns where Grow pays off the most:
len(rows) order entries, where each row is roughly the same width.Reset(), the buffer goes back to zero capacity, and the next iteration would re-grow from scratch. A Grow call after each Reset keeps the capacity steady.Each iteration writes a fresh CSV row. Without the Grow(64), the Builder would grow its slice from zero on every iteration. With it, the slice stays at 64 bytes of capacity across the whole loop, and no growth happens at all.
For benchmarking these kinds of changes, Go's testing package has a built-in B type for microbenchmarks. The shape is straightforward:
Run with go test -bench=. -benchmem. The Builder version typically runs orders of magnitude faster than the += version for inputs of this size, and allocates a small constant number of bytes per iteration instead of one allocation per piece. Specific nanosecond numbers depend on your machine and Go version, so the value of the benchmark is the relative comparison, not absolute timing.
Each Write* method on Builder returns either (int, error) or error. Builder is allocating into a memory buffer it owns, so it can't actually fail. Every call returns nil for the error and the byte count for the success path. So why the signatures at all?
Because Go's standard library is built around the io.Writer interface:
Any type that implements that single method can plug into the dozens of functions that consume writers: fmt.Fprintf, io.Copy, json.NewEncoder, template.Execute, and many more. Builder's Write(p []byte) (int, error) matches the interface exactly. The other Write* methods follow the same pattern for consistency, so the API is uniform.
This means anywhere you'd normally use fmt.Sprintf to build a formatted string, you can write directly into a Builder instead and skip the intermediate string allocation.
Pass the Builder by pointer (&b) because fmt.Fprintf needs to call Write on the same Builder you're going to read from. We'll see why that pointer requirement is even stricter than usual in the next section.
Cost: fmt.Fprintf into a Builder avoids the allocation that fmt.Sprintf makes when it returns a string. For one-off calls the difference is negligible. Inside a hot loop, it adds up quickly.
Because Builder's Write* methods never actually return a non-nil error, you can ignore the return values in your own code without losing anything:
Some linters will warn about ignored error return values; that warning is generally safe to silence specifically for strings.Builder writes. The io.Writer interface forces the signature, not the actual behaviour.
strings.Builder has one unusual rule: do not copy a Builder after you've written to it. The reason is the internal unsafe trick that lets String() return without copying.
When you call b.String(), Builder reinterprets its []byte as a string by sharing the same underlying memory. For that to be safe, the Builder has to guarantee that nobody is going to mutate that memory ever again. The way it enforces this is by checking, on every write, that the Builder is still the same Builder it was during the previous write. If you copied it, the copy and the original both think they own the same buffer, which would let one of them mutate bytes that the other has handed out as an immutable string.
The runtime detects this with a sentinel pointer the Builder stores at first use. The check is small, but the consequence of failing it is a panic.
The error message is precise: "non-zero Builder copied by value". The fix is to use a pointer to the Builder wherever you'd otherwise hand it off.
writeFooter takes a pointer, so the function and main are operating on the same Builder. No copy, no panic.
What does count as a copy?
c := b after b has been written to.func write(b strings.Builder).What doesn't count as a copy?
func write(b *strings.Builder).The Go vet tool detects this at build time when it's statically obvious. Running go vet ./... on the broken example above produces:
The vet tool says "lock value" because Builder's no-copy mechanism is implemented using the same machinery the sync package uses to detect copies of Mutex. That's why the error mentions a lock even though Builder doesn't actually lock anything.
Cost: The copy check itself is one pointer comparison per write. It's free for practical purposes. The cost of forgetting the rule is a runtime panic, which is much worse.
Builder is the right tool when you have many small writes producing one final string. It's not the right tool everywhere strings get combined.
Small fixed concatenations. When you have two or three pieces, + is shorter, clearer, and just as fast. The compiler can sometimes fold short concatenations into a single allocation.
A strings.Builder here would be six lines and no faster. The point of Builder is loops and dynamic counts, not every concatenation everywhere.
Formatted output where layout matters. If you need width specifiers, alignment, padding, or numeric formatting, fmt.Sprintf is purpose-built. The result is one allocation, and the code is easier to read.
You can write the same line into a Builder via fmt.Fprintf(&b, ...) when you're inside a larger build, but for a single formatted string Sprintf is cleaner.
Heavy byte manipulation. If you're slicing, replacing, or rearranging bytes (not just appending), the right type is bytes.Buffer. It exposes a richer API including Bytes(), Truncate, ReadFrom, and so on. strings.Builder is intentionally write-only and forward-only.
A one-line comparison: strings.Builder produces a string at the end and is read-only afterward. bytes.Buffer produces a []byte and supports reading, writing, and partial consumption.
| Need | Reach For |
|---|---|
| Loop building a string from many pieces | strings.Builder |
| Two or three pieces joined together | + operator |
One formatted line with %d, %s, width | fmt.Sprintf |
| Format into a larger Builder | fmt.Fprintf(&b, ...) |
| Joining a slice of strings with a separator | strings.Join |
| Read-write byte buffer, e.g. for I/O | bytes.Buffer |
strings.Join deserves a special note. When the only thing you're doing is gluing together a slice with a separator, strings.Join(parts, ", ") is faster than a hand-rolled Builder loop and clearer. It pre-computes the exact size internally and uses a Builder-like trick to produce the result in one allocation.
So the decision tree is: if you have a []string and a separator, use Join. If you have a mix of strings, runes, bytes, and formatted values, use Builder. If you have two or three things, use +.
s += x in a loop allocates and copies the full accumulator on every iteration. The total work is O(n²) in the number of pieces.strings.Builder keeps an internal []byte and appends to it the same way append extends a slice. Each Write* call is amortized O(1), and .String() returns the final string without copying.strings.Builder is ready to use. No new() or make() needed. Just var b strings.Builder.Grow(n) when you know the final size. It saves a chain of doublings and reduces allocator pressure.io.Writer, so fmt.Fprintf(&b, ...) writes a formatted value directly into the buffer. The error return on every Write* method is always nil; the signatures exist for interface compliance.strings.Builder after first use. Pass it by pointer. The runtime panics on copy, and go vet catches most cases at build time.strings.Builder when a loop produces one final string. Use + for two or three pieces, fmt.Sprintf for one formatted line, strings.Join for joining a slice with a separator, and bytes.Buffer when you need read-write byte operations.The next chapter, String Formatting with fmt, looks at the fmt package in depth: the verbs (%d, %s, %v, %q), width and precision flags, and how Sprintf, Fprintf, and friends fit together with the Builder pattern you just learned.