skip to content

When does a strings.Builder panic with "illegal use of non-zero Builder copied by value"?

level: middleimportance: should knowfreq 44%

answer

  1. a builder remembers where it lives
  2. the check runs on write, not on read
  3. argument, assignment and return all copy
  4. a struct field carries the builder with it
  5. hand over a pointer instead of the value

basics

~20 s

A strings.Builder records its own address on its first write. Copying the value afterwards — by assignment, by passing it to a function, or by returning it — leaves that stored address stale, so the copy panics on its next write. Copying an unwritten builder is fine.

solid answer

~50 s

`strings.Builder` holds a `[]byte` plus a pointer to itself. The first write stores the builder's own address in that pointer; every later write compares the stored address with the builder's current address, and panics with `strings: illegal use of non-zero Builder copied by value` when they differ. Copying the value — assigning it, passing it by value to a helper, returning it, or copying a struct that embeds it — is what makes them differ. The check exists because the original and the copy would share one backing array while each maintaining its own length, so writes through both would silently overwrite each other's bytes, and because `String()` hands that array out without copying. Only the mutating methods check: `Write`, `WriteString`, `WriteByte`, `WriteRune` and `Grow`. Reading with `String`, `Len` or `Cap` through a copy does not panic. The fix is to pass `*strings.Builder`, or to give the enclosing struct pointer receivers.

code

go · 12 lines
go
type query struct {
	sb strings.Builder
	args []any
}

// q is a copy, and its Builder has already been written to by the caller
func addWhere(q query, col string, v any) query {
	q.sb.WriteString(" WHERE ") // panic: strings: illegal use of non-zero Builder copied by value
	q.sb.WriteString(col)
	q.args = append(q.args, v)
	return q
}

go deeper

for a junior

Recall the rule rather than the mechanism: once you have written to a strings.Builder, pass a pointer to it, never the value. Recognise the panic text as meaning the builder was copied.

for a middle

Explain the self-address check: the first write records the builder's own address, later writes compare it, and any copy makes them differ. Say why a copy would be dangerous — a shared backing array with two independent lengths.

for a senior

Spot the invisible copies in a design review: a struct field holding a builder, a value receiver, a helper that takes and returns the struct. Know that only the mutating methods check, which explains why the panic surfaces away from the copy.

for a principal

Decide the convention: types that own an accumulator are pointer-only, documented as such, because the compiler will not enforce it and bytes.Buffer offers no equivalent guard. Weigh that against APIs that return values for chaining.

## The panic ``` panic: strings: illegal use of non-zero Builder copied by value ``` This is not a memory error, a race, or a size limit. It is a deliberate guard built into `strings.Builder` to catch a specific programming mistake at the moment it would cause silent corruption. ## What the type actually holds A `strings.Builder` is a two-field struct: a `[]byte` holding the accumulated text, and a pointer that points at the builder itself. That self-pointer is nil in the zero value. The first mutating call sets it to the builder's own address. Every subsequent mutating call re-checks it: if the address stored inside the value is not the address the value now lives at, the value has been relocated by a copy, and the method panics. ## Why a copy is dangerous rather than merely wasteful Copying the struct copies the slice *header* — pointer, length, capacity — not the bytes. So the original and the copy both refer to the same backing array while each tracks its own length independently. Write through the copy and it appends at the shared array's current end; write through the original and it appends at the same offset, overwriting what the copy just wrote. Neither side sees the corruption until the resulting strings come out wrong. There is a second reason. `String()` hands the accumulated bytes out as a string *without copying them*, which is safe only because a builder never rewrites bytes it has already emitted — it only appends past them. Two builders sharing one array break that invariant: one can overwrite bytes the other has already handed out as an immutable string. Rather than let that happen quietly, the type panics. ## Every way a copy happens Go copies a value in more places than people remember, and all of them trip the check: - Assignment: `b2 := b1`. - Passing by value: `func f(b strings.Builder)`. - Returning by value: `func g() strings.Builder`. - Copying a struct that contains a builder as a field — a `query` struct passed by value carries its embedded builder with it. - Appending a struct containing a builder to a slice, or storing it in a map (both copy the value in). - A method with a value receiver on a type that contains a builder: the receiver itself is a copy. The last two are the ones that bite in real code, because the copy is invisible at the call site. A data-access layer whose `query` struct holds a `strings.Builder` and whose helpers take `q query` and return a modified `query` reads perfectly well and panics on the second helper call. ## Which methods check Only the mutating operations perform the check: `Write`, `WriteString`, `WriteByte`, `WriteRune` and `Grow`. `String`, `Len` and `Cap` do not, so a copy can still be read from without panicking. That asymmetry explains a confusing symptom: a helper that only formats and returns `b.String()` works, and the panic appears later, in whichever function next tries to write. ## Why an unwritten builder copies fine The self-pointer is nil until the first mutating call, and the byte slice is nil too. A zero builder is therefore just an empty struct, and copies of it are genuinely independent — each will record its own address on its own first write. This is why a struct literal containing a fresh `strings.Builder` field, or a `var b strings.Builder` passed around before anyone writes, causes no trouble. The panic message says *non-zero* for exactly this reason. ## The fix Pass the pointer. `func addWhere(q *query, ...)` instead of `func addWhere(q query, ...) query`; pointer receivers on any method that writes; `*strings.Builder` as a parameter type when a helper needs to append. If a struct owns a builder, treat that struct as pointer-only and say so in a comment — the compiler will not stop you from copying it. ## The contrast worth knowing `bytes.Buffer` has the same underlying hazard and **no** such check. Copying a used `bytes.Buffer` compiles, runs, and silently produces interleaved or lost bytes. `go vet` has copylocks-style checks for types containing a `sync.Locker`, but neither builder nor buffer is protected that way. So the discipline — never copy an accumulator after its first write — applies to both; only one of them will tell you when you break it.

  • Why is copying a strings.Builder that has never been written to allowed?
    Its self-pointer and its byte slice are both nil in the zero value, so a copy shares nothing with the original — each records its own address on its own first write. The panic message says *non-zero* for that reason, and it is why a struct literal containing a fresh builder field is perfectly safe to pass around.
  • Does bytes.Buffer panic the same way when it is copied?
    No. `bytes.Buffer` has no copy check at all. Copying a used buffer compiles and runs, and the two copies then share one backing array while tracking separate lengths, so writes overwrite each other silently. The same discipline applies — never copy an accumulator after its first write — but only `strings.Builder` will tell you when you break it.
  • Which methods on strings.Builder do not trigger the check?
    `String`, `Len` and `Cap` only read, and skip it. That is why a helper taking a builder by value merely to render `b.String()` appears to work, and the panic surfaces later in the first function that tries to append. The mutating methods — `Write`, `WriteString`, `WriteByte`, `WriteRune` and `Grow` — are the ones that check.

saying these in an interview costs you the question

  • Says the panic means the string grew too large
  • Thinks passing a Builder by value is safe because copies are cheap
  • Blames a data race and reaches for the race detector
  • Believes returning a written-to Builder from a function is fine
  • Assumes bytes.Buffer raises the same panic when copied