The string concatenation trap
You are writing a function to build a CSV row. You have ten fields. You grab the + operator and chain them together. The code works for a few rows. Then you run it on a million rows. The CPU spikes. Memory usage climbs. The garbage collector starts thrashing. You didn't write a memory leak, but the program behaves like it has one. The culprit is string concatenation.
Go strings are immutable. Once you create a string, you cannot change it. When you write a + b, Go allocates a new block of memory large enough to hold both, copies a into it, copies b into it, and returns the new string. The old strings become garbage. Do this in a loop, and you allocate and discard memory on every iteration. The cost grows quadratically. A loop that concatenates 10,000 strings doesn't just do 10,000 copies. It does 10,000 copies of increasingly large buffers.
strings.Builder breaks this pattern. It holds a mutable byte buffer. You append data to the buffer. When you are done, you call String() to get the final result. The buffer grows efficiently, avoiding the copy-on-every-step tax.
How strings work in Go
Strings in Go are value types. They consist of a pointer to the underlying byte array and a length. The pointer is read-only. You can slice a string, but the slice shares the same underlying array. You cannot modify the bytes. This design enables safe sharing. Strings can be cached, used as map keys, and passed around without worrying about mutation.
The trade-off is concatenation. a + b must create a new string because neither a nor b can be modified. The compiler optimizes simple cases. If you write name + " " + surname, the compiler often merges the constants and allocates once. But inside a loop, or with variables, the compiler cannot predict the size. It falls back to the general concatenation logic, which allocates on every step.
strings.Builder provides a mutable container. It wraps a byte slice that grows as needed. You write to the builder. The builder manages the memory. When you extract the string, the builder returns a string pointing to the buffer. The buffer stays alive as long as the string exists.
Minimal example
Here is the basic pattern: create a builder, write to it, extract the result.
package main
import (
"fmt"
"strings"
)
func main() {
// Builder starts with a zero-length buffer.
var sb strings.Builder
// WriteString adds text to the internal buffer without allocating a new string.
sb.WriteString("Hello, ")
sb.WriteString("World!")
// String() returns the final content as a string value.
result := sb.String()
fmt.Println(result)
}
The builder accumulates data in place. WriteString copies the bytes into the buffer. If the buffer is full, it allocates a larger buffer, copies the existing data, and continues. This growth strategy is exponential. The capacity doubles when needed. The amortized cost of appending is constant time.
Strings are values. Builders are containers. Pick the right tool for the job.
Walkthrough: what happens at runtime
When you declare var sb strings.Builder, the struct contains a pointer to a byte slice and an integer tracking the length. The slice starts empty. WriteString checks if the buffer has room. If it does, it copies the bytes and updates the length. If not, it allocates a larger slice, copies the existing data, copies the new data, and updates the pointer.
The growth factor is roughly 1.25 to 2, depending on the size. Small buffers grow quickly. Large buffers grow more slowly to avoid wasting memory. This strategy ensures that resizing is rare. Most appends hit the existing capacity.
The + operator pays a linear cost per concatenation. It copies everything again. strings.Builder pays a constant amortized cost. The difference is massive for large strings or tight loops.
Realistic example
Real code often mixes strings, numbers, and error checks. Here is a function that builds a URL query string from a map, using the builder to avoid intermediate allocations.
// BuildQuery creates a URL-encoded query string from key-value pairs.
func BuildQuery(params map[string]string) string {
// Builder accumulates the query string efficiently.
var sb strings.Builder
// Pre-allocate if you have a rough estimate.
// This avoids resizing overhead for large maps.
sb.Grow(len(params) * 10)
first := true
for k, v := range params {
// Skip empty keys to avoid malformed URLs.
if k == "" {
continue
}
// Add the separator between pairs, but not before the first one.
if !first {
sb.WriteByte('&')
}
first = false
// Write the key and value. URL encoding is omitted for brevity.
sb.WriteString(k)
sb.WriteByte('=')
sb.WriteString(v)
}
// Return the accumulated buffer as a string.
return sb.String()
}
The Grow method is optional but helpful. If you know the approximate size, call Grow once. The builder allocates the buffer upfront. This avoids multiple resizes. The example estimates 10 bytes per pair. Adjust based on your data.
WriteByte is a convenience method for single bytes. It avoids creating a one-byte string. WriteString is preferred for strings. Write is the io.Writer interface method. It takes a []byte. Use WriteString when you have a string. It avoids a temporary allocation.
The community convention is to check errors, even if strings.Builder rarely fails. WriteString returns (int, error). The error is almost always nil. But the signature enforces discipline. Ignore the error with _ if you are sure, or check it for robustness.
// Check the error if the caller needs to handle write failures.
// In practice, Builder errors are rare and usually indicate OOM.
n, err := sb.WriteString(data)
if err != nil {
return "", err
}
Builder for loops. Plus for constants. Join for slices.
Pitfalls and conventions
Error handling
WriteString, Write, and WriteByte all return (int, error). The compiler does not force you to check the error. You can call sb.WriteString("x") without assignment. But the community convention is to handle the return values. If you ignore the error, use _ to signal intent.
// Discard the error intentionally.
// Builder errors are rare, but the signature requires handling.
_, _ = sb.WriteString(data)
If you try to assign to a single variable, the compiler rejects the program with assignment mismatch: 1 variable but sb.WriteString returns 2 values. Use the blank identifier or a two-variable assignment.
WriteString vs Write
Write implements the io.Writer interface. It takes a []byte. If you have a string, calling Write([]byte(s)) creates a temporary slice. WriteString takes a string directly. It avoids the allocation. Use WriteString for strings. Use Write only when you already have a byte slice or when you need to satisfy the io.Writer interface.
Grow
If you know the size, use Grow. Without Grow, the builder resizes multiple times. Each resize allocates a new buffer and copies the data. For a 1 MB string, you might resize 20 times. With Grow, you allocate once. The cost is negligible.
// Pre-allocate for known size.
sb.Grow(expectedLength)
Reset
Builders can be reused. Reset clears the buffer and sets the length to zero. The capacity remains. You can reuse the builder in a loop without reallocating.
// Reuse the builder to avoid allocation in tight loops.
sb.Reset()
This is useful when building many strings in a loop. Create the builder once. Reset it for each iteration. Extract the string. Reset again.
Cap and Len
Len returns the current length. Cap returns the capacity. You rarely need these. Use Len if you need the size. Use Cap if you are debugging memory usage. Do not rely on Cap for logic. The capacity is an implementation detail.
Type errors
Builders are not strings. You cannot use + on a builder. The compiler rejects this with invalid operation: sb + "text" (mismatched types strings.Builder and untyped string). Call String() to convert.
Convention aside
gofmt is mandatory. Run it on save. Most editors do this automatically. gofmt handles indentation and formatting. Don't argue about style. Let the tool decide. Focus on logic.
Decision matrix
Use strings.Builder when you are concatenating strings in a loop or building a large string from many parts. Use the + operator when you have a fixed number of small concatenations, like name + " " + surname. The compiler optimizes this case. Use fmt.Sprintf when you need formatted output with mixed types and the performance cost is acceptable for the readability gain. Use bytes.Buffer when you need to write both strings and raw bytes, or when you need to implement io.Writer for a library that expects it. Use strings.Join when you have a slice of strings and a separator. It is cleaner and often faster than a manual loop.
Trust the builder. Grow early. Reset when done.