Why is strings.Builder preferred over `+=` when assembling a string in a Go loop?
answer
- a string can never change once made
- one growing byte slice, not many strings
- every += rebuilds the whole prefix
- the final String call copies nothing
basics
~20 sGo strings are immutable, so each += builds a brand-new string and copies everything accumulated so far. strings.Builder appends into one growable byte slice instead, and its String method hands those bytes over as a string without a final copy.
solid answer
~50 sA Go string is an immutable sequence of bytes, so `s += part` cannot extend `s` in place — it allocates a fresh string and copies both the old content and the new piece into it, every iteration. `strings.Builder` keeps a single `[]byte` that it appends to, so the accumulated text is copied only when the slice has to grow. Its zero value is ready to use (`var b strings.Builder`, no constructor), `WriteString`, `WriteByte`, `WriteRune` and `Write` all append, `Grow(n)` reserves room up front when you can estimate the size, and `String()` returns the accumulated bytes as a string without copying them again. Because it implements `io.Writer`, you can also write into it with `fmt.Fprintf(&b, ...)`. For two or three fixed pieces, plain `+` is clearer and fine — the Builder earns its keep in a loop.
code
go · 11 linesvar b strings.Builder
b.Grow(64) // optional: reserve room when you can estimate
b.WriteString("SELECT ")
for i, c := range cols {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(c)
}
b.WriteString(" FROM orders")
sql := b.String() // the accumulated bytes, handed over without a copygo deeper
Be ready to say that Go strings are immutable, so += allocates and copies each time, and to write the four-line Builder shape from memory: declare, write in the loop, call String at the end.
Explain the mechanics: the builder appends into one []byte, copying only when that slice must grow, and String hands the bytes over without a further copy. Know that the write methods' error is always nil and exists only to satisfy io.Writer.
Show judgment about when it matters — a data-driven loop, not a two-part concatenation — and mention Grow with a size estimate plus the fact that a builder must never be copied or shared between goroutines without synchronisation.
Frame it as a review heuristic rather than a rule: cheap to apply where the piece count scales with input, noise everywhere else. Decide when a codebase should standardise on a helper instead of repeating builder boilerplate in every package.
## The property that starts it all: strings are immutable In Go, a `string` value is an immutable sequence of bytes. Nothing in the language can change the bytes of an existing string — there is no `s[0] = 'x'`, and there is no way to extend one in place. That single rule is what makes `+=` in a loop expensive. When you evaluate `s = s + part`, the runtime must produce a **new** string whose bytes are the concatenation of the two operands. That means allocating fresh storage and copying the whole of `s` into it, followed by `part`. The old string is left for the garbage collector. Do that inside a loop and every iteration re-copies everything you have built so far. ## What strings.Builder does instead `strings.Builder` is a small struct in the `strings` package that holds a `[]byte` (plus a pointer used for its copy check). Writing to it *appends* to that slice, which is exactly the operation strings themselves cannot support. The accumulated bytes are only ever copied when the slice runs out of capacity and has to move to a bigger array — not once per write. The API is deliberately tiny: - `WriteString(s string) (int, error)` — appends a string. - `Write(p []byte) (int, error)`, `WriteByte(c byte) error`, `WriteRune(r rune) (int, error)` — the other append forms. - `Grow(n int)` — guarantees room for another `n` bytes, so you can pay for one allocation instead of several when you can estimate the final size. - `Len() int`, `Cap() int` — how much is written and how much room there is. - `String() string` — the accumulated text. - `Reset()` — empties it. Two details of that surface surprise newcomers. First, **the error returned by the write methods is always nil**. The signatures exist so that `*strings.Builder` satisfies `io.Writer` and `io.StringWriter`, which is what lets `fmt.Fprintf(&b, ...)` target a Builder. You do not need to check those errors, and idiomatic code does not. Second, **the zero value is ready to use**: `var b strings.Builder` is a complete, working builder. There is no constructor function to call, and no need to allocate anything yourself. ## Why String() is cheap The last step of a naive hand-rolled version — `string(buf)` on a `[]byte` — copies the bytes, because converting a byte slice to a string must produce something immutable and the slice is not. `strings.Builder.String()` avoids that copy: the builder only ever appends and never rewrites bytes it has already produced, so it can hand the accumulated bytes out as a string directly. That saved copy is the main reason to reach for `strings.Builder` rather than appending to your own `[]byte` and converting at the end. ## The one rule you must not break A `strings.Builder` must not be copied once it has been written to — not by assignment, not by passing it to a function by value, not by returning it. The type detects this and panics on the next write. Pass `*strings.Builder` around instead. (Copying a builder you have never written to is harmless.) It is also not safe for concurrent use: if two goroutines write to the same builder you need your own synchronisation. ## When plain concatenation is the right answer This is a loop-shaped concern, not a rule against `+`. Joining a handful of known pieces — `"user:" + id` — is one allocation and reads better than three lines of builder code. Reviewers who demand a `strings.Builder` for a two-part concatenation are optimising noise. Reach for the builder when the number of pieces is driven by data: a query being assembled column by column, a report line by line, a payload field by field. ## A quick shape to remember Declare, optionally `Grow`, write in the loop, `String()` once at the end, and never copy the value in between. If you also need to read the bytes back, or hand them to something expecting an `io.Reader`, that is the job of `bytes.Buffer` rather than `strings.Builder`, which is write-only.
- Does strings.Builder's WriteString ever return a non-nil error?No. It always returns `(len(s), nil)`. The signature exists so that `*strings.Builder` satisfies `io.Writer` and `io.StringWriter`, which is what makes `fmt.Fprintf(&b, ...)` and other writer-taking helpers work. Idiomatic code ignores the return values rather than checking an error that can never be non-nil.
- Do you need a constructor to create a strings.Builder?No. Its zero value is a working, empty builder, so `var b strings.Builder` or a `strings.Builder` field inside a struct is immediately usable — the first write allocates the backing slice. The only care needed is not copying the value after that first write, since the type panics when a written-to builder is used through a copy.
- Is it always wrong to use + on strings in Go?No. For a fixed, small number of pieces, `a + b + c` is a single concatenation and reads better than three builder calls. The cost only compounds when the number of pieces grows with the data, which is what a loop implies. Optimise the loop; leave the two-part concatenations alone.
Concatenating with += is photocopying the whole page each time you add a line; a Builder is writing further down the same page.
saying these in an interview costs you the question
- Claims += edits a Go string in place
- Says strings.Builder.String copies all the accumulated bytes
- Insists a Builder is needed for a two-part concatenation
- Checks the error returned by WriteString as if it could fail
- Thinks a strings.Builder must be created by a constructor call