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

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)

🎯 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!

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

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.