Lesson 14 / 18
Strings and Runes
A Go string is a byte array: length counts bytes, indexing returns a byte, only range gives a rune. The same text's byte count, rune count and first byte are measured separately from each other, and the string is shown to be immutable. The limiting measure shows that rune count is also not character count: combining marks can produce text that looks the same with different rune counts.
Published
In this lesson
- Measures that a string's length counts bytes and that indexing returns a byte
- Shows that a string is immutable and that iterating with range returns a rune
- Bounds with an example why rune count may not overlap with character count
Contents
The types measured throughout this topic — array, slice, map, struct, pointer — always carried a number, an address, or a named field. A string asks a question separate from these: a piece of text that looks like a single unit from the outside, what does it carry inside? The answer is this topic’s closing question: what does a string’s length count, what does indexing a string return?
Go’s string type stands under every text-processing tool in the language’s standard library: a file path, a network request’s body, user input — all are carried as strings. Not knowing what this type carries means not knowing which unit these tools operate in (bytes, or code points).
A string is also a composite type, but it differs from the others in this topic in two ways. First, every type measured so far (except the array) was changeable in some way — a slice could be written to, a map could be added to, a struct’s field could be updated; a string cannot be changed in place in any way. Second, the data a string carries is not meaningless bytes on its own, it is a byte array interpreted according to an encoding rule (UTF-8); this rule directly determines what length and indexing mean.
A String Is a Byte Array
In Go, a string is a read-only byte array. Because the source file is UTF-8, string
literals are UTF-8, but the language does not guarantee that a string at run time carries
valid UTF-8. When len is applied to a string, it gives the byte count; indexing (text[i])
returns a single byte — not a character. This was one of the three cases left open in the
Zero Values lesson’s binding distribution: a string’s length is defined, but what comes back
when it is indexed is a byte, not a character.
CT16. — "élan" was chosen because it contains a letter, é, that cannot be encoded in a
single byte; a text made only of ASCII letters would never show this measurement’s byte/rune
difference.
// byte.go — a string's length and indexing operate on bytes
package main
import "fmt"
func main() {
text := "élan"
fmt.Println("length (bytes):", len(text))
fmt.Println("first byte :", text[0])
fmt.Println("rune count :", len([]rune(text)))
}
length (bytes): 5 first byte : 195 rune count : 4
"élan" looks like four letters, and len gives 5, because the letter é is encoded in
UTF-8 as two bytes (195 and a following byte), while l, a and n each take one
byte. text[0] is not a character, it is the numeric value of é’s first byte (195) —
fmt.Println prints this as an integer, not as a piece of the string. The conversion
[]rune(text), on the other hand, splits the string into code points and finds four runes —
this is the number that overlaps with the visible letter count, not the byte count.
These three numbers (5, 195, 4) need to be read independently of each other.
len(text) always counts bytes, no matter how many letters appear visible; in a pure-ASCII
string, byte count and rune count overlap, but this is a coincidence — because every ASCII
character is already encoded in a single byte. In a string containing French, Greek,
Cyrillic, or any multi-byte alphabet, this overlap breaks down; "élan" being four letters but
five bytes is exactly this breakdown itself.
A String Is Immutable
The question left at the end of the Pointers lesson finds its answer here: a string stays outside this topic’s copy/sharing distinction, because a string is immutable. It is not possible to change even a single byte of a string in place. This is the most visible difference between a slice and a string: both carry a header leading to a byte (or element) array, but while writing to the backing array a slice’s header points to is free, writing to the byte array a string’s header points to is blocked by the language itself.
// immutable.go — writing to a byte of a string is rejected
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
func compiles(source string) bool {
dir, err := os.MkdirTemp("", "trial")
if err != nil {
return false
}
defer os.RemoveAll(dir)
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
os.WriteFile(filepath.Join(dir, "code.go"), []byte(source), 0o644)
cmd := exec.Command("go", "build", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
return cmd.Run() == nil
}
func main() {
write := `package trial
func F() {
s := "abc"
s[0] = 'x'
_ = s
}`
fmt.Println("writing to a string index:", compiles(write))
}
writing to a string index: false
s[0] is a valid notation for reading a byte, but s[0] = 'x' is rejected — the compiler
does not allow writing to a string’s elements. This is fundamentally different from writing to
an array index in the Arrays and Slices lesson: writing to an array element is valid, writing
to a string byte is never valid. Code that wants to “change” a string actually produces a
new string — it leaves the original as it is and reassigns a changed copy to a variable.
This is why a string is never subject to the “does writing to a derived piece also change the
source” question measured in any lesson in this topic — writing itself is not possible.
CT17. — This measurement is also run with a compiler call, because the rejection comes at compile time; it is not a panic left to run time.
Code that wants to change a string at the byte level produces a separate copy with a
[]byte conversion; that copy is changeable, but the original string is not affected by it.
// convert.go — a []byte conversion produces a separate copy
package main
import "fmt"
func main() {
text := "élan"
bytes := []byte(text)
bytes[2] = 'X' // the third byte is the letter 'l', occupying a single byte
fmt.Println("changed copy :", string(bytes))
fmt.Println("original string:", text)
part1 := "élan"
joined := part1 + " vital"
fmt.Println("joined string :", joined)
fmt.Println("part1 unchanged? :", part1 == "élan")
}
changed copy : éXan original string: élan joined string : élan vital part1 unchanged? : true
The notation []byte(text) copies text‘s bytes into a new slice; this slice can be
changed like an ordinary slice, and making its third byte 'X' changes the slice’s own data.
But text is not affected by this at all, because the conversion is a copy, not sharing.
Joining with + is similar: part1 + " vital" produces a new string, part1 itself never
changes. Like the array in the Arrays and Slices lesson, a string can never be written back to
its source through any “derived” path either — this is exactly where it departs from a slice
and a map.
range: Byte Index, Rune Value
len and indexing worked at the byte level; iterating over a string with range, on the
other hand, behaves differently. range decodes the string according to UTF-8 rules and gives
a rune at every step, not a single byte. len counts bytes, text[i] gives a byte,
range gives a rune — all three read from the same data, in three separate units.
// iterate.go — range gives a rune in a string, not a byte
package main
import "fmt"
func main() {
text := "élan"
for i, r := range text {
fmt.Printf(" byte-index=%d rune=%q code-point=U+%04X\n", i, r, r)
}
}
byte-index=0 rune='é' code-point=U+00E9 byte-index=2 rune='l' code-point=U+006C byte-index=3 rune='a' code-point=U+0061 byte-index=4 rune='n' code-point=U+006E
Four steps give four runes — not the single byte (195) text[0] gives, but a full code
point (é, U+00E9). The printed indices are not consecutive either: 0, 2, 3, 4 — the
second step skips 1, because é takes two bytes and range jumps to the next rune’s
byte position. rune is the language’s own type name and is an alias for int32; it
carries a Unicode code point. The code point itself is a concept closed in the How Computers
Work course; what is measured here is how Go realizes this concept under the name rune and
with range’s automatic decoding.
This automatic decoding is unique to range; if the string is iterated by being
converted to another composite type first, the same decoding is not applied. Doing range
over []byte(text) gives a different result than doing range over text itself.
// byteiter.go — range over []byte walks byte by byte
package main
import "fmt"
func main() {
text := "élan"
for i, b := range []byte(text) {
fmt.Printf(" index=%d byte=%d\n", i, b)
}
}
index=0 byte=195 index=1 byte=169 index=2 byte=108 index=3 byte=97 index=4 byte=110
Here there are five steps, not the four in the previous section; the indices are
0, 1, 2, 3, 4 — consecutive, with no gaps. range’s behavior of decoding into runes applies
only when the value given to range is a string; when the same data is converted to
[]byte and given instead, range assumes it is walking an ordinary slice and gives the
bytes one by one. The difference is not in the source data itself, it is in the type given
to range.
Limiting Measure: Rune Count Is Also Not Character Count
len([]rune(text)) gave the visible letter count, and this looks more reliable than the byte
count. But rune count also does not always overlap with “character count,” because in
Unicode, what looks like a single mark on screen can also be written as a combination of
more than one code point.
CT18. — Both strings carry four letters that look exactly the same in the source (c,
a, f, an e-derived mark); the measurement separates the two only at the code point
level, not in what is displayed on screen.
// combining.go — text that looks the same, two different rune counts
package main
import "fmt"
func main() {
precomposed := "café" // e and the accent combined into a single code point: U+00E9
decomposed := "cafe" + "́" // e separate, accent a separate code point: U+0065 U+0301
fmt.Println("precomposed :", precomposed, " rune count=", len([]rune(precomposed)), " bytes=", len(precomposed))
fmt.Println("decomposed :", decomposed, " rune count=", len([]rune(decomposed)), " bytes=", len(decomposed))
fmt.Println("byte-equal? :", precomposed == decomposed)
}
precomposed : café rune count= 4 bytes= 5 decomposed : café rune count= 5 bytes= 6 byte-equal? : false
Both strings look the same on screen — both write “café.” But precomposed uses a single
precomposed code point (é, U+00E9), while decomposed follows a plain e with a separate
combining accent code point (U+0301); the two produce the same mark visually, but with a
different number of code points. This is why precomposed counts four runes, decomposed
counts five, and they do not come out equal when compared with == — string comparison is
done byte by byte, it does not look at visual equivalence. The rule measured in the Arrays and
Slices lesson, that == looks at content in an array, applies here too: string comparison
also looks at the exact equality of content (the bytes), not how it looks on screen. Even when
two strings are visually indistinguishable, == counts them as different if they have
different code point sequences. This is a concrete problem code working with user input can
run into: two texts that look the same may not come out equal when compared with == if their
sources differ (one may have come from a keyboard, one from a file, one from another system) —
the cause of the error is not in the comparison operator, it is in the two inputs having been
encoded with different code points from the start.
This limit shows that the unit of “the mark you see” has no counterpart in the language itself. The grapheme concept closed in the How Computers Work course names exactly this: one or more code points coming together to form what the user sees as a single mark. Go’s standard library offers no tool beyond counting runes; counting graphemes requires a separate step that applies code point combination rules, and that step is outside this measurement’s scope.
The three units of measure thus answer three separate questions, and none of them
substitutes for the others. Byte count tells how much room a string takes up in memory or over
a network — storage and transfer matter here. Rune count tells how many code points follow one
after another — the boundary of the language’s own type system is here. Grapheme count, on the
other hand, tells how many “marks” a user sees on screen, but this information never stands in
the language’s type system; it cannot be obtained just by looking at the string itself. Code
that does not know len counts bytes can cut a string in the middle and produce an invalid
UTF-8 sequence; code that does not know rune count is not grapheme count can also misapply a
rule like “limit to five characters” on text with combining marks. Both come from the same
root: confusing the string’s own unit of measure with the unit of measure the user
sees.
Summary
- A Go string is a byte array:
lencounts bytes, indexing returns a byte — this was one of the three open cases from the Zero Values lesson. - A string is immutable; writing to a byte is rejected at compile time. “Changing” always
means producing a new string; even a
[]byteconversion does not share the original, it produces an independent copy. - Iterating with
rangegives not a byte but a rune; byte indices may not be consecutive at multi-byte code points. - Although rune count looks like a more reliable “character” measure than byte count, text that looks the same can produce different rune counts with combined or separate code points — what we call a “character” has no direct counterpart in the language, and the grapheme concept names this gap.
Next Step
This topic began with the array and closed with the slice, map, struct, pointer, and finally the string; each answered its own unwritten question in a different direction — some copied (array, struct), some shared (slice, map, pointer), some could not be written to at all (string). The six lessons’ common question was always the same: when a value is carried somewhere, what gets copied, what gets shared? The language’s type system tells you what a string is: a byte array, with a specific encoding, an immutable value. But the type system does not tell you what a string means — which visible mark the code points form, what a name represents.
The next topic carries the same question outside the source. Everything measured so far
stood inside a single file, a single package — every example stayed in package main, in a
single directory; no measurement asked whether a name would be visible from code in another
directory. But a real program is not made of a single file: it spreads across several files,
several packages, several dependencies. Who determines whether a name can be seen from another
file, which version of a dependency gets chosen, and how an unwritten formatting rule gets
applied? These questions now belong not to the language’s type system, but to the package and
module mechanism.
To keep your progress and take notes, Log in
My notes
Log in to take notes.