22. Strings, bytes and runes

📖 Reading · 10 min
💡 Every code box below is live — edit it and hit Run.

A Go string is not a list of characters. It's an immutable sequence of bytes, holding UTF-8 encoded text. That one sentence explains every surprising thing strings do — why len("héllo") is 6, why indexing gives you a number, and why ranging gives you something else again.

A string is bytes

package main

import "fmt"

func main() {
    s := "hello"
    fmt.Println(len(s))
    fmt.Println(s[0])
    fmt.Println(string(s[0]))

    e := "héllo"
    fmt.Println(len(e))
}

s[0] is 104, not "h" — indexing a string gives you a byte (uint8). And "héllo" is 6 bytes for 5 characters, because é needs two bytes in UTF-8.

So len counts bytes, and indexing addresses bytes. For ASCII text those happen to coincide with characters; for anything else they don't.

Runes: what a "character" is

A rune is Go's name for a Unicode code point. It's an alias for int32, and it's what you get when you range over a string:

package main

import "fmt"

func main() {
    for i, r := range "héllo" {
        fmt.Printf("byte %d: %c (rune value %d)\n", i, r, r)
    }
}

Two things to notice. The index jumps from 1 to 3 — it's a byte offset, so the two-byte é costs one position. And r is the full code point, which %c prints as the character.

Ranging a string decodes UTF-8 for you. That's why for _, ch := range word in the last lesson gave you letters and not bytes.

Counting characters properly

package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {
    s := "héllo"

    fmt.Println("bytes:", len(s))
    fmt.Println("runes:", utf8.RuneCountInString(s))

    runes := []rune(s)
    fmt.Println("as []rune:", len(runes), string(runes[1]))
    fmt.Println("reversed:", string([]rune{runes[4], runes[3], runes[2], runes[1], runes[0]}))
}

Converting to []rune gives you a slice you can index by character. It costs an allocation and a decode pass, so do it when you actually need random access — reversing, or taking "the first 10 characters" of user text.

Use len when you care about size (buffers, limits, storage). Use rune counting when you care about characters (display width, truncation).

Strings are immutable

package main

import "fmt"

func main() {
    s := "hello"
    // s[0] = 'H' // won't compile: cannot assign to s[0]

    b := []byte(s)
    b[0] = 'H'
    fmt.Println(string(b))

    fmt.Println(s + " world")
    fmt.Println(s)
}

You can never modify a string in place. Convert to []byte (or []rune), change that, convert back. Every "modification" — concatenation, replacement, upper-casing — produces a new string.

Note 'H' in single quotes: that's a rune literal, a number. "H" in double quotes is a string. Go keeps them strictly apart.

Why += in a loop is a trap

Because strings are immutable, each += allocates a whole new string and copies everything:

package main

import (
    "fmt"
    "strings"
)

func main() {
    parts := []string{"go", "is", "fast", "when", "you", "build", "right"}

    slow := ""
    for _, p := range parts {
        slow += p + " "
    }

    var b strings.Builder
    for _, p := range parts {
        b.WriteString(p)
        b.WriteString(" ")
    }

    fmt.Println(strings.TrimSpace(slow))
    fmt.Println(strings.TrimSpace(b.String()))
    fmt.Println(strings.Join(parts, " "))
}

All three produce the same text. The first copies the whole accumulated string on every iteration — fine for seven items, quadratic for seventy thousand. strings.Builder appends into a growing byte buffer and converts once at the end; strings.Join is the one-liner when you already have the slice.

Reach for Join first, Builder when you're assembling piece by piece, and += only for a handful of parts.

The strings package essentials

package main

import (
    "fmt"
    "strings"
)

func main() {
    s := "  BrevFeed Go Course  "

    fmt.Printf("%q\n", strings.TrimSpace(s))
    fmt.Println(strings.ToLower(strings.TrimSpace(s)))
    fmt.Println(strings.Contains(s, "Go"), strings.HasPrefix(strings.TrimSpace(s), "Brev"))
    fmt.Println(strings.Split("a,b,c", ","))
    fmt.Println(strings.Fields("  spaced   out   words "))
    fmt.Println(strings.ReplaceAll("go go go", "go", "run"))
    fmt.Println(strings.Repeat("-", 20))
    fmt.Println(strings.Index("hello", "ll"))
}

Split cuts on an exact separator; Fields splits on runs of whitespace and drops the empties — use Fields for words, Split for CSV-ish data. Index returns -1 when not found.

Comparing strings

package main

import (
    "fmt"
    "strings"
)

func main() {
    fmt.Println("go" == "go")
    fmt.Println("Go" == "go")
    fmt.Println(strings.EqualFold("Go", "GO"))
    fmt.Println("apple" < "banana")
}

== on strings is a real value comparison (not pointer identity), < orders them byte by byte, and strings.EqualFold is the case-insensitive comparison — cheaper and more correct than lower-casing both sides.

Your turn

Write a program that counts the vowels in "programming in go" and prints the count, then prints the string with every space removed:

5
programmingingo
package main

import (
    "fmt"
    "strings"
)

func main() {
    s := "programming in go"
    // count vowels (a e i o u), then print s without spaces
}
package main

import (
    "fmt"
    "strings"
)

func main() {
    s := "programming in go"
    count := 0
    for _, r := range s {
        if strings.ContainsRune("aeiou", r) {
            count++
        }
    }
    fmt.Println(count)
    fmt.Println(strings.ReplaceAll(s, " ", ""))
}

That's collections done. Next module: giving your data a shape of its own — structs and methods.