Foundations Updated 2026-09 View as Markdown

Strings and Runes

A Go string is a read-only slice of bytes, not characters. Understanding that distinction prevents a specific and common category of bug.

go
s := "Hello, 世界"
fmt.Println(len(s))          // 13, not 9 — that is BYTES

A Go string is an immutable sequence of bytes, conventionally holding UTF-8. len gives you the byte count, and indexing gives you a byte:

go
fmt.Println(s[0])            // 72 — a byte, not "H"
fmt.Println(string(s[0]))    // "H"
fmt.Println(s[7])            // 228 — the first byte of 世, meaningless alone

That is the source of the bug: slicing a string at an arbitrary byte offset can cut a multi-byte character in half.

Runes#

A rune is an alias for int32 and holds one Unicode code point.

go
fmt.Println(utf8.RuneCountInString(s))    // 9 — actual characters

runes := []rune(s)
fmt.Println(len(runes))                    // 9
fmt.Println(string(runes[7]))              // 世

Converting to []rune allocates and copies, so do it when you need character-level indexing, not in a hot loop.

Ranging over a string#

range decodes UTF-8 for you, which is almost always what you want:

go
for i, r := range s {
    fmt.Printf("%d: %c\n", i, r)
}
// i is the BYTE index, r is a rune
// 0: H ... 7: 世  10: 界

Note the index jumps from 7 to 10 — occupies three bytes. Ranging is the safe way to walk a string; for i := 0; i < len(s); i++ walks bytes and will split characters.

Immutability#

go
s[0] = 'h'          // compile error: cannot assign

Strings cannot be modified. To change one, build a new one:

go
b := []byte(s)
b[0] = 'h'
s = string(b)

Both conversions copy. That is why concatenating in a loop is expensive.

Building strings#

go
// BAD: quadratic — each += allocates and copies the whole string
var out string
for _, w := range words {
    out += w + " "
}

// GOOD
var b strings.Builder
b.Grow(64)                     // optional, avoids regrowth
for _, w := range words {
    b.WriteString(w)
    b.WriteByte(' ')
}
out := b.String()

// BEST when you already have the slice
out := strings.Join(words, " ")

This is the most common performance mistake in generated Go — see the performance page.

The strings package#

go
strings.Contains(s, "ell")          // true
strings.HasPrefix(s, "Hello")       // true
strings.HasSuffix(s, "界")           // true
strings.Index(s, "o")               // 4, or -1
strings.Split("a,b,c", ",")         // []string{"a","b","c"}
strings.Join([]string{"a","b"}, "-")// "a-b"
strings.TrimSpace("  hi  ")         // "hi"
strings.Trim("xxhixx", "x")         // "hi"
strings.ReplaceAll("a-b-c", "-", "+")
strings.ToUpper(s)
strings.Fields(" a  b ")            // []string{"a","b"} — splits on any whitespace
strings.EqualFold("Go", "GO")       // true — case-insensitive compare
strings.Cut("key=value", "=")       // "key", "value", true

strings.Cut is the modern way to split on the first occurrence and is clearer than Index plus slicing.

Converting to and from numbers#

go
n, err := strconv.Atoi("42")               // string -> int
s := strconv.Itoa(42)                      // int -> string
f, err := strconv.ParseFloat("3.14", 64)
b, err := strconv.ParseBool("true")
s := strconv.FormatInt(255, 16)            // "ff"

Formatting#

go
fmt.Sprintf("%s is %d years old", name, age)
fmt.Sprintf("%.2f", 3.14159)        // "3.14"
fmt.Sprintf("%v", someStruct)       // default representation
fmt.Sprintf("%+v", someStruct)      // with field names
fmt.Sprintf("%#v", someStruct)      // Go syntax
fmt.Sprintf("%T", someValue)        // the type
fmt.Sprintf("%q", "hi")             // `"hi"` — quoted

%+v is the one to remember for debugging structs, and %T for "what actually is this".

Exercise#

go
package main

import "fmt"

func main() {
	phrase := "Go is 简单"
	// Print, one per line:
	//   the byte length
	//   the rune count
	//   each rune with its byte index, using range
	//   the phrase reversed BY RUNE (not by byte)
	fmt.Println(phrase)
}

Common questions#

Why is len() giving me the wrong length?#

It is giving you the byte length, which differs from the character count for any non-ASCII text. Use utf8.RuneCountInString for characters, and range to iterate them safely.

When should I use []byte instead of string?#

When you are building or mutating data, doing I/O, or working with a package that expects bytes. Strings are immutable so every modification copies; []byte avoids that. Converting between them copies, so pick one representation and stay in it within a hot path.

Is strings.Builder worth it for a few concatenations?#

No — for two or three, + is clearer and the difference is irrelevant. It matters inside loops, where += is quadratic because each step copies everything accumulated so far.

Get the Go agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Go. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.