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
"As implied up front, indexing a string accesses individual bytes, not characters." — Strings, bytes, runes and characters in Go
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). 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.
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).
So len(s) is the byte count, and the character count is a different number you must ask for explicitly:
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). 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).
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). The index i therefore jumps by the rune's byte width (1, 2, 3, or 4), and r is a rune.
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). 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).
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).
// 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) — 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); Uber's guide is blunt: prefer strconv over fmt because, when converting primitives to/from strings, strconv is faster (Uber Go Style Guide).
// 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). 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); 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:
// 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). 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). This is the modern replacement for theIndex+slice dance and for manySplitN(s, sep, 2)uses. Version-gate pergo-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). Don'tSplit(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).
// 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 (é). "Using a byte-to-byte comparison to determine equality would clearly not give the right result for these two strings" (The Go 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, thestringspackage) over hand-rolled byte loops.go-slices-and-maps—[]byteis an ordinary slice; the view/length/aliasing semantics and why a conversion copies.go-performance— the allocation cost of[]byte/[]rune/stringconversions and+=; measure with-benchmembefore optimizing.go-zero-values-and-construction—strings.Builderandbytes.Bufferas types with a useful zero value (no constructor).go-json—[]bytepayloads fromMarshal, and thestring↔[]byteboundary at encode/decode.go-tooling-and-static-analysis—go vet's stringintconv check that catches thestring(int)trap in CI.go-version-feature-map—strings.Cut(1.18) and other version-gated helpers; "idiomatic" means available in this module'sgodirective.go-error-handling—strconv.Atoi/ParseIntreturn anerror; 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
Source provenance for every claim in this skill:
references/sources.yaml