Skip to content
academia.sh

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: len counts 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 []byte conversion does not share the original, it produces an independent copy.
  • Iterating with range gives 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close