# Go Strings Bytes Runes

> Guides Go string handling as what a string actually is — an immutable, read-only slice of UTF-8 bytes where indexing yields a byte (not a character), len is a byte count (not a rune count), for range decodes runes while an indexed for walks bytes, []byte/[]rune conversions copy, string(intValue) is the code-point trap go vet flags, strings.Builder (not += in a loop) builds strings without O(n²) reallocation, and strconv beats fmt.Sprintf in hot paths. Auto-invokes when writing or editing string indexing/iteration, []byte/[]rune conversions, string concatenation in loops, strconv vs fmt, string(int), or on "why is len wrong for this unicode string" requests. The depth behind the policy root's "use the most standard tools" for text.

- Skill: `ctoth/go-strings-bytes-runes` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-strings-bytes-runes`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-strings-bytes-runes/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-strings-bytes-runes

---


# Go Strings, Bytes, and Runes

> "In Go, a string is in effect a read-only slice of bytes."
> — [Strings, bytes, runes and characters in Go](https://go.dev/blog/strings)

> "As implied up front, indexing a string accesses individual bytes, not characters."
> — [Strings, bytes, runes and characters in Go](https://go.dev/blog/strings)

A Go `string` is not an array of characters. It is an immutable, read-only slice of bytes that *usually* holds UTF-8 text — "a string holds *arbitrary* bytes. It is not required to hold Unicode text, UTF-8 text, or any other predefined format" ([The Go Blog](https://go.dev/blog/strings)). Almost every string bug in LLM-generated Go comes from forgetting that one fact: that `s[i]` is a byte, that `len(s)` counts bytes, that `string(n)` is a code point, and that converting to `[]byte` or `[]rune` copies. This skill is the depth behind `go-idiomatic-discipline`'s "prefer the most standard tools" for text work.

---

## 1. A String Is Read-Only Bytes; Indexing Yields a Byte

Indexing a string gives you the byte at that offset, typed `byte` (alias for `uint8`) — never a character. For ASCII the two happen to coincide; for any multibyte rune they diverge and the bug is silent.

```go
s := "héllo" // 'é' is 2 bytes in UTF-8 (0xC3 0xA9)

// WRONG — s[1] is the byte 0xC3, the first half of 'é', not the character 'é'
fmt.Printf("%c\n", s[1]) // prints 'Ã', garbage

// RIGHT — decode the rune at that position
r, _ := utf8.DecodeRuneInString(s[1:])
fmt.Printf("%c\n", r) // prints 'é'
```

A string is also immutable: `s[0] = 'H'` does not compile. To mutate text you convert to `[]byte` (or `[]rune`), change it, and convert back — and that round-trip copies (§7).

---

## 2. Bytes vs Runes vs UTF-8: `len` Is Not Character Count

UTF-8 encodes each code point in **1 to 4 bytes** (`utf8.UTFMax = 4`). A **rune** is Go's name for a code point: "The Go language defines the word `rune` as an alias for the type `int32`, so programs can be clear when an integer value represents a code point" ([The Go Blog](https://go.dev/blog/strings)).

So `len(s)` is the **byte** count, and the character count is a different number you must ask for explicitly:

```go
s := "héllo"
len(s)                      // 6 — bytes (é is two bytes)
utf8.RuneCountInString(s)   // 5 — runes (characters)
```

`utf8.RuneCountInString` is the rune-count primitive ([pkg.go.dev/unicode/utf8](https://pkg.go.dev/unicode/utf8)). Reaching for `len(s)` as a character count is the single most common Unicode mistake — it is right for ASCII and wrong for everything else. If you need to validate that bytes even *are* UTF-8, `utf8.ValidString` "reports whether s consists entirely of valid UTF-8-encoded runes" ([pkg.go.dev/unicode/utf8](https://pkg.go.dev/unicode/utf8)).

---

## 3. Iterating: `for range` Decodes Runes, Indexed `for` Walks Bytes

The two loop forms iterate different things, and choosing the wrong one is a bug, not a style choice.

`for i, r := range s` decodes UTF-8: "A `for` `range` loop ... decodes one UTF-8-encoded rune on each iteration. Each time around the loop, the index of the loop is the starting position of the current rune, measured in bytes, and the code point is its value" ([The Go Blog](https://go.dev/blog/strings)). The index `i` therefore **jumps by the rune's byte width** (1, 2, 3, or 4), and `r` is a `rune`.

```go
s := "héllo"

// Runes — i is 0,1,3,4,5 (jumps over é's second byte); r is the character
for i, r := range s {
	fmt.Printf("%d:%c ", i, r) // 0:h 1:é 3:l 4:l 5:o
}

