skip to content

String and Byte Handling

The strings and bytes packages do the same work over immutable text and reusable buffers, and the interview point is usually which call allocates a fresh string.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

explore

questions

18

Why is strings.Builder preferred over `+=` when assembling a string in a Go loop?

level: juniorimportance: must knowfreq 68%

answer

  1. a string can never change once made
  2. one growing byte slice, not many strings
  3. every += rebuilds the whole prefix
  4. the final String call copies nothing

basics

~20 s

Go 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 s

A 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 lines
go
var 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 copy

go deeper

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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
open as a page

What is the difference between strings.Split(s, " ") and strings.Fields(s) when splitting a line into words?

level: juniorimportance: must knowfreq 70%

basics

~20 s

strings.Split cuts at every single separator, so a run of two spaces produces an empty string between them. strings.Fields treats any run of one or more Unicode whitespace characters as one separator and never returns an empty element.

open as a page

In Go, how does strings.Trim's cutset differ from strings.TrimPrefix's prefix?

level: juniorimportance: must knowfreq 72%

basics

~20 s

strings.Trim takes a cutset: a set of individual characters, stripped repeatedly from both ends in any order. strings.TrimPrefix takes a literal string and removes it once from the front, returning the input unchanged when that prefix is absent.

open as a page

Which Go standard library call counts the runes in a string, and why can its result surprise a user?

level: juniorimportance: must knowfreq 72%

basics

~20 s

utf8.RuneCountInString from the unicode/utf8 package returns the number of Unicode code points in a Go string. It can still surprise: accented letters, emoji and flags are often several code points that a user sees as one character.

open as a page

When would you reach for bytes.Buffer instead of strings.Builder in Go?

level: middleimportance: should knowfreq 52%

basics

~20 s

Reach for bytes.Buffer when you need to read the accumulated bytes back, hand them to something expecting an io.Reader, or keep the capacity across a Reset. Use strings.Builder when the result is a string: its String method avoids the final copy.

open as a page

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

level: middleimportance: should knowfreq 44%

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.

open as a page

Why is strings.Cut safer than strings.Index for parsing a "key=value" header line?

level: middleimportance: should knowfreq 55%

basics

~20 s

strings.Cut returns the text before and after the separator plus a found boolean, so a missing separator is impossible to ignore. strings.Index returns -1, and the offset arithmetic around that sentinel either panics or silently slices the wrong text.

open as a page

What does the n argument to strings.SplitN control, and what do n=0 and n<0 mean?

level: middleimportance: should knowfreq 44%

basics

~20 s

n caps how many substrings come back, not how many cuts: with n above zero the last element is the unsplit remainder. A negative n means no limit, like strings.Split, and an n of zero returns nil.

open as a page

In Go, why prefer strings.EqualFold over comparing strings.ToLower of both sides?

level: middleimportance: should knowfreq 45%

basics

~20 s

strings.EqualFold compares under Unicode simple case folding, allocates nothing and stops at the first difference. Lowercasing both sides builds two throwaway strings and still misses folds such as the long s, which lowercases to itself but folds to a plain s.

open as a page

In Go, when does strings.NewReplacer behave differently from chained strings.ReplaceAll calls?

level: middleimportance: should knowfreq 54%

basics

~20 s

A strings.Replacer scans the input once, so text it has just written is never re-examined. Chained strings.ReplaceAll calls run in sequence, so a later call can rewrite what an earlier one produced — replacements cascade.

open as a page

In a Go signup validator, what do unicode.IsLetter, IsDigit and IsSpace actually accept?

level: middleimportance: should knowfreq 34%

basics

~20 s

They test Unicode categories over a whole rune, not ASCII. IsLetter accepts letters from every script, IsDigit accepts only decimal digits in category Nd including Arabic-Indic ones, and IsSpace accepts the Unicode white-space set including the no-break space.

open as a page

What does utf8.DecodeRuneInString return for invalid bytes, and how do you spot a genuine U+FFFD?

level: middleimportance: should knowfreq 42%

basics

~10 s

It returns utf8.RuneError with size 1 for invalid bytes, and size 0 for an empty string. A genuine U+FFFD decodes with size 3, so the size, not the rune, tells corruption apart.

open as a page

A -benchmem run shows a strings.Builder assembling SQL allocating several times per call. How do you cut that?

level: seniorimportance: should knowfreq 36%

basics

~20 s

Compute 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.

open as a page

Why does truncating a Go display name with name[:32] leave a replacement box, and how do you fix it?

level: seniorimportance: should knowfreq 46%

basics

~20 s

Slicing a Go string cuts at a byte offset, so a multi-byte rune gets split in half and the stored bytes are no longer valid UTF-8. Back the cut up to a rune boundary and check utf8.ValidString before storing.

open as a page

What does unique.Make give you when a long-lived Go map holds millions of repeated strings?

level: seniorimportance: nice to knowfreq 20%

basics

~20 s

unique.Make interns a comparable value and returns a Handle. All equal values collapse onto one canonical copy, so repeated strings stop duplicating memory, and comparing two handles is a cheap pointer comparison rather than a byte-by-byte one.

open as a page

When does replacing strings.Split with strings.SplitSeq actually reduce allocations?

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

Only the result slice is saved: strings.Split allocates one []string per call, while the substrings are already views into the input. So SplitSeq helps when that per-call allocation sits on a hot path, and not otherwise.

open as a page

In Go, why can keeping strings.TrimSpace's result hold a whole 10 MB document in memory?

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

Slicing a Go string does not copy its bytes, and strings.TrimSpace returns a slice of its input. A short trimmed field therefore points into the original document's backing array and keeps all of it reachable. strings.Clone makes an independent copy.

open as a page

How do you decide whether to depend on golang.org/x/text and normalise stored names to NFC?

level: principalimportance: nice to knowfreq 20%

basics

~20 s

Go's standard library validates UTF-8 but does not normalise, so normalisation means adding golang.org/x/text to go.mod. Decide it before user data exists, because normalising later rewrites stored bytes and can collide rows that were previously distinct.

open as a page