Go Performance: Strings, Bytes & Zero-Copy
"A Builder is used to efficiently build a string using Write methods. It minimizes memory copying. The zero value is ready to use. Do not copy a non-zero Builder." — pkg.go.dev/strings#Builder
The correctness layer — that a string is read-only UTF-8 bytes, that s[i] is a byte not a character, that for range decodes runes, that string(int) is the code-point trap — belongs to go-strings-bytes-runes; read it first for any text-handling question. This skill is its performance peer: it owns only the allocation accounting — when building text, converting []byte↔string, or formatting numbers copies, and the exact tools (and the compiler's own elisions) that drive the copy count to zero.
All Go below builds clean under gofmt, go vet, and go test on Go 1.25/1.26.
1. strings.Builder with One Grow — Not += in a Loop
acc += piece is O(n²): a string is immutable, so each += allocates a fresh backing array and copies the entire accumulator so far. strings.Builder grows one buffer and "minimizes memory copying" (strings). When the final size is known, one Grow makes the whole build a single allocation — "After Grow(n), at least n bytes can be written to b without another allocation" (strings).
// WRONG — every += reallocates and recopies the whole accumulator: O(n²), n-1 garbage strings
var s string
for _, p := range parts {
s += p
}
// RIGHT — one buffer; with a size hint, exactly one allocation; amortized O(n)
var b strings.Builder
b.Grow(size) // optional but ideal when the total length is known up front
for _, p := range parts {
b.WriteString(p)
}
s := b.String()
Builder.String() returns the accumulated string with no copy (it reinterprets the buffer it already owns), which is why Builder beats bytes.Buffer for the build-a-string case (bytes: "To build strings more efficiently, see the strings.Builder type"). One hard rule carried from go-strings-bytes-runes: "Do not copy a non-zero Builder" (strings) — pass *strings.Builder, never a Builder by value, once it has been written.
2. strconv.Append* over fmt.Sprintf — Append Into a Reused Buffer
fmt.Sprintf("%d", n) does three expensive things: it boxes each argument into an any (an interface conversion that escapes to the heap — see go-perf-escape-analysis), parses the format string at runtime, and allocates a fresh result string. The strconv Append* family does none of it: each "appends the string form … to dst and returns the extended buffer" (strconv) — so a reused dst makes the formatting zero-alloc.
// WRONG — Sprintf boxes n into an any (escapes), parses "%d", allocates the result
line := fmt.Sprintf("id=%d;", n)
// RIGHT — append the decimal form straight into a buffer you keep across calls: 0 allocs/op
buf = buf[:0] // reuse capacity; see go-perf-slices
buf = append(buf, "id="...)
buf = strconv.AppendInt(buf, int64(n), 10)
buf = append(buf, ';')
The whole family appends rather than allocates: AppendInt, AppendUint, AppendFloat, AppendBool, and AppendQuote ("appends a double-quoted Go string literal … to dst and returns the extended buffer" — strconv). When you do need a standalone string and not an append, strconv.Itoa(n) (= FormatInt(int64(n), 10), strconv) still beats Sprintf — no boxing, no format parse. Reserve fmt for human-facing multi-value messages off the hot path.
The same asymmetry holds on the parse side: strconv.Atoi(s) (= ParseInt(s, 10, 0), strconv) is the direct path where fmt.Sscanf drags in reflection and a format scan. Atoi/ParseInt return an error — check it (go-error-handling); a failed parse is an ordinary value, never a discard.
3. Pool a bytes.Buffer with Reset — Don't Reallocate Per Call
bytes.Buffer is the []byte analogue of Builder; "the zero value for Buffer is an empty buffer ready to use" (bytes). The performance lever is reuse: Reset "resets the buffer to be empty, but it retains the underlying storage for use by future writes" (bytes). Pairing Reset with a sync.Pool keeps a population of warm buffers so a high-churn path allocates almost nothing.
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
func render(n int) string {
b := bufPool.Get().(*bytes.Buffer)
b.Reset() // clear length, KEEP the backing array
defer bufPool.Put(b)
b.WriteString("n=")
b.WriteString(strconv.Itoa(n))
return b.String() // String() copies out, so it's safe after Put
}
The pool mechanics — store pointers not values, the per-P/victim-cache structure, resetting before Put, and the retain-an-oversized-buffer trap — belong to go-perf-sync-pool; this skill only flags that the per-call new(bytes.Buffer) is the allocation to kill.
4. Conversions Copy — and the Compiler's No-Copy Special Cases
string(b) and []byte(s) each allocate and copy: "Converting a slice of bytes to a string type yields a string whose successive bytes are the elements of the slice" (spec — Conversions), and because a string is immutable the runtime cannot alias the slice's memory, so it copies. []rune(s) is worse — it copies and widens every code point to a 4-byte int32. In a hot loop, the fix is to convert once and keep the result (go-strings-bytes-runes §7).
But the gc compiler recognizes specific syntactic forms and elides the temporary entirely — no string is ever allocated (CompilerOptimizations):
// 1. Map key: m[string(b)] hashes the bytes directly — no temporary string (gc 1.4+)
if v, ok := counts[string(b)]; ok { use(v) }
// 2. Comparison / switch: bytes compared in place — no temporary string (gc 1.5+, CL 3790)
if string(b) == "ready" { ... } // memeq, no alloc
switch string(b) { // a switch is chained comparisons
case "go", "rust":
}
// 3. Ranging over a []byte(string) conversion does not allocate the byte slice
for i, c := range []byte(s) { ... } // walks s's bytes, no copy
These are the only elided cases — they are narrow, syntactic, and unguaranteed by the language spec (they live in the compiler-optimizations wiki, not the spec). The moment you bind the conversion to a variable (key := string(b); m[key]) or pass it to a function, the copy is back. Do not generalize "string conversions are free."
5. unsafe.String / unsafe.Slice — Genuine Zero-Copy, Strict Rules
When the elisions above don't apply and the profile proves the conversion copy dominates, Go 1.20's unsafe.String/unsafe.Slice convert with no copy by reinterpreting the existing memory. The contract is exact and unforgiving:
"String returns a string value whose underlying bytes start at ptr and whose length is len. … Since Go strings are immutable, the bytes passed to String must not be modified as long as the returned string value exists." — unsafe.String
"At run time, if len is negative, or if ptr is nil and len is not zero, a run-time panic occurs." — unsafe.String
// []byte -> string with no copy (Go 1.20+). The slice's bytes ARE the string.
func zeroCopyString(b []byte) string {
if len(b) == 0 {
return "" // &b[0] panics on an empty slice; guard it first
}
return unsafe.String(&b[0], len(b))
}
// string -> []byte with no copy. unsafe.StringData gives the string's base pointer.
func zeroCopyBytes(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s))
}
The footguns that make this undefined behavior if violated:
- Never mutate after. Once
bbacks a string viaunsafe.String, writingb[i]mutates an immutable string — UB. The bytes "must not be modified as long as the returned string value exists" (unsafe.String). The reverse (unsafe.Sliceover a string) is worse: never write to the result at all — string memory may live in read-only pages. - Lifetime. The result shares the source's backing array; keep the source alive for as long as the result is used, and never apply this to memory that may be freed or reused (e.g. a pooled buffer you then
Put). - The empty-slice panic.
&b[0]on a zero-length slice panics (index out of range) beforeunsafe.Stringruns — guardlen(b) == 0, or useunsafe.SliceData(b)whose nil/empty resultunsafe.Stringtolerates only whenlenis 0.
This pair replaces the pre-1.20 *(*string)(unsafe.Pointer(&b)) / reflect.StringHeader / reflect.SliceHeader trick, which is now deprecated and was always fragile about the header's lifetime. Reach for unsafe only after measuring (go-perf-methodology); the conversion's escape behavior is go-perf-escape-analysis.
6. Don't
- Don't build strings with
+=in a loop — it is O(n²) copying; usestrings.Builder, ideally with oneGrow(strings). - Don't
fmt.Sprintf("%d", n)on a hot path — it boxes args and parses a format string;strconv.AppendInt/Itoadoes neither (strconv). - Don't reallocate a
bytes.Bufferper call —Resetkeeps the storage; pool it for high churn (bytes). - Don't assume
string(b)/[]byte(s)is free — it copies, except in the three narrow compiler-recognized forms (§4); binding to a variable defeats them (CompilerOptimizations). - Don't
unsafe.Stringa buffer you then mutate or free — modifying the bytes "as long as the returned string value exists" is UB (unsafe.String). - Don't call
unsafe.String(&b[0], …)without guardinglen(b) == 0—&b[0]panics on an empty slice. - Don't reach for
unsafebefore profiling — and never use the deprecatedreflect.StringHeaderpattern whenunsafe.String/unsafe.Sliceexist.
7. Routing to Related Skills
Correctness (sibling golang marketplace):
go-strings-bytes-runes— string = read-only UTF-8 bytes,s[i]is a byte,for rangedecodes runes, thestring(int)trap, conversions copy, the basic+=-vs-Builderrule. Read this first.go-slices-and-maps—[]byteview/aliasing/3-index semantics behind buffer reuse.
This plugin (go-perf-* depth):
go-perf-sync-pool— pooling abytes.Buffercorrectly: pointers-not-values, victim cache, reset discipline, the oversized-buffer trap.go-perf-escape-analysis— proving whether a conversion orSprintfargument escapes;-gcflags=-m.go-perf-slices—buf = buf[:0]reuse,slices.Grow, prealloc as a throughput lever.go-perf-encoding-json— JSON hot paths,json/v2, decoder/encoder reuse,RawMessage.go-perf-methodology— the measure-first gate: profile before reaching forunsafe.
8. Reference Files
High-frequency zero-copy string/byte anti-patterns in LLM-generated Go, each with wrong/right code and citations:
references/common-mistakes.md
Source provenance for every claim in this skill:
references/sources.yaml