Strings Under the Hood

Strings Under the Hood

Strings Under the Hood

A Go string is a chunk of bytes. For ASCII text you never notice, but non-English characters and emoji force the distinction into the open.

Strings are bytes

example.go
s := "世界"
fmt.Println(len(s))  // 6, not 2

len() counts bytes. UTF-8 encodes as 3 bytes and as 3 bytes, so len("世界") is 6.

Indexing has the same problem:

example.go
s := "世界"
fmt.Println(s[0])  // 228

s[0] returns a single byte. For "Hello" that happens to match the character. For "世界" it's a meaningless fragment of a multi-byte rune.

Range decodes runes

range over a string yields one rune per iteration, regardless of how many bytes that rune occupies:

example.go
for i, ch := range "世界" {
    fmt.Printf("%d: %c\n", i, ch)
}
// 0: 世
// 3: 界

The index jumps from 0 to 3 because range skipped the remaining bytes of . You get characters, not byte fragments.

Runes are integers

A rune is just an int32, so you can compare runes with integer operators:

example.go
for _, ch := range s {
    if ch >= 'a' && ch <= 'z' {
        // lowercase letter
    }
}

Character literals like 'a' and 'z' are integer constants, so the comparison is a plain numeric range check. The same pattern works for uppercase, digits, or any contiguous span of code points.

Helpers in unicode/utf8

When you don't need a full loop, unicode/utf8 has drop-in helpers.

Counting characters:

example.go
import "unicode/utf8"

utf8.RuneCountInString("世界") // 2

RuneCountInString returns how many runes the string contains, which is what len() can't tell you.

Decoding the first rune:

example.go
r, size := utf8.DecodeRuneInString("世界")
fmt.Println(r, size)         // 19990 3
fmt.Println(string(r), size) // 世 3

DecodeRuneInString returns two values:

  • r: the first rune, as an int32 code point
  • size: how many bytes that rune took in the original string

Since r is an integer, printing it directly gives you the code point 19990. Use string(r) to convert it back into a readable character.

One trap: on an empty string, DecodeRuneInString returns the Unicode replacement character U+FFFD (), not zero and not an error. Always guard empty input:

example.go
func firstRuneSize(s string) int {
    if s == "" {
        return 0
    }
    _, size := utf8.DecodeRuneInString(s)
    return size
}

Use these helpers when you want characters. Reach for len() and s[i] only when you truly want bytes.

>_Exercise

Exercise: First Character

Write firstChar that returns the first character of a string as a string. It must work for any input, including emoji and non-English text.

Examples:

  • firstChar("Hello") returns "H"
  • firstChar("世界") returns "世"
  • firstChar("🚀 launch") returns "🚀"
  • firstChar("") returns ""

Steps:

  1. If the string is empty, return "" before doing anything else. Decoding an empty string yields the replacement character , not "".
  2. Decode the first rune with a helper from unicode/utf8 (remember to import the package).
  3. Convert that rune to a string before returning it.

Watch out for two byte traps:

  • s[0] is a single byte, not a character, so it can't be returned directly as a string (byte and string are different types).
  • string(s[0]) does compile and passes "Hello", but it converts only that one byte, so it mangles any multi-byte character like or 🚀. Decode a rune instead.
Stuck? Reveal a hint to help you.
Hints (0/4)
Key Takeaway
Next UpFormatting strings like a pro with Sprintf.