22. Strings, bytes and runes
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.