The Twenty-Character Limit That Ate a Name
A signup form enforces "usernames up to 20 characters" with one line:
func shortName(name string) string {
if len(name) > 20 {
return name[:20]
}
return name
}
For "Marguerite" it behaves. For "Zoësmörgåsbord" — or any name with non-ASCII letters — it can return a string that prints as Zoësmörgåsbord with a replacement character glued to the end, fails a round trip through a JSON encoder, or trips a validation regex that expects valid UTF-8. The line confused two different measurements.
A Go string is an immutable sequence of bytes, and for text those bytes are UTF-8 encoded. One character does not equal one byte: é is 2 bytes, 日 is 3, 😀 is 4, plain a is 1. So:
fmt.Println(len("Zoë")) // 4 — bytes: Z, o, c3, af
fmt.Println(len("日本語")) // 9 — three 3-byte characters
fmt.Println(len("héllo")) // 6 — é costs two bytes
Indexing has the same altitude: s[i] is a byte (a uint8), never a character. s[0] for "日" is 0xE6, the first third of the character. Slicing s[:2] therefore cuts the character in half — the bytes form an invalid UTF-8 sequence, and anything that later decodes the string (printing, scanning, re-encoding) shows the corruption.
This is not a defect of the design; UTF-8 byte length is the measurement protocols, buffer limits, and database columns actually care about. len(s) <= 100 is a perfectly good payload-size check. The bug appears only when a byte budget is enforced against a character requirement, and the corrected validator measures in the unit the requirement named:
func shortName(name string) string {
if utf8.RuneCountInString(name) <= 20 {
return name
}
return string([]rune(name)[:20]) // decode, cut at a character, re-encode
}
RuneCountInString decodes without allocating a rune slice, and the []rune round trip guarantees the cut lands on a boundary — the conversion cost is trivial on a username. Two measurements, one rule: decide whether the spec said bytes or characters before writing the comparison. Byte truth and character truth are separate questions, and the habit of asking which one the requirement meant is what keeps every limit and every truncation correct — the next chapter builds on it with a cut that needs no rune slice at all.
range Decodes for You — And Hands You Byte Offsets
Ranging over a string does not walk bytes blindly. The loop decodes one UTF-8 character per iteration and gives you the rune plus the byte offset where it starts:
s := "héllo"
for i, r := range s {
fmt.Printf("%d: %q (%d bytes)\n", i, r, len(string(r)))
}
// 0: 'h' (1 bytes)
// 1: 'é' (2 bytes)
// 3: 'l' (1 bytes)
// ...
Note the offsets jump by the encoded size — i is a position in the byte string, not a character counter. That single fact gives you two correct truncation strategies.
Count while scanning, cut at the last byte offset:
func truncate(s string, max int) string {
if max <= 0 {
return ""
}
seen := 0
last := 0
for i, r := range s {
if seen == max {
return s[:i] // i is the byte offset where rune max would start
}
seen++
last = i + len(string(r))
}
return s[:last] // whole string fits
}
Convert, slice by characters, convert back:
func truncateSimple(s string, max int) string {
r := []rune(s) // decodes every character into its own slot
if len(r) > max {
r = r[:max]
}
return string(r) // re-encodes to UTF-8
}
[]rune(s) allocates one slot per character and string(runes) allocates again on the way back — fine for short strings and user-facing paths, wasteful inside a hot loop over megabytes of text. The scanning version allocates nothing and keeps byte positions, which matters when the truncated result must align with other byte-indexed data.
Two conversions worth separating: string(byte) builds a one-character string from a byte value (with the byte's numeric value as the code point — string(65) is "A"), while string(runes) encodes a rune sequence. Mixing them up is a classic source of mystery strings containing control characters. When you need characters, decode; when you need positions, stay in bytes; when you need both in one pass, range hands you both at once.
Pick Your Altitude: Bytes for Positions, Runes for Meaning
Production text handling is mostly a routing problem — each task has a natural altitude, and picking the wrong one is what produces mojibake and off-by-N bugs.
| Task | Altitude | Tool |
|---|---|---|
| payload limits, hex dumps, protocol framing | bytes | len, s[i], for range []byte(s) |
| counting characters, truncating display text | runes | range s, []rune(s), utf8.RuneCountInString |
| scanning ASCII structure inside text | bytes | s[i] < 0x80 checks |
| building a string piece by piece | either | strings.Builder |
A byte loop is correct — and faster — when the job is structural. Validating that a token contains only [a-z0-9-], locating a colon in "host:port", or lowercasing ASCII protocol keywords never needs decoding:
func isSlug(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
ok := c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-'
if !ok {
return false
}
}
return true
}
When one pass must decode but the input is untrusted — log lines, uploads, user bios — decode manually so invalid bytes cannot derail you:
func printable(s string) bool {
for i := 0; i < len(s); {
r, size := utf8.DecodeRuneInString(s[i:])
if r == utf8.RuneError && size <= 1 {
return false // invalid byte, or a lone encoded RuneError
}
if r < 0x20 && r != '\n' && r != '\t' {
return false // control character
}
i += size // advance by the encoded width, never by 1
}
return true
}
DecodeRuneInString returns the rune and how many bytes it occupied. For malformed input it yields RuneError with size 1 — advancing by exactly one byte guarantees the loop terminates even on garbage, which is why the i += size pattern is the safe streaming form. (range over a string makes the same choice internally: undecodable bytes surface as one U+FFFD each.)
Finally, assemble output with strings.Builder rather than + in a loop — each + allocates a fresh string, while a builder appends into growing storage:
var b strings.Builder
b.Grow(len(in)) // capacity hint, same spirit as make([]byte, 0, n)
for i, r := range in {
if r == '\t' {
continue
}
b.WriteString(in[i : i+utf8.RuneLen(r)])
}
out := b.String()
Bytes for positions, runes for characters, one decoder for untrusted input — the rest of the standard library (strings and fmt) assumes you have already made that choice before you call it.
Quiz
1. What does len("héllo") report, and what does it measure?
2. A validator does name[:20] when len(name) > 20 on UTF-8 input. What can go wrong?
3. In for i, r := range s, what are i and r?
4. You decode untrusted bytes with utf8.DecodeRuneInString and get (utf8.RuneError, 1). What should the loop do?
Flashcards
Question
Why does len("Zoë") return 4 when the name has three letters?
Click to reveal answer
Answer
len reports the byte length of the UTF-8 encoding: Z and o are one byte each, and ö is two bytes (C3 B6), so three characters occupy four bytes. Counting characters requires decoding — utf8.RuneCountInString or a range loop.
Question
What two values does range over a string yield, and which one is safe to slice with?
Click to reveal answer
Answer
The decoded rune and the byte offset where that rune begins. The offset is the slice-safe value: s[i:] starts exactly at a character boundary, so truncation logic cuts with it instead of with a character counter.
Question
When is iterating a string byte by byte the correct choice rather than a shortcut bug?
Click to reveal answer
Answer
When the task is structural instead of linguistic: enforcing an ASCII allowlist, locating delimiters like ':' or ',', framing length-prefixed payloads, or hex output. Those operate on byte values, and staying in bytes avoids decode cost and keeps indices valid for direct slicing.
Question
What is the difference between string(byte) and string([]rune) in Go?
Click to reveal answer
Answer
string(byte) — or string(int) — treats the number as a code point and produces a one-rune string, so string(65) is "A" and string(228) is "ä", not a raw byte. string([]rune) encodes the whole rune sequence into UTF-8. Confusing the scalar form with byte casts produces strings full of unexpected characters.
Question
You must scan a megabyte of text for ASCII delimiters. Why avoid []rune conversion up front?
Click to reveal answer
Answer
[]rune decodes every character and allocates four bytes per slot — a megabyte of ASCII becomes a four-megabyte allocation plus GC pressure for data you never needed decoded. A byte loop with s[i] checks touches the original string and allocates nothing.
Revision Notes
Key Takeaways
- 1. A Go string is immutable UTF-8 bytes: len and s[i] speak bytes, and slicing never respects character boundaries
- 2. range over a string decodes one rune per iteration and reports its starting byte offset — the safe cut point for truncation
- 3. []rune(s) plus string(runes) is the simple character-slice path; it allocates twice, so keep it off hot paths
- 4. Decode untrusted input with DecodeRuneInString and advance by the returned size; size-1 RuneError results keep the loop safe on garbage
- 5. Byte altitude for framing and ASCII checks, rune altitude for display and counting — choosing the altitude is most of the correctness
Interview Tips
- • "How do you truncate a UTF-8 string?" — show range-based cutting by byte offset, then mention the []rune shortcut and its allocation cost
- • "What does len(string) return?" — bytes; immediately follow with 'characters need utf8.RuneCountInString' to show you know both measurements
- • When asked about emoji length, give the range: 1 to 4 bytes per rune — so a '40-character' limit may be up to 160 bytes
- • "How do you safely iterate untrusted bytes?" — DecodeRuneInString with i += size, and explain why size 1 on RuneError prevents infinite loops
Cheat Sheet
Strings, Bytes, and Runes
Pick by question asked:
| Question | Unit | Tool |
|---|---|---|
| how big is the payload? | bytes | len(s) |
| how many characters? | runes | utf8.RuneCountInString(s) |
| cut after N characters? | runes | range offsets or []rune(s) |
| is this ASCII structure? | bytes | s[i] comparisons |
| decode untrusted text safely? | runes | utf8.DecodeRuneInString + i += size |
UTF-8 sizes: ASCII = 1 byte · Latin/Greek/Cyrillic extended = 2 · CJK & most emoji = 3 · rare emoji = 4.
Code template:
// Truncate at a character boundary with zero allocation.
func truncate(s string, max int) string {
seen, last := 0, 0
for i, r := range s {
if seen == max {
return s[:i] // byte offset of the (max+1)-th rune
}
seen++
last = i + utf8.RuneLen(r)
}
return s[:last]
}
// Safe single pass over untrusted text.
for i := 0; i < len(s); {
r, size := utf8.DecodeRuneInString(s[i:])
if r == utf8.RuneError && size <= 1 { /* skip or reject byte */ }
i += size
}
Traps: s[:n] is a byte cut that can split a character · string(65) is "A" (code point), not a byte cast · + in a loop allocates per iteration — use strings.Builder.
Continue Learning
Before You Start
- No prerequisites listed.