A -benchmem run shows a strings.Builder assembling SQL allocating several times per call. How do you cut that?
answer
- read the numbers before changing code
- one big reservation beats several growths
- tell it how many bytes are coming
- the returned string is itself an allocation
- Reset does not mean the same on both types
basics
~20 sCompute a cheap size estimate and call Grow once so the backing slice is allocated a single time instead of growing repeatedly. Remember that strings.Builder.Reset drops its buffer, so reusing capacity across calls needs a bytes.Buffer, whose Reset keeps the array.
solid answer
~50 sFirst confirm the shape with `go test -bench . -benchmem`: `allocs/op` tells you how many allocations, `B/op` how many bytes, and repeated growth shows up as several allocations rising with input size. The fix is `Grow(n)` with an estimate computed from the inputs — the lengths of the column names, separators and the fixed keywords — which guarantees room for `n` more bytes so the slice is allocated once. Below that, one allocation per call is the floor for a returned string: `String()` hands the backing array out to the caller, so escape analysis (`go build -gcflags=-m`) cannot keep it on the stack. Going further means not returning a string at all — appending into a `[]byte` the caller supplies, or reusing one accumulator across calls. For that last option `strings.Builder` is the wrong type: its `Reset` sets the slice to nil, while `bytes.Buffer.Reset` reslices to zero length and keeps the capacity, so a reused buffer stops allocating after it warms up.
code
go · 19 linesfunc buildSelect(cols []string, table string) string {
n := len("SELECT ") + len(" FROM ") + len(table)
for _, c := range cols {
n += len(c) + 2 // the column plus ", "
}
var b strings.Builder
b.Grow(n)
b.WriteString("SELECT ")
for i, c := range cols {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(c)
}
b.WriteString(" FROM ")
b.WriteString(table)
return b.String()
}go deeper
Know that Grow exists and takes a byte count you can estimate from the inputs, and that a benchmark is what tells you whether it helped.
Explain what -benchmem's B/op and allocs/op mean, what Grow guarantees, and why Reset behaves differently on strings.Builder and bytes.Buffer.
Show the measure-change-measure loop and know where the floor is: one allocation for a returned string, because String hands the backing array to the caller. Recognise when the allocations are coming from the loop body instead.
Own the tradeoff between an allocation-free append-style API and a signature callers find natural, and decide how much of a data-access layer is worth reshaping for a measured allocation you can actually attribute to latency.
## Measure the shape before changing anything `go test -bench . -benchmem` adds two columns: `B/op`, the bytes allocated per operation, and `allocs/op`, the number of distinct allocations. Those two numbers tell you which problem you have. - `allocs/op` of 4 or 5 that climbs as the input grows: the accumulator is growing repeatedly. This is the case `Grow` fixes. - `allocs/op` of exactly 1 with a large `B/op`: you are already down to the single unavoidable allocation for the returned string, and the remaining lever is not returning a string. - `allocs/op` far higher than the number of pieces: something inside the loop is allocating per iteration — a conversion, an interface boxing, a temporary slice — and the builder is not the culprit. Guessing which of those you have is how tuning sessions get wasted. ## Grow, and what it actually promises `Grow(n)` grows the builder's capacity, if necessary, to guarantee space for another `n` bytes beyond what is already written. It does not set the capacity to exactly `n`, it never shrinks, and it makes no promise beyond `n` — underestimate and a later append still triggers a growth, overestimate and you hold memory you never use. The estimate does not have to be exact, and it does not have to be tight. For a SQL statement assembled from a column list, summing the column name lengths plus two bytes per separator plus the fixed keywords is a handful of integer additions and eliminates every intermediate growth. Cheap arithmetic in exchange for several allocations is almost always a good trade in a hot data-access path; the same arithmetic in a function called twice per request is noise. ## The floor: why one allocation remains `String()` returns the accumulated bytes as a string without copying them, which means the caller now holds a reference to the builder's backing array. That array therefore outlives the function's frame. `go build -gcflags=-m` reports escape-analysis decisions and will say so — the backing slice escapes to the heap. No amount of `Grow` tuning removes that allocation, because the result itself is the allocation. So if a benchmark shows `1 allocs/op` on a function returning a freshly built string, you are done unless you change the signature. ## Below the floor: change what you return Two shapes get past it, and both trade API convenience for allocations: 1. **Append into a caller-supplied slice.** `func appendSelect(dst []byte, cols []string) []byte` lets the caller pass a slice it reuses, and the whole assembly can then be allocation-free after the first call. This is the pattern the standard library itself uses for its `Append`-style functions, and it composes: the caller keeps one slice per worker and passes it in every time. 2. **Reuse one accumulator.** Keep the accumulator alive across calls and clear it between uses. Here the choice of type matters and the two are not interchangeable: `strings.Builder.Reset` sets its backing slice to nil, so the next write allocates from scratch — the capacity is gone. `bytes.Buffer.Reset` reslices the existing array to zero length and keeps the capacity, so a reused buffer allocates while it warms up and then stops. If your reuse plan is "build, emit, Reset, build again", `bytes.Buffer` is the type that actually delivers it. A reused accumulator is per-goroutine state, so it belongs to one worker or one request at a time; sharing one between goroutines is a data race, not an optimisation. ## Where the aliasing bites If you go the `bytes.Buffer` route and hand callers `buf.Bytes()`, remember that slice aliases the buffer and is only valid until the next modification — which, in a reuse loop, is the very next iteration. Either copy before returning, or document that the result is only valid until the next call. Handing out an aliased slice from a reused buffer is the classic way this optimisation turns into a data-corruption bug. ## Sequence to follow Benchmark with `-benchmem`, read `allocs/op`, add `Grow` with a computed estimate, benchmark again, and stop when you reach one allocation per call. Only then consider changing the function's signature, and only if the benchmark says the remaining allocation is worth an API you like less. Every step should be a measured before-and-after, because the difference between five allocations and one is easy to see in a benchmark and impossible to see in a code review.
- Why can't Grow get a function that returns a built string below one allocation?`String()` hands the builder's backing array to the caller as a string, so that array outlives the frame and escape analysis — visible with `go build -gcflags=-m` — must heap-allocate it. The result *is* the allocation. Getting below one means not returning a string: append into a caller-supplied `[]byte`, or reuse an accumulator the caller owns.
- What exactly does strings.Builder.Grow(n) guarantee?Room for another `n` bytes beyond the current length, allocating if the existing capacity is short. It is not an exact capacity, it never shrinks, and it makes no promise past those `n` bytes — underestimating simply means a later append grows the slice again, and overestimating holds memory you do not use.
- If you reuse one bytes.Buffer across calls, what must you not return from it?The slice from `Bytes()`. It aliases the buffer's array, and in a reuse loop the next iteration overwrites it, so a caller holding it sees another call's data. Either copy the bytes before returning, or return a string, which copies anyway. This is how the reuse optimisation turns into a correctness bug.
saying these in an interview costs you the question
- Calls Grow with a large constant without measuring
- Assumes strings.Builder.Reset preserves the capacity
- Tunes the builder when the loop body is what allocates
- Thinks Grow can shrink or cap the buffer
- Shares one reused accumulator across goroutines
- Returns Bytes() from a buffer that will be reused