// Bytes — c is a byte; multibyte runes are split and mangled
for i := 0; i < len(s); i++ {
	c := s[i] // 0xC3, 0xA9 for é — NOT a character
}
```

Use `for range` when you mean characters; use the indexed `for` only when you genuinely mean bytes (e.g. scanning ASCII delimiters in a buffer).

---

## 4. The `string(intValue)` Trap

`string(65)` is `"A"`, not `"65"`. The spec is explicit: "Converting a signed or unsigned integer value to a string type yields a string containing the UTF-8 representation of the integer as defined by `utf8.EncodeRune` when applied to the equivalent rune value" — `string(65) == "A"` ([Go spec — Conversions](https://go.dev/ref/spec#Conversions)). The model reaches for it expecting `fmt`-style formatting and gets the code point instead.

`go vet`'s **stringintconv** check flags this: "This checker flags conversions of the form string(x) where x is an integer (but not byte or rune) type. Such conversions are discouraged because they return the UTF-8 representation of the Unicode code point x, and not a decimal string representation of x as one might expect" ([cmd/vet](https://pkg.go.dev/cmd/vet)).

```go
n := 65

// WRONG — "A", and go vet (stringintconv) flags it
s := string(n)

// RIGHT — you wanted the digits
s := strconv.Itoa(n) // "65"

// RIGHT — you really did mean the code point: say so with rune()
s := string(rune(n)) // "A", and vet is satisfied
```

vet's own fix advice: "For conversions that intend on using the code point, consider replacing them with string(rune(x)). Otherwise, strconv.Itoa and its equivalents return the string representation of the value in the desired base" (`go tool vet help stringintconv`). `go-tooling-and-static-analysis` owns wiring this check into CI.

---

## 5. Build Strings with `strings.Builder`, Not `+=` in a Loop

Strings are immutable, so `acc += piece` allocates a brand-new string and copies the whole accumulated result *every* iteration — O(n²) work and garbage for an n-piece loop. `strings.Builder` grows one backing buffer instead: "A Builder is used to efficiently build a string using Write methods. It minimizes memory copying. The zero value is ready to use" ([pkg.go.dev/strings](https://pkg.go.dev/strings#Builder)).

```go
// WRONG — every += reallocates and recopies the whole accumulator: O(n²)
var out string
for _, p := range parts {
	out += p
}

// RIGHT — one growing buffer; zero value is ready, no constructor needed
var b strings.Builder
for _, p := range parts {
	b.WriteString(p)
}
out := b.String()
```

The zero value works directly — no `New`, matching `go-zero-values-and-construction`. One hard rule: **"Do not copy a non-zero Builder"** ([pkg.go.dev/strings](https://pkg.go.dev/strings#Builder)) — passing a used Builder by value (or assigning it) corrupts its internal buffer pointer; pass `*strings.Builder` if it must cross a function boundary. `bytes.Buffer` is the analogous tool when you are building a `[]byte` (and, like `Builder`, has a useful zero value — see `go-zero-values-and-construction`). For known final size, `b.Grow(n)` preallocates.

---

## 6. `strconv` over `fmt.Sprintf` in Hot Paths

For a single primitive↔string conversion, `strconv` is the direct, allocation-light path; `fmt.Sprintf` drags in reflection and format-string parsing. "Package strconv implements conversions to and from string representations of basic data types" ([pkg.go.dev/strconv](https://pkg.go.dev/strconv)); Uber's guide is blunt: prefer `strconv` over `fmt` because, when converting primitives to/from strings, `strconv` is faster ([Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md#prefer-strconv-over-fmt)).

```go
// WRONG (hot path) — reflection + format parsing for a trivial int→string
s := fmt.Sprintf("%d", n)

// RIGHT — direct, no reflection
s := strconv.Itoa(n)              // int → "123"
s := strconv.FormatInt(x, 10)     // int64 → base-10 string

// And the reverse: parse, don't Sscanf
n, err := strconv.Atoi(s)         // "123" → int, with an error to check
```

`strconv.Itoa` "is equivalent to FormatInt(int64(i), 10)" and `Atoi` "is equivalent to ParseInt(s, 10, 0)" ([pkg.go.dev/strconv](https://pkg.go.dev/strconv)). `Atoi` returns an `error` — check it (`go-error-handling`); a failed parse is an ordinary value to handle, never a discard. Use `fmt` freely for human-facing multi-value messages; reach for `strconv` on the per-item conversion in a loop.

---

## 7. Conversions Copy: Avoid Needless Round-Trips

`[]byte(s)` and `string(b)` both **allocate and copy** — a string is immutable, so the runtime cannot share the bytes safely. "Converting a slice of bytes to a string type yields a string whose successive bytes are the elements of the slice" ([Go spec](https://go.dev/ref/spec#Conversions)); the same copy happens in reverse, and `[]rune(s)` copies *and* decodes every rune into a 4-byte `int32`.

The fix is to do the round-trip once, not repeatedly:

```go
// WRONG — re-converts the same string to bytes every iteration
for _, line := range lines {
	w.Write([]byte(prefix)) // fresh allocation + copy each time
	w.Write([]byte(line))
}

// RIGHT — convert the constant once; keep the result
prefixBytes := []byte(prefix)
for _, line := range lines {
	w.Write(prefixBytes)
	io.WriteString(w, line) // takes a string directly — no []byte() at all
}
```

Uber names this directly: avoid repeated string-to-byte conversions — perform the conversion once and capture the result ([Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md#avoid-repeated-string-to-byte-conversions)). Many stdlib APIs accept *either* form (`io.WriteString`, `bufio.Writer.WriteString`, `bytes`/`strings` mirror packages) — pick the one that avoids a conversion. To **mutate** text, the round-trip is unavoidable and correct: `b := []byte(s); b[0] = 'H'; s = string(b)` (two copies, by design). `[]byte` is an ordinary slice — its view/aliasing semantics belong to `go-slices-and-maps`; the allocation cost belongs to `go-performance`.

---

## 8. The `strings` Package Toolbox

Before hand-rolling a parser over `s[i]`, check `strings` — the standard tools are clearer and already correct on UTF-8 boundaries.

- **`strings.Cut(s, sep)`** (Go 1.18) — "slices s around the first instance of sep, returning the text before and after sep. The found result reports whether sep appears in s" ([pkg.go.dev/strings](https://pkg.go.dev/strings#Cut)). This is the modern replacement for the `Index`+slice dance and for many `SplitN(s, sep, 2)` uses. Version-gate per `go-version-feature-map`.
- **`strings.Fields(s)`** — splits "around each instance of one or more consecutive white space characters ... Every element of the returned slice is non-empty" ([pkg.go.dev/strings](https://pkg.go.dev/strings#Fields)). Don't `Split(s, " ")` and then filter empties.
- **`strings.TrimSpace(s)`** — "returns a slice (substring) of the string s, with all leading and trailing white space removed, as defined by Unicode" ([pkg.go.dev/strings](https://pkg.go.dev/strings#TrimSpace)).

```go
// WRONG — manual index dance, off-by-one prone
i := strings.Index(line, "=")
key, val := line[:i], line[i+1:] // panics if '=' absent (i == -1)

// RIGHT — Cut handles the not-found case in the boolean
key, val, ok := strings.Cut(line, "=")
if !ok {
	return fmt.Errorf("no '=' in %q", line)
}
```

**A note on comparison.** Equal-looking text can differ at the byte level: `é` may be one rune (`é`) or `e`+combining-accent (`é`). "Using a byte-to-byte comparison to determine equality would clearly not give the right result for these two strings" ([The Go Blog — normalization](https://go.dev/blog/normalization)). For user-facing equality, normalize first via `golang.org/x/text/unicode/norm`; plain `==` compares bytes, which is right only for already-normalized or pure-ASCII input.

---

## 9. Routing to Related Skills

- `go-idiomatic-discipline` — the policy root; "prefer the most standard tools" (Builder, strconv, the `strings` package) over hand-rolled byte loops.
- `go-slices-and-maps` — `[]byte` is an ordinary slice; the view/length/aliasing semantics and why a conversion copies.
- `go-performance` — the allocation cost of `[]byte`/`[]rune`/`string` conversions and `+=`; measure with `-benchmem` before optimizing.
- `go-zero-values-and-construction` — `strings.Builder` and `bytes.Buffer` as types with a useful zero value (no constructor).
- `go-json` — `[]byte` payloads from `Marshal`, and the `string`↔`[]byte` boundary at encode/decode.
- `go-tooling-and-static-analysis` — `go vet`'s **stringintconv** check that catches the `string(int)` trap in CI.
- `go-version-feature-map` — `strings.Cut` (1.18) and other version-gated helpers; "idiomatic" means *available in this module's `go` directive*.
- `go-error-handling` — `strconv.Atoi`/`ParseInt` return an `error`; check it, never discard.

---

## 10. Reference Files

High-frequency strings/bytes/runes 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)

