Working with Strings
Working with Strings
A Go string is an immutable sequence of bytes, usually UTF-8. The boring default is: range for runes, len for bytes, unicode/utf8 when you need a count of characters, and strings.Builder when you are assembling text in a loop.
Mental model
len(s) is the number of bytes. Indexing s[i] is a byte. for i, r := range s walks runes and gives the byte index of each.
You cannot write s[0] = 'x'. Conversion []byte(s) copies. Conversion string(bytes) copies. strings.Builder is the standard library’s append-only buffer that becomes a string once.
Worked examples
Case 1: Bytes, not letters
Save as string_len.go. "café" is four letters and five bytes because é is C3 A9 in UTF-8.
// string_len.go
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "café"
fmt.Println("bytes", len(s))
fmt.Println("runes", utf8.RuneCountInString(s))
fmt.Printf("s[3] byte %d\n", s[3])
}Run:
go run string_len.goOutput:
bytes 5
runes 4
s[3] byte 195
s[3] is the first byte of é (0xC3 = 195), not the letter é. Do not index a string to get “the fourth character” unless you have proven the text is ASCII.
Case 2: range walks runes
Save as range_runes.go. The index jumps by the size of each encoding.
// range_runes.go
package main
import "fmt"
func main() {
for i, r := range "café" {
fmt.Printf("%d %q %U\n", i, r, r)
}
}Run:
go run range_runes.goOutput:
0 'c' U+0063
1 'a' U+0061
2 'f' U+0066
3 'é' U+00E9
There is no index 4 in this loop. The last rune starts at byte 3 and occupies two bytes.
Case 3: The strings package
Save as strings_menu.go. Split, trim, join, contains. These copy; they do not edit in place.
// strings_menu.go
package main
import (
"fmt"
"strings"
)
func main() {
line := " toast, tea, soup "
line = strings.TrimSpace(line)
parts := strings.Split(line, ", ")
fmt.Println(parts)
fmt.Println(strings.Join(parts, " | "))
fmt.Println("has tea", strings.Contains(line, "tea"))
fmt.Println("upper", strings.ToUpper(parts[0]))
}Run:
go run strings_menu.goOutput:
[toast tea soup]
toast | tea | soup
has tea true
upper TOAST
TrimSpace returns a new string. The original literal is still the original literal.
Case 4: strings.Builder in a loop
Save as builder.go. Do not s = s + piece inside a hot loop; each + copies the whole string. A builder grows a byte buffer.
// builder.go
package main
import (
"fmt"
"strings"
)
func main() {
items := []string{"toast", "tea", "soup"}
var b strings.Builder
for i, item := range items {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "%d:%s", i+1, item)
}
fmt.Println(b.String())
}Run:
go run builder.goOutput:
1:toast, 2:tea, 3:soup
fmt.Fprintf(&b, ...) works because Builder implements io.Writer. b.String() is the snapshot.
Case 5: Conversions copy
Save as bytes_copy.go. Mutating the slice does not change the string.
// bytes_copy.go
package main
import "fmt"
func main() {
s := "tea"
buf := []byte(s)
buf[0] = 'T'
fmt.Println("string", s)
fmt.Println("bytes", string(buf))
}Run:
go run bytes_copy.goOutput:
string tea
bytes Tea
string(buf) is another copy. After that line, more writes to buf would not change the new string either.
The trap
Indexing through a UTF-8 string as if it were an array of letters cuts a rune in half. Save as cut_rune.go:
// cut_rune.go
package main
import "fmt"
func main() {
s := "café"
fmt.Printf("s[:3] = %q\n", s[:3])
fmt.Printf("s[:4] = %q\n", s[:4])
fmt.Printf("range last = ")
var last rune
for _, r := range s {
last = r
}
fmt.Printf("%q\n", last)
}Run:
go run cut_rune.goOutput:
s[:3] = "caf"
s[:4] = "caf\xc3"
range last = 'é'
s[:4] ends in the middle of é. The printed à is the stray C3 byte shown as Latin-1. The fix: range, or utf8.DecodeLastRuneInString, or convert to []rune when you truly need character indexes ([]rune(s)[3] is é, and it copies).
The boring rule
- Treat strings as UTF-8 bytes.
lenis bytes. - Range for characters. Index for bytes you have measured.
stringsfor search, split, join, trim. Do not write those loops.strings.Builder(orbytes.Buffer) when building in a loop.[]byte(s)andstring(buf)copy. Do not expect aliasing.- Keep prices and IDs out of strings until you print them.
Try this
- In
string_len.go, use"tea 🍵"and print bytes vs runes (the emoji is four UTF-8 bytes). - In
strings_menu.go,strings.ReplaceAll(line, "tea", "chai")and print. - In
builder.go, callb.Grow(64)before the loop. Behaviour stays the same; one allocation is reserved up front. - Convert
"café"to[]rune, change index3, convert back, and print. Compare withs[:4].