You write a profile-card component that truncates a bio to a fixed length so every card is the same height: bio[:20]. It passes every test, because every test uses a plain ASCII name. Then a real signup comes in with "café owner, coffee lover" in their bio, and the card renders caf followed by a broken character — the browser's best attempt at a byte sequence that is no longer valid UTF-8. Nothing crashed. Nothing logged an error. The bug is silent, and it only ever hits the users whose names and words don't fit in ASCII.
bio := "café owner, coffee lover"
short := bio[:4]
fmt.Println(short) // prints caf + one stray byte: invalid UTF-8The three sections below are why this happens, and the fix — rune-aware truncation — is the last thing on this page.
A Go string is an immutable, read-only slice of bytes; by convention (not enforcement) that byte sequence holds UTF-8 encoded text. A rune (an alias for int32) represents a single Unicode code point. Because UTF-8 encodes a code point in anywhere from 1 to 4 bytes, indexing a string with s[i] gives you one raw byte, which is only ever the whole character for plain ASCII text.
Indexing gives bytes, not characters
Because a string is a byte slice underneath, s[i] yields a single byte (a uint8) at that byte offset — for ASCII text this happens to match each character, but for any multi-byte UTF-8 character it splits it apart.
s := "café" // é is 2 bytes in UTF-8
fmt.Println(len(s)) // 5 — byte length, not character count (4 characters)
fmt.Println(s[3]) // 195 — a raw byte, half of é's 2-byte encoding, not 'é'range over a string gives runes
Unlike plain indexing, for range over a string decodes the UTF-8 correctly: it gives you the byte offset where each rune starts, and the decoded rune itself (as an int32 code point) — this is the idiomatic way to iterate character-by-character.
for i, r := range "café" {
fmt.Printf("%d: %c\n", i, r)
}
// 0: c
// 1: a
// 2: f
// 3: é — correctly decoded as ONE rune, even though it's 2 bytes
fmt.Println(len([]rune("café"))) // 4 — the actual character countConverting between strings, bytes, and runes
Converting a string to []byte gives the raw UTF-8 bytes; converting to []rune decodes it into Unicode code points, which is what you want when you need an accurate character count or to index by character rather than by byte. Both conversions copy the data, since strings are immutable.
s := "héllo"
b := []byte(s) // raw UTF-8 bytes, len 6
r := []rune(s) // decoded code points, len 5 — the real character count
back := string(r) // convert back to a string: "héllo"Which is the fix for the bio truncation at the top of this page: convert to []rune first, slice by rune count instead of byte count, then convert back. The result is cut at a character boundary, not partway through one.
bio := "café owner, coffee lover"
runes := []rune(bio)
short := string(runes[:4])
fmt.Println(short) // café — sliced by character, not cut mid-byte