# Go Perf Strings Bytes Zerocopy

> 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 less than - greater than 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.

- Skill: `ctoth/go-perf-strings-bytes-zerocopy` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-perf-strings-bytes-zerocopy`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-perf-strings-bytes-zerocopy/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-perf-strings-bytes-zerocopy

---


# 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](https://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](https://pkg.go.dev/strings#Builder)). 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](https://pkg.go.dev/strings#Builder)).

```go
// 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](https://pkg.go.dev/bytes#Buffer): "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](https://pkg.go.dev/strings#Builder)) — 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](https://pkg.go.dev/strconv)) — so a **reused** `dst` makes the formatting **zero-alloc**.

```go
// 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](https://pkg.go.dev/strconv)). When you do need a standalone string and not an append, `strconv.Itoa(n)` (= `FormatInt(int64(n), 10)`, [strconv](https://pkg.go.dev/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](https://pkg.go.dev/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](https://pkg.go.dev/bytes#Buffer)). The performance lever is **reuse**: `Reset` "resets the buffer to be empty, but it retains the underlying storage for use by future writes" ([bytes](https://pkg.go.dev/bytes#Buffer)). Pairing `Reset` with a `sync.Pool` keeps a population of warm buffers so a high-churn path allocates almost nothing.

```go
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](https://go.dev/ref/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](https://go.dev/wiki/CompilerOptimizations)):

```go
// 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](https://pkg.go.dev/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](https://pkg.go.dev/unsafe#String)

```go
// []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 `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](https://pkg.go.dev/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](https://pkg.go.dev/strings#Builder)).
- **Don't `fmt.Sprintf("%d", n)` on a hot path** — it boxes args and parses a format string; `strconv.AppendInt`/`Itoa` does neither ([strconv](https://pkg.go.dev/strconv)).
- **Don't reallocate a `bytes.Buffer` per call** — `Reset` keeps the storage; pool it for high churn ([bytes](https://pkg.go.dev/bytes#Buffer)).
- **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](https://go.dev/wiki/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](https://pkg.go.dev/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:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

