| name | go-perf-strings-bytes-zerocopy |
| description | Guides zero-copy string and []byte work in Go — strings.Builder (one Grow) vs += in a loop (quadratic), strconv.Append* vs allocating fmt.Sprintf, a pooled bytes.Buffer+Reset, []byte<->string conversion cost plus compiler no-copy cases (m[string(b)], comparison, range), and unsafe.String/unsafe.Slice (1.20) zero-copy with safety rules. Auto-invokes on "avoid string allocation", "[]byte to string without copy", "strconv vs fmt", "unsafe.String". Routes rune correctness to go-strings-bytes-runes, pooling to go-perf-sync-pool. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
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).
var s string
for _, p := range parts {
s += p
}
var b strings.Builder
b.Grow(size)
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.
line := fmt.Sprintf("id=%d;", n)
buf = buf[:0]
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()
defer bufPool.Put(b)
b.WriteString("n=")
b.WriteString(strconv.Itoa(n))
return b.String()
}
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):
if v, ok := counts[string(b)]; ok { use(v) }
if string(b) == "ready" { ... }
switch string(b) {
case "go", "rust":
}
for i, c := range []byte(s) { ... }
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
func zeroCopyString(b []byte) string {
if len(b) == 0 {
return ""
}
return unsafe.String(&b[0], len(b))
}
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
b backs a string via unsafe.String, writing b[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.Slice over 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) before unsafe.String runs — guard len(b) == 0, or use unsafe.SliceData(b) whose nil/empty result unsafe.String tolerates only when len is 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; use strings.Builder, ideally with one Grow (strings).
- Don't
fmt.Sprintf("%d", n) on a hot path — it boxes args and parses a format string; strconv.AppendInt/Itoa does neither (strconv).
- Don't reallocate a
bytes.Buffer per call — Reset keeps 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.String a 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 guarding len(b) == 0 — &b[0] panics on an empty slice.
- Don't reach for
unsafe before profiling — and never use the deprecated reflect.StringHeader pattern when unsafe.String/unsafe.Slice exist.
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 range decodes runes, the string(int) trap, conversions copy, the basic +=-vs-Builder rule. Read this first.
go-slices-and-maps — []byte view/aliasing/3-index semantics behind buffer reuse.
This plugin (go-perf-* depth):
go-perf-sync-pool — pooling a bytes.Buffer correctly: pointers-not-values, victim cache, reset discipline, the oversized-buffer trap.
go-perf-escape-analysis — proving whether a conversion or Sprintf argument 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 for unsafe.
8. Reference Files
High-frequency zero-copy string/byte anti-patterns in LLM-generated Go, each with wrong/right code and citations:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml