Runes, Bytes & UTF-8
Reviewed & published by Brayan K
A byte is a single uint8 of raw UTF-8 data while a rune is an int32 holding one full Unicode character, and understanding the difference is the key to handling non-ASCII text correctly in Go.
Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Tell a byte (uint8) from a rune (int32)
- Understand why len() counts bytes not characters
- Count characters with utf8.RuneCountInString
- See how range over a string yields runes
- Convert between []byte and []rune
- Reverse text correctly with []rune
1️⃣ Bytes, Runes & Why len() Counts Bytes
Go source code is UTF-8, and a string stores that UTF-8 directly as bytes. A byte is a uint8 — one slot of the encoding. A rune is an int32 — one whole Unicode code point. ASCII characters fit in a single byte, but accented or non-Latin characters take 2-4. That's why len("héllo") is 6 (bytes) while the character count is 5, which you get from utf8.RuneCountInString.
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "héllo" // the é is 2 bytes in UTF-8
// len counts BYTES, not characters.
fmt.Println("bytes:", len(s)) // 6
// RuneCountInString counts CHARACTERS (runes).
fmt.Println("runes:", utf8.RuneCountInString(s)) // 5
// Indexing gives a raw byte; for é you'd see only half of it.
fmt.Println("s[1] byte:", s[1]) // 195 (first byte of é)
}2️⃣ Ranging Over a String Yields Runes
The for ... range loop over a string is UTF-8 aware. Each iteration decodes one rune and gives you two values: the byte index where that character starts and the rune itself. Because a multi-byte character advances the index by more than one, you'll see the index jump — proof that range is walking by character, not by byte. A rune literal like 'A' is just an int32 code point (65).
package main
import "fmt"
func main() {
s := "Gé!" // 'é' takes 2 bytes, so byte indexes will SKIP.
// Ranging over a string yields (byteIndex, rune) pairs.
for i, r := range s {
fmt.Printf("index %d: %c (rune %d)\n", i, r, r)
}
fmt.Println("---")
// A rune is just an int32 holding a Unicode code point.
var r rune = 'A'
fmt.Println(r) // 65
fmt.Printf("%c %d\n", r, r) // A 65
}3️⃣ []rune vs []byte
Converting a string makes its structure explicit. []byte(s) gives the raw UTF-8 bytes — perfect for I/O and byte-level work. []rune(s) gives one element per character, so you can index the Nth character safely and operate per character. Reversing text is the classic case: doing it with bytes would split a multi-byte character into garbage, but reversing a []rune and converting back keeps every character whole.
package main
import "fmt"
func main() {
s := "héllo"
// []byte: the raw UTF-8 bytes. len matches the byte count.
bs := []byte(s)
fmt.Println("byte len:", len(bs)) // 6
// []rune: one element per character. Safe to index by character.
rs := []rune(s)
fmt.Println("rune len:", len(rs)) // 5
fmt.Printf("3rd char: %c\n", rs[2]) // l
// Reversing is correct only with runes — bytes would split the é.
for i, j := 0, len(rs)-1; i < j; i, j = i+1, j-1 {
rs[i], rs[j] = rs[j], rs[i]
}
fmt.Println("reversed:", string(rs)) // olléh
}🎯 Your Turn
Fill in the blanks to print both the byte count and the character count of "café" — they differ because é is two bytes.
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "café" // the é is 2 bytes
// 1) Print the number of BYTES.
fmt.Println("bytes:", ___(s)) // 👉 len
// 2) Print the number of RUNES (characters).
fmt.Println("runes:", utf8.___(s)) // 👉 RuneCountInString
// ✅ Expected output:
// bytes: 5
// runes: 4
}Common Errors (and the fix)
- ❌ Using len(s) to count characters — that's the byte count. ✅ Use utf8.RuneCountInString(s) or len([]rune(s)).
- ❌ Indexing s[i] expecting a character — you get a single byte. ✅ Convert to []rune and index that, or range the string.
- ❌ Reversing a string by bytes — multi-byte characters get split into invalid bytes. ✅ Reverse a []rune instead.
- ❌ string(65) surprises you — converting an int to string yields the character for that code point ("A"), not "65". ✅ Use strconv.Itoa for the digits.
🎯 Mini-Challenge: Reverse a Unicode String
Reverse "go世界" the correct way. Convert to []rune, swap from both ends, then convert back with string(rs). If you tried this with bytes the CJK characters would shatter.
package main
import "fmt"
func main() {
// 🎯 MINI-CHALLENGE: reverse a string correctly.
// Convert to []rune so multi-byte characters stay intact,
// swap from both ends, and print the reversed string.
//
// ✅ Expected output: 界世og
s := "go世界"
// your code here
}🎉 Lesson Complete!
- ✅ A byte is uint8; a rune is int32 (one Unicode character)
- ✅ len counts bytes; use utf8.RuneCountInString for characters
- ✅ range over a string yields (byteIndex, rune) and skips multi-byte gaps
- ✅ []byte is raw UTF-8; []rune is one element per character
- ✅ Reverse or index per character via []rune to avoid splitting characters
Practice quiz
A byte in Go is an alias for which type?
- int32
- rune
- uint8
- int8
Answer: uint8. byte is an alias for uint8 — one 8-bit value of the raw UTF-8 encoding.
A rune in Go is an alias for which type?
- int32
- uint8
- int64
- uint32
Answer: int32. rune is an alias for int32 and holds one full Unicode code point.
What does len("héllo") return, where é is 2 bytes in UTF-8?
- 4
- 5
- 7
- 6
Answer: 6. len counts bytes: h-é(2)-l-l-o is 6 bytes.
How do you count the number of characters (runes) in a string s?
- len(s)
- utf8.RuneCountInString(s)
- cap(s)
- s.Count()
Answer: utf8.RuneCountInString(s). utf8.RuneCountInString(s) (or len([]rune(s))) counts characters, not bytes.
What does utf8.RuneCountInString("café") return, where é is 2 bytes?
- 4
- 3
- 5
- 6
Answer: 4. There are 4 characters (c, a, f, é) even though it is 5 bytes.
Ranging over a string with for i, r := range s yields what for each iteration?
- A byte index and a byte
- A rune index and a rune
- A byte index and a rune
- A character index and a byte
Answer: A byte index and a rune. range is UTF-8 aware: it gives the starting byte index and the decoded rune.
Ranging over "Gé!", what byte index does the '!' character have?
- 1
- 3
- 2
- 4
Answer: 3. G is byte 0, é occupies bytes 1-2, so '!' starts at byte index 3.
Indexing a string with s[1] returns what kind of value?
- A one-character string
- A rune
- A pointer
- A single byte (uint8)
Answer: A single byte (uint8). Indexing a string always yields a single byte, not a character.
Why reverse a string by converting to []rune rather than []byte?
- []rune is faster to allocate
- Each []rune element is a whole character, so multi-byte characters stay intact
- []byte cannot be reversed
- string(rs) only works on bytes
Answer: Each []rune element is a whole character, so multi-byte characters stay intact. Reversing bytes would split multi-byte UTF-8 sequences; []rune keeps characters whole.
What does the conversion string(rune(65)) produce?
- "65"
- 65
- "A"
- An error
Answer: "A". Converting an int code point to string yields the character for that code point — "A".
Continue this course
- Previous: Strings & the strings Package
- Next: Formatting with fmt (Printf, Sprintf) — Verbs like %d %s %v %+v %T %q, width/precision, and the Stringer interface
Frequently asked questions
What is the difference between a byte and a rune in Go?
A byte is an alias for uint8 — a single 8-bit value, one piece of the raw UTF-8 encoding. A rune is an alias for int32 and holds one full Unicode code point (one character). A character like é or 世 is one rune but two or three bytes, so the distinction matters whenever your text isn't plain ASCII.
Why does len() not match the number of characters I see?
len() counts bytes, and UTF-8 encodes many characters in more than one byte. For example "héllo" is 6 bytes but 5 characters, because é takes 2 bytes. When you want the character (rune) count, use utf8.RuneCountInString(s) or len([]rune(s)).
Why does ranging over a string give me a rune instead of a byte?
The for...range loop over a string is UTF-8 aware: it decodes one rune per iteration and gives you the byte index where that rune starts plus the rune itself. That's why the index can jump by more than 1 — a 2-byte character advances the index by 2. If you truly want raw bytes, index with s[i] or range over []byte(s) instead.
When should I use []rune versus []byte?
Use []byte when you care about the raw UTF-8 bytes — I/O, hashing, or byte-level manipulation. Use []rune when you need to work per character, such as indexing the Nth character, reversing text, or counting characters. Converting to []rune makes each element exactly one character, which avoids splitting multi-byte runes.
Why does reversing a string by bytes corrupt it?
Reversing the bytes of a multi-byte character splits its UTF-8 sequence and produces invalid bytes (often shown as the replacement character). Converting to []rune first means each element is a whole character, so swapping from both ends and converting back with string(rs) keeps every character intact.