skip to content

How does strings.Builder produce its final string without copying the bytes it accumulated?

level: middleimportance: should knowfreq 44%

answer

  1. It never hands back a fresh array
  2. The buffer becomes the string
  3. It only ever appends, never rewrites
  4. The finish is an unsafe.String over the buffer
  5. Which is why copying a used one panics

basics

~20 s

strings.Builder appends into an internal byte slice and its String method builds a string header over that same array with unsafe.String, so there is no final copy. It is safe because a Builder only ever appends, never rewrites bytes it already handed out.

solid answer

~50 s

`strings.Builder` holds a `[]byte` that it appends to on every `WriteString`, `Write`, `WriteByte` and `WriteRune`. `String()` does not convert with `string(buf)` — it returns a string header pointing at the buffer's existing array, using `unsafe.String` over the buffer's data pointer and length. That is only sound because the Builder never overwrites bytes below its current length: later writes either land in spare capacity past the end or move to a brand-new array, so a string handed out earlier keeps seeing exactly the bytes it was given. The price is that a Builder must not be copied after it has been written to; it records its own address and panics with "strings: illegal use of non-zero Builder copied by value" if you write to a copy. Contrast `bytes.Buffer.String()`, which genuinely allocates and copies on every call.

code

go · 7 lines
go
var b strings.Builder
b.Grow(1024) // one allocation instead of several
for _, line := range lines {
	b.WriteString(line)
	b.WriteByte('\n')
}
out := b.String() // no copy: the string points at b's buffer

go deeper

for a junior

Be ready to say strings.Builder is the right way to assemble a string piece by piece, that its zero value works with no setup, and that String returns the result at the end.

for a middle

Explain that String hands out a header over the Builder's own buffer with no copy, and tie the append-only invariant to why a copied Builder panics on the next write.

for a senior

Demonstrate the judgment of picking strings.Builder over bytes.Buffer for string output, using Grow when the size is known, and recognising a Builder-copied-by-value panic in a stack trace during an incident.

for a principal

Own the API-shape lesson: the stdlib buys a zero-copy trick with a documented invariant plus a runtime check that fails loudly. That is the bar to hold any in-house zero-copy helper to.

## The problem it solves Building a string by repeated concatenation allocates a new array on every step, because strings are immutable. Building into a `[]byte` and converting once at the end is better, but that final `string(buf)` is itself an allocation and a full copy of everything you just assembled. `strings.Builder` removes that last copy. ## How the type is put together A `strings.Builder` has two fields: a `[]byte` buffer, and a pointer it uses to detect being copied. Its zero value is ready to use — `var b strings.Builder` needs no constructor, no `make`. The write methods (`Write`, `WriteString`, `WriteByte`, `WriteRune`) all reduce to `append` on the buffer, so the buffer grows amortised like any slice. `Grow(n)` reserves room up front, `Len()` and `Cap()` report the buffer's state, and `Reset()` drops the buffer entirely. ## The zero-copy finish `String()` returns a string whose pointer is the buffer's data pointer and whose length is the buffer's length — in current Go, `unsafe.String(unsafe.SliceData(b.buf), len(b.buf))`. No allocation, no byte copying, whatever the size. The soundness argument is the interesting part, and it is a single invariant: **a Builder only appends.** Bytes below the current length are never rewritten. After you call `String()`, further writes do one of two things — land in spare capacity past the returned string's length, or trigger `append` to allocate a new, larger array and leave the old one alone. Either way, the string you were handed keeps observing the same bytes forever, so its immutability is not violated. This is exactly the invariant that a hand-rolled `unsafe.String` over some other byte slice usually cannot promise. ## Why copying a Builder panics The invariant breaks if two Builders share one array. That is what copying a used Builder by value does: the copy's slice header points at the original's array, and appends through the copy could write into bytes the original also considers spare — two writers, one array, and strings already handed out potentially observing changes. So the Builder stores its own address on first use, and every write checks that the address still matches. Writing to a copy of a non-empty Builder panics with `strings: illegal use of non-zero Builder copied by value`. In practice this catches: passing a Builder by value to a function, storing one in a map or a slice of values and re-reading it, capturing one in a struct that is copied, or `for _, b := range builders`. Pass `*strings.Builder`, or keep the Builder local and return the finished string. Copying a **zero** Builder is fine — there is nothing to alias yet. ## bytes.Buffer is not the same deal `bytes.Buffer` is the older, more general type: it is a read-write buffer with an internal read offset, so bytes can be consumed as well as appended, and its array cannot be handed out as an immutable string. `bytes.Buffer.String()` therefore allocates and copies every time it is called. If the destination is a `string` and you only ever append, `strings.Builder` is the right type; if you need `io.Reader` behaviour or want to read the bytes back out as `[]byte`, `bytes.Buffer` is. ## Practical notes - Call `Grow` when you can estimate the final size; it turns several growth reallocations into one. - `String()` is cheap enough to call more than once, but each call returns a string over the buffer as it stands at that moment. - `Reset()` releases the buffer so the next write starts from nothing; reuse of the Builder itself is fine after a `Reset`. - A Builder is not safe for concurrent use; it has no locking. ## What an interviewer is listening for The words "the final conversion is elided because the Builder never rewrites what it has handed out", and an explanation of the copy check that ties back to that same invariant rather than treating it as an arbitrary rule.

  • Why is copying a strings.Builder after its first write a panic rather than merely discouraged?
    Two Builders sharing one array could both append into the same spare capacity, so bytes a previously returned string points at could change — breaking string immutability. The Builder records its own address on first use and every write verifies it, so the copy is caught at the first write instead of producing silent corruption.
  • How does bytes.Buffer.String() differ from strings.Builder.String()?
    `bytes.Buffer.String()` allocates and copies the unread bytes on every call, because a Buffer supports reads and consumption and cannot promise its array will never be rewritten. `strings.Builder` is append-only, which is what lets it hand out its array as an immutable string.
  • Is calling Grow before writing worth it?
    When you can estimate the final length, yes: it replaces several append-driven reallocations and their copies with a single allocation. When you cannot estimate, skip it — amortised growth is already fine, and an oversized Grow just wastes memory.

It is like publishing a book one page at a time and giving readers the shelf position of the first N pages. Adding page N+1 never rewrites page 3, so every slip you handed out stays true.

saying these in an interview costs you the question

  • Says String() converts the buffer with string(buf)
  • Thinks a Builder is safe to pass around by value
  • Believes bytes.Buffer.String() is also zero-copy
  • Claims Builder is faster because it is concurrency-safe
  • Cannot say why append-only makes the shared array safe