Working with Strings

Updated

September 13, 2026

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.go

Output:

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.go

Output:

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.go

Output:

[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.go

Output:

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.go

Output:

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.go

Output:

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. len is bytes.
  • Range for characters. Index for bytes you have measured.
  • strings for search, split, join, trim. Do not write those loops.
  • strings.Builder (or bytes.Buffer) when building in a loop.
  • []byte(s) and string(buf) copy. Do not expect aliasing.
  • Keep prices and IDs out of strings until you print them.

Try this

  1. In string_len.go, use "tea 🍵" and print bytes vs runes (the emoji is four UTF-8 bytes).
  2. In strings_menu.go, strings.ReplaceAll(line, "tea", "chai") and print.
  3. In builder.go, call b.Grow(64) before the loop. Behaviour stays the same; one allocation is reserved up front.
  4. Convert "café" to []rune, change index 3, convert back, and print. Compare with s[:4].