---
title: 'Strings and Runes'
source: 'https://academia.sh/en/courses/go-fundamentals/strings-and-runes'
course: 'Go Fundamentals'
language: en
updated: '2026-09-13T05:08:44+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "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'
---

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

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.

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

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

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

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

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

```go
// 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  : café  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.
