Strings, Bytes, and Runes: Text Without the Lies
A Go string is an immutable sequence of UTF-8 bytes, not a sequence of characters — indexing gives you a byte, len gives you a byte count, and range gives you code points. What that means in practice, and how to build strings without quadratic surprises. Compiled and run against Go 1.26.5.
Most languages let you pretend a string is a list of characters. Go does not, and the honesty is worth the small upfront cost. A Go string is an immutable sequence of bytes, and by strong convention those bytes are UTF-8. That single fact explains every surprise in this chapter: why len counts more than you expect, why indexing hands you a number, and why iterating a string has two completely different behaviors depending on how you write the loop.
If you only ever touch ASCII, you can coast for a while. The moment a name has an accent or your input arrives in Japanese, the difference between a byte and a character stops being academic. So let’s make it concrete, with real output at every step.
A string is bytes, and it is immutable
Two properties define the type. First, immutable: once you have a string, you cannot change a byte of it. s[0] = 'H' does not compile. Operations that “modify” a string actually build a new one, which matters for performance later in this chapter. Second, a byte sequence: the underlying data is raw bytes, and Go makes no promise on its own that they mean anything. The convention that string literals in your source are UTF-8 is what makes text work, but the type itself is bytes.
This is a deliberate design. Go’s authors wanted strings that were cheap to pass around (a string is a small header pointing at read-only bytes, so copying one is copying a pointer and a length), safe to share across goroutines without locking (immutability guarantees it), and honest about encoding rather than hiding it behind a “character” abstraction that leaks the moment you leave ASCII. The tradeoff is that you have to know whether you want bytes or characters, because Go will not guess.
len is a byte count, and indexing gives a byte
Here is the first thing that trips people. len(s) returns the number of bytes, not the number of characters. And s[i] returns the byte at that position, typed as uint8 (Go’s byte is an alias for uint8), not a character.
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "héllo"
fmt.Printf("string: %q\n", s)
fmt.Printf("len(s): %d\n", len(s))
fmt.Printf("rune count: %d\n", utf8.RuneCountInString(s))
jp := "日本語"
fmt.Printf("string: %q\n", jp)
fmt.Printf("len(s): %d\n", len(jp))
fmt.Printf("rune count: %d\n", utf8.RuneCountInString(jp))
b := s[1]
fmt.Printf("s[1] type: %T\n", b)
fmt.Printf("s[1] value: %d\n", b)
}
$ go run .
string: "héllo"
len(s): 6
rune count: 5
string: "日本語"
len(s): 9
rune count: 3
s[1] type: uint8
s[1] value: 195
Read those numbers carefully. "héllo" looks like five characters, and len reports 6, because the é is encoded as two bytes in UTF-8. "日本語" is three characters and reports a len of 9, three bytes each. And s[1] is not 'é'; it is the byte 195, which is only the first half of the two-byte sequence that encodes é. Indexing a string never gives you back a character unless every character happens to be ASCII.
utf8.RuneCountInString, from the standard library’s unicode/utf8 package, is how you count actual characters. Reach for it whenever “how long is this text” means characters rather than storage bytes.
Runes: the character type
Go’s name for a Unicode code point is rune, and it is an alias for int32. A rune holds one code point (the number Unicode assigns to a character), which is why it needs 32 bits: code points run well past what a byte can hold. When you write a character literal like 'é', its type is rune.
So a string has two natural granularities. As bytes, it is however many bytes UTF-8 needed. As runes, it is the sequence of code points those bytes encode. The confusion in most codebases comes from conflating the two. Keep them separate in your head and the rest falls out cleanly.
range iterates runes, with byte offsets
Now the payoff. A for i := 0; i < len(s); i++ loop walks bytes. But for i, r := range s walks runes — and it hands you two things: the byte offset i where the rune starts, and the rune r itself, already decoded. This is the loop you almost always want for text.
package main
import "fmt"
func main() {
s := "héllo"
fmt.Println("by byte index:")
for i := 0; i < len(s); i++ {
fmt.Printf(" %d: %d\n", i, s[i])
}
fmt.Println("by range (runes with byte offsets):")
for i, r := range s {
fmt.Printf(" offset %d: %q (code point %d, type %T)\n", i, r, r, r)
}
}
$ go run .
by byte index:
0: 104
1: 195
2: 169
3: 108
4: 108
5: 111
by range (runes with byte offsets):
offset 0: 'h' (code point 104, type int32)
offset 1: 'é' (code point 233, type int32)
offset 3: 'l' (code point 108, type int32)
offset 4: 'l' (code point 108, type int32)
offset 5: 'o' (code point 111, type int32)
Two details reward a close look. The byte loop shows six entries, including the raw pair 195 169 that together encode é. The range loop shows five, one per character, with é correctly decoded to code point 233 and typed int32 (that is, rune). And the offsets skip from 1 to 3: because é occupied bytes 1 and 2, the next rune l starts at byte offset 3. The index range gives you is a position in the byte sequence, not a character count. That is exactly what you want when you need to slice back into the original string, and exactly what surprises you if you assumed it counted characters.
Converting: []byte and []rune
When you genuinely need indexable characters, convert the string to a []rune. When you need raw mutable bytes, convert to []byte. Both conversions copy (a string is immutable, so the slice cannot alias it), and both round-trip back to a string.
package main
import "fmt"
func main() {
s := "héllo"
bs := []byte(s)
rs := []rune(s)
fmt.Printf("[]byte: %v (len %d)\n", bs, len(bs))
fmt.Printf("[]rune: %v (len %d)\n", rs, len(rs))
fmt.Printf("rs[1]: %q\n", rs[1])
fmt.Printf("string([]byte): %q\n", string(bs))
fmt.Printf("string([]rune): %q\n", string(rs))
fmt.Printf("string(rune 0x4e16): %q\n", string(rune(0x4e16)))
}
$ go run .
[]byte: [104 195 169 108 108 111] (len 6)
[]rune: [104 233 108 108 111] (len 5)
rs[1]: 'é'
string([]byte): "héllo"
string([]rune): "héllo"
string(rune 0x4e16): "世"
Now rs[1] is the character é, because a []rune is indexed by code point. The []byte has length 6 and the []rune has length 5, the same split we saw with len versus RuneCountInString. And a lone rune converts straight to its UTF-8 encoding as a string, which is how string(rune(0x4e16)) produces 世. One warning that catches everyone once: string(someInt) does not format the number as text, it interprets the integer as a code point. Use strconv.Itoa or fmt.Sprintf to turn a number into its digits.
Building strings: don’t += in a loop
Because strings are immutable, s += piece cannot append in place. It allocates a brand-new string every iteration and copies everything accumulated so far into it. Do that in a loop over n pieces and you have quadratic work and a trail of garbage. For a handful of concatenations it does not matter. In a loop, it does.
The idiomatic fix is strings.Builder, which holds a growable byte buffer and hands you a string at the end with no extra copy. It implements io.Writer, so fmt.Fprintf can write straight into it.
package main
import (
"fmt"
"strings"
)
func main() {
var b strings.Builder
for i := 0; i < 5; i++ {
fmt.Fprintf(&b, "line %d\n", i)
}
out := b.String()
fmt.Print(out)
fmt.Printf("built %d bytes\n", b.Len())
}
$ go run .
line 0
line 1
line 2
line 3
line 4
built 35 bytes
The zero value of a strings.Builder is ready to use — no constructor, in keeping with the pattern you saw for other types. You append with WriteString, WriteByte, WriteRune, or by writing through fmt.Fprintf as above, and call String() once at the end. If you know the rough size in advance, b.Grow(n) preallocates so the buffer never has to resize. For joining a slice of strings with a separator, strings.Join is even simpler and does the same single-allocation trick.
Two kinds of string literal: interpreted and raw
Everything so far used the ordinary double-quoted form, "...". Go calls this an interpreted string literal: backslash escapes are processed, so \n becomes a newline and \t a tab, and the literal cannot span source lines. There’s a second form, the raw string literal, written between backticks `...`. Inside backticks nothing is an escape — \n is a literal backslash followed by an n — and the literal may run across multiple lines, with the newlines becoming part of the string.
package main
import "fmt"
func main() {
interpreted := "line one\nline two\ttabbed"
raw := `line one\nline two\ttabbed
still the same string`
fmt.Println("interpreted:")
fmt.Println(interpreted)
fmt.Println("raw:")
fmt.Println(raw)
// A struct tag is itself a raw string literal.
fmt.Printf("%q\n", `json:"name"`)
}
$ go run .
interpreted:
line one
line two tabbed
raw:
line one\nline two\ttabbed
still the same string
"json:\"name\""
The interpreted string turned \n and \t into a real newline and tab. The raw string left them as the four literal characters you typed, and its own line break carried through into the output. That makes raw strings the natural choice for anything where backslashes are content rather than escapes — Windows paths, regular expressions, JSON blobs, or multi-line templates — none of which you want to double-escape. It’s also the same syntax struct tags use, those `json:"name"` annotations you’ll meet in the next chapter: a struct tag is a raw string literal precisely so the quotes inside it don’t need escaping. Both forms produce an ordinary string once compiled; the only difference is how the source text is read. And when you want to transform strings — case, trimming, splitting, number parsing — the standard library’s strings, strconv, and unicode packages are where those tools live.
What to carry forward
A Go string is immutable UTF-8 bytes. len and indexing work in bytes; range and []rune work in characters (runes, which are int32 code points). When you want the character count, use utf8.RuneCountInString; when you want to index characters, convert to []rune; when you want to build text in a loop, use strings.Builder rather than +=. None of this is Go being awkward for its own sake. It is Go refusing to hide the difference between storage and meaning, so that your code behaves the same whether the input is hello or 日本語.
Next: structs and composition — how Go builds bigger types out of smaller ones, and why it embeds instead of inherits.
Comments