Deep Engineering
Intermediate·Published·30 MIN

Strings, runes and bytes: why len does not count characters, and a substring holds megabytes

A string in Go is a header of a pointer and a length over immutable bytes. Everything else follows: len gives bytes, s[i] gives a byte, range gives runes at byte offsets, a substring is free and therefore retains the whole original array. Measured: ten bytes hold eight megabytes, and joining a thousand pieces with += costs 109 times a Builder.

Full technical treatment

TL;DR

A string in Go is a sequence of bytes, not of characters. Text is stored in UTF-8, where one mark takes between one and four bytes — so the length counts bytes, indexing yields a byte, and for range walks code points. On purely Latin text all three coincide, which is exactly why a bug from this topic lives in the code until the first non-Latin input.

Hence what does not look the way people expect. "A世🙂" is eight bytes at three runes: s[0] is 65 and that is the whole letter "A", while s[1] is 228 and that is a third of the CJK character — the first case works by accident and thereby masks the second. range yields byte offsets 0 1 4, not 0 1 2. Slicing cuts by bytes too: s[:2] splits the CJK character in half and produces a string that is not valid UTF-8 — with no crash and no warning. And a rune is not a character: a decomposed é is three bytes, two runes and one visible mark, the family "👩‍👩‍👧" is eighteen bytes, five runes, one mark; splitting into graphemes is not in the standard library at all.

Beyond that come the layout, the numbers and their limits. A string is a header of a pointer and a length (16 bytes) over immutable bytes, and both the free substring and the quadratic join follow from immutability. A substring copies nothing — and for exactly that reason it retains the whole original array: measured, ten bytes hold 8 MB until strings.Clone is called. On go1.24 len([]rune(s)) allocates nothing — the compiler recognises the expression, so the advice "replace it with utf8.RuneCountInString" describes a compiler that has long since gone; you pay where the slice is kept: ×3.4 and one allocation. For the same reason some []bytestring conversions are free: m[string(b)], string(b) == s, range string(b)zero allocations. And joining in a loop is quadratic: on one machine the ratio against Builder grew with the number of pieces, ×2.3 → ×23.3 → ×111.8 — though at two or three pieces a + b + c is faster.

Where to start
Before this lesson it is enough to understand
  • text is held in memory as numbers: a byte is a number from 0 to 255;
  • marks are not only Latin ones, and a single text can mix them freely;
  • you take a string's length, cut a piece out of it by position, and walk it with a loop.
You do not need to know in advance
  • UTF-8, code point, rune, grapheme cluster;
  • utf8.RuneCountInString, strings.Builder, strings.Clone, the replacement character U+FFFD;
  • what a string variable is made of and why a substring copies no data.

What is really being asked

The topic looks like a warm-up, and that is the trap: the questions almost always come in pairs, where the second checks whether the reason behind the first was understood.

  1. "What does len(s) return for a string with CJK characters in it?" — followed by: "and why?"
  2. "What is a rune?" — followed by: "is a rune the same as a character?"
  3. "Why are strings immutable?" — a question about substrings, not about safety.
  4. "What does s[2:5] cost?" — followed by: "and what stays in memory?"
  5. "What is wrong with += in a loop?" — followed by: "and at three pieces?"

The topic has a single spine, and it is worth saying out loud: a string is a header over an immutable run of bytes. Immutability makes a substring free, a free substring retains the array, and immutability is also what makes joining quadratic. Three different questions, one property.

Base: a string is bytes

A string holds text, and memory can only hold numbers. So between the text and the memory sits an agreement: which number writes down which mark. Such an agreement is called an encoding, and in Go there is only one — UTF-8.

It is deliberately uneven: frequent marks are written in one byte, the rest in two, three or four. A Latin letter takes one byte, a CJK character three, an emoji four. The number the encoding assigns to a mark is called a code point; in Go it has its own name — a rune.

Take a string of three marks of different lengths — "A世🙂" — and walk the whole entrance to the topic with it.

The length returns bytes. There are three marks, and the length is eight: one plus three plus four. A string's length in Go is not the number of marks but the room the text occupies.

Ranging walks marks. for range over a string steps through code points and yields "A", "世" and "🙂" — three steps, not eight. But the number it hands you alongside each mark is the byte offset where that mark starts: 0, 1 and 4.

Indexing yields a byte. s[0] is not the first mark but the first byte: 65, which happens to be the whole letter "A", because that letter is one byte long. And s[1] is 228, only the first third of the CJK character: the other two thirds sit in s[2] and s[3].

That is the whole entrance. Three answers to "how many are there" — eight bytes, three marks, eight positions to index — and none of them is the string's "real" number: which one is right depends on why you are asking.

That is already enough to answer the basic interview question: a string's length in Go counts bytes rather than characters, because the text is stored in UTF-8 where a mark takes between one and four bytes. Everything below is about where that difference cuts: why a rune is still not a visible mark, how slicing a string splits a mark in half, and what follows from what a string variable is made of.

Mechanism 1: one string, three frames of reference

The lesson carries that same string — "A世🙂" — through every mechanism. It is chosen to cover all three UTF-8 lengths at once: the Latin letter takes one byte, the CJK character three, the emoji four.

This is not pedantry. On a purely Latin string not one bug in this topic shows up: len matches the number of characters, s[0] gives the first character, range indices run consecutively. Which is exactly why such bugs live in the code until the first non-Latin input — and surface in production rather than in tests.

A run of bench/gostring/practice.go prints:

8        len(s) — bytes
3        utf8.RuneCountInString(s) — runes
65       s[0] — a byte, and also the whole letter "A"
228      s[1] — a byte, and it is a THIRD of the CJK character
3        len([]rune(s))
0 1 4    byte offsets from range
1        len(string(s[0]))
2        len(string(s[1]))
language contractLanguage guarantees: they follow from the definition of a string and from the semantics of range. They do not depend on the Go version.

Three points are what matter here.

len counts bytes. Eight against three is one and the same string in two frames of reference, and neither of them is the "real" one. The right answer to "how many characters are there" depends on why you are asking.

s[i] is a byte, not a character. And here is the pair the string was chosen for: s[0] gives 65, and that really is the whole letter "A"; s[1] gives 228, and that is the first of three bytes. The first case works by accident — and thereby masks the second.

The last two output lines follow from that. string(s[i]) does not decode the byte: it converts a number into the rune with that code. From 65 comes "A", 1 byte long — right by coincidence. From 228 comes "ä", 2 bytes long — a character with no relation to the original string.

range yields byte offsets: 0 1 4, not 0 1 2. That is in the specification directly:

For a string value, the "range" clause iterates over the Unicode code points in the string starting at byte index 0. On successive iterations, the index value will be the index of the first byte of successive UTF-8-encoded code points.

The Go specification — range

The practical consequence: an index from range is fit for slicing the string (s[i:]) but is not a character number. A character counter goes in a separate variable.

Mechanism 2: bytes, runes and graphemes — three different numbers

"A rune is a character" is the most durable simplification in the topic, and it is worth demolishing right away, before it works its way into decisions. A run of bench/gostring/internals.go:

stringbytesrunesvisible marks
"A世🙂"833
"é" precomposed211
"é" decomposed321
"👩‍👩‍👧" a family1851

The two middle rows are the same mark on screen with different rune counts. é exists in two forms: precomposed (U+00E9) and decomposed (e plus a combining acute U+0301). So a rune is not a character, and a rune count answers "how many characters" correctly only when the text is known to be in the precomposed form — which text from the outside world does not guarantee.

The last row takes the distinction to its limit: eighteen bytes, five runes, one visible mark. Those five runes are three emoji and two zero-width joiners.

What a person calls a character is called a grapheme cluster, and splitting into clusters is not in the standard library at all. That is the substantive interview answer: "count the characters" in the general case is solved by a separate package, while len and []rune answer two other questions.

The three levels are worth holding in mind as three:

bytes      the unit of storage        len(s)
runes      Unicode code points        utf8.RuneCountInString(s)
graphemes  what a person sees         a separate package

Mechanism 3: slicing cuts by bytes

String slice indices are byte indices, which means s[:n] with an arbitrary n can cut a UTF-8 sequence in half. From the run:

expressionresultvalid UTF-8runes
s[:1]"A"true1
s[:2]"A\xe4"false2
s[:4]"A世"true2
s[:8]"A世🙂"true3

The second row is a CJK character cut in half. The program did not crash and said nothing: a string in Go is a sequence of arbitrary bytes, and invalid UTF-8 inside one is legal.

Note the rune counter on that row: it counted two runes where the second does not exist. Iteration substitutes the replacement character U+FFFD for every invalid byte — so the corruption turns not into an error but into a question mark in a diamond that users will notice.

The practical consequence is narrow and common: you cannot truncate text by length with a byte slice. A hundred-character preview cut as s[:100] will sooner or later split a character. Cut on the boundaries range gives — it walks exactly the starts of runes.

Mechanism 4: what is in the variable — and why a substring is free

The measurement prints the header sizes:

size
string16 bytes (pointer + length)
[]byte24 bytes (pointer + length + capacity)

Those eight bytes of difference explain half the topic: a string has no capacity, because nothing can ever be appended to it.

A string value is a (possibly empty) sequence of bytes. Strings are immutable: once created, it is impossible to change the contents of a string.

The Go specification

Hence, immediately: s[0] = 'x' does not compile, and that is not caution for its own sake. Writing into a string would corrupt every substring looking into the same array — and they look into it precisely because a string is immutable. The property holds itself up.

A substring is free — and therefore expensive

Here is the pair they want to hear together.

The substring s[10:20] costs zero allocations: a new header points into the same array. And for exactly that reason:

left on the heap
an 8 MB string created8.0 MB
kept a 10-byte substring of it8.0 MB
after strings.Clone0.0 MB

Ten bytes retain eight megabytes. The collector reclaims an array whole or not at all; a reference into the array holds all of it.

Where this actually happens: a large service response is parsed, an identifier is taken out of it and put into a long-lived cache — and the response stays in memory along with the identifier. The symptom is characteristic: memory grows while the object count shows nothing.

Clone returns a fresh copy of s. It guarantees to make a copy of s into a new allocation, which can be important when retaining only a small substring of a much larger string.

Package strings

The rule that follows is a narrow one: strings.Clone is needed only where a short piece outlives a long string. Cloning everything means paying in copies for a problem you do not have.

Mechanism 5: joining in a loop — why this is not about the speed of an operation

Before looking at the numbers it is worth deriving the answer from what has already been said. A string is immutable — that is the lesson's first claim. So "appending to a string" is impossible in principle: every += builds a new string and copies everything accumulated so far into it. On step k it copies k pieces, and over n steps the sum from 1 to n — the work is quadratic.

The prediction from that is unambiguous: the ratio against Builder must grow with the number of pieces. The growth, not the ratio itself: a single number says nothing, because it depends on the length of the loop.

measured observationbench/gostring/internals.go, go1.24.7 linux/amd64, two cores. The absolute nanoseconds depend on the machine; what carries meaning is how the ratio grows down the rows.
piecess += xBuilderBuilder+Growratio
10474 ns208 ns71 ns×2.3
10023,938 ns1,028 ns502 ns×23.3
10001,674,914 ns14,976 ns4,279 ns×111.8

The prediction holds: ×2.3 → ×23.3 → ×111.8. A growing ratio is the signature of quadratic work; if the work were linear, the column would stand still.

Allocations at a thousand pieces: 999 against 15 for Builder and 1 for Builder with Grow.

Builder writes into a growing buffer and hands it over as a string without a copy; Grow removes the intermediate expansions as well, when the final size is known in advance.

And the caveat without which the rule does harm. At two or three pieces a + b + c is faster than a Builder and reads better: the compiler joins such an expression with a single call. A Builder is for where the pieces are many and their number is not known in advance — that is, in a loop.

Deeper: conversions — what copies and what does not

From here the claims change kind, and the difference is worth saying. The contract says only that string and []byte are different types; everything below is what the current compiler does, and numbers taken on one particular machine.

implementation detail · Go 1.24Conversion optimisations are not in the specification. The contract only says that string and []byte are different types; the freeness of particular forms is a property of the compiler, and code that must work on any implementation cannot rely on it.

This is where the topic carries its most outdated piece of common wisdom.

First. len([]rune(s)) — the very expression people are told to replace with utf8.RuneCountInStringallocates nothing. Measured:

timeallocations
utf8.RuneCountInString(s)98.83 ns0
len([]rune(s))104.26 ns0
r := []rune(s), slice kept333.39 ns1

The compiler recognises the shape len([]rune(s)) and counts runes in place without building a slice. The advice repeated in articles describes a compiler that has long since gone. You pay where the slice is actually needed — when it is kept and indexed.

Second. Some []bytestring conversions are free:

allocations
m[string(b)] — a map lookup0
string(b) == s — a comparison0
for range string(b)0
s := string(b) — the string is kept1

The rule is simple: a copy is needed when the string outlives the expression. If it is needed only inside one expression and never escapes, the compiler does not build it. Hence the practical consequence: looking up a map[string]T by a key held in a []byte can be done directly, with no unsafe and no manual workarounds.

How to answer in an interview

Short answer: a string in Go is a sequence of bytes in UTF-8, not a sequence of characters. That is why the length counts bytes, indexing yields a byte, and for range walks code points and hands back byte offsets. Add the layout as a second sentence: a string is a pointer and a length over immutable bytes, and both the free substring and the quadratic join follow from immutability.

That is enough for a correct answer. What follows is what you add when the interviewer digs.

If the interviewer digs deeper

On len, answer with a number and a reason. "Bytes. For "A世🙂" it is eight against three runes: the letter takes one byte, the CJK character three, the emoji four."

On s[i], name the type and give both halves. "byte. In that same string s[0] is 65 and the whole letter "A", while s[1] is 228 and a third of the CJK character. And string(s[1]) gives not the character but an unrelated "ä": it converts a number into the rune with that code rather than decoding the byte."

On characters, separate the three levels. "Bytes, runes and graphemes are three different numbers. A decomposed é is two runes for one visible mark, an emoji family five runes for one. Splitting into graphemes is not in the standard library."

On runes, add the second half. "A rune is a code point, but not a character on screen: é may be two runes, and then the counter shows two for one visible mark."

On substrings, name both sides at once. "Zero allocations — and retention of the whole array. Ten bytes held eight megabytes for me; strings.Clone fixes it."

On joining, talk about growth, not about the ratio. "Quadratic: every += copies what has accumulated. At a thousand pieces it came out ×109 against a Builder. But at two or three pieces the plus is faster."

Next they ask

Next they ask

How does []byte(s) differ from an unsafe conversion?

Short answer

[]byte(s) copies — and must: a slice is mutable, a string is not. Without the copy, writing into the slice would change the string that other substrings may be looking at.

unsafe.String and unsafe.Slice (Go 1.20) do the same without a copy, and that is exactly why they are dangerous: the resulting slice looks into the string's memory, and any write to it is undefined behaviour. Their legitimate place is a hot path where you know for certain the data is only read. In an interview answer, what matters is naming not the trick but the condition under which it applies.

Next they ask

How do you reverse a string properly?

Short answer

A trick question, and the right answer starts with one of your own: reverse what? Bytes cannot be reversed — UTF-8 would break. Runes can, via []rune, and that is what people mean in 99% of cases.

But even that is not "reversing a string": combining marks come loose from their base characters, and an é made of two runes turns into an acute over the neighbouring letter. A fully correct reversal requires splitting into grapheme clusters — not something the standard library does.

Next they ask

What does strings.Builder do that bytes.Buffer does not?

Short answer

Both accumulate into a growing buffer; the difference is at the end. Builder.String() hands the buffer over without a copy — it can afford to, because it forbids copying the Builder itself (the noCopy check panics). Buffer.String() copies, because the buffer may keep being used afterwards.

Hence the choice: if the result is a string and the buffer is no longer needed, Builder is cheaper by exactly one copy. If you need an io.Writer you can also read from, use a Buffer.

Next they ask

Why does comparing strings not require equal lengths?

Short answer

Comparison looks at the lengths first: different means false immediately, without a single byte compared. Equal lengths mean the bytes are compared, and for identical pointers the runtime returns at once.

The practical consequence: comparing strings is cheap at different lengths and only gets expensive on matching prefixes of equal length. This is also why strings are legitimate map keys: the hash is computed over the bytes, and the comparison is unambiguous because the bytes are immutable.

Next they ask

Can a string hold invalid UTF-8?

Short answer

Yes, and that is an important yes: a string is a sequence of arbitrary bytes. Nothing stops you from putting something that is not UTF-8 into one — data read from a file, for instance.

What happens then: range and []rune substitute U+FFFD, the replacement character, for every invalid byte, while utf8.ValidString returns false. In other words, corruption does not crash the program; it quietly turns into question marks in diamonds, and only a check will find it.

Next they ask

Where do string literals live?

Short answer

In the read-only data segment of the executable — which is another way to explain immutability: writing into a literal would be a write into a page marked read-only.

Hence a small but useful point: s := "abc" allocates nothing on the heap, and neither does a substring of a literal, while []byte("abc") does — because a slice is mutable and cannot be left in that segment.

Common misconceptions

Claim

len(s) returns the number of characters

Actually

The number of bytes. For "A世🙂" that is 8 against three runes: the letter takes one byte, the CJK character three, the emoji four. The rune count comes from utf8.RuneCountInString, and even that is not the number of visible characters: a decomposed é is two runes for one mark.

Claim

s[0] is the first character of the string

Actually

It is the first byte, of type byte. For "A世🙂" it is 65 and coincides with the whole letter "A" — while s[1] is 228 and only a third of the CJK character. And string(s[1]) gives not «世» but an unrelated «ä»: it converts a number into the rune with that code, rather than decoding the byte.

Claim

range over a string gives the indices 0, 1, 2…

Actually

It gives the byte offsets of the start of each rune: for "A世🙂" those are 0, 1, 4. An index from range is fit for slicing the string and unfit as a character number — the counter is kept separately.

Claim

a rune is a character

Actually

A rune is one Unicode code point. A visible character may consist of several: é is written either as one point or as a letter plus a combining acute, and in the second case there are two runes for one mark on screen. Counting characters is a grapheme-cluster problem.

Claim

a substring copies the data, so it is expensive

Actually

The opposite: zero allocations — a new header points into the same array. What is expensive is the other side: a substring retains the whole original array. Measured: ten bytes hold 8 MB until strings.Clone is called.

Claim

len([]rune(s)) allocates, so replace it with utf8.RuneCountInString

Actually

The measurement does not support this: len([]rune(s)) makes zero allocations and takes the same time — the go1.24 compiler recognises the expression and counts runes in place. That is a property of the compiler, not a promise of the language: the specification says nothing about such conversions. The allocation appears when the slice is kept: 300.84 ns against 94.52, and one allocation.

Claim

converting []byte to string always copies

Actually

Only when the string outlives the expression. m[string(b)], string(b) == s and for range string(b) make zero allocations — the go1.24 compiler knows the string will not escape. On another implementation the copy would be legitimate: the contract promises only that string and []byte are different types.

Claim

never concatenate with +, always use a Builder

Actually

At two or three pieces a + b + c is faster than a Builder and reads better — the compiler joins such an expression with a single call. A Builder is for where the pieces are many and their number is unknown in advance: at a thousand pieces the gap is ×109, and it grows with their number, because the work is quadratic.

Practice

Two problems. Answer first, then check against the real output: in both, the correct answer comes from a run of the script, not from an assertion.

Practice · predict the output

The string s := «A世🙂» — three marks: a letter, a CJK character and an emoji. What will len, the rune counter, s[0], s[1], the length of the rune slice, the byte offsets from range and the lengths of the strings built from those two bytes print?
fmt.Println(len(s))
fmt.Println(utf8.RuneCountInString(s))
fmt.Println(s[0])
fmt.Println(s[1])
fmt.Println(len([]rune(s)))

var offsets []string
for i := range s {
offsets = append(offsets, strconv.Itoa(i))
}
fmt.Println(strings.Join(offsets, " "))

fmt.Println(len(string(s[0])))
fmt.Println(len(string(s[1])))

Practice · estimate

Counting the runes in a string. How many times dearer is it through a kept slice r := []rune(s) than through utf8.RuneCountInString?
times

Knowledge check

Question 1 of 6

s := «A世🙂». What will fmt.Println(len(s), s[0], s[1]) print?

Sources & further reading

4 SOURCES

  1. The Go Programming Language Specification — String typesOfficial documentation. The definition everything else is derived from: «A string value is a (possibly empty) sequence of bytes. The number of bytes is called the length of the string and is never negative. Strings are immutable: once created, it is impossible to change the contents of a string.» Note the wording: a sequence of bytes, not of characters.https://go.dev/ref/spec#String_types
  2. The Go Programming Language Specification — For statements with range clauseOfficial documentation. The answer to why range indices come with gaps: «For a string value, the "range" clause iterates over the Unicode code points in the string starting at byte index 0. On successive iterations, the index value will be the index of the first byte of successive UTF-8-encoded code points in the string.»https://go.dev/ref/spec#For_range
  3. Strings, bytes, runes and characters in Go — the Go blogOfficial documentation. Rob Pike's article, the source of the precise vocabulary: «a rune is a Go term for a single Unicode code point» and, more importantly, the warning against equating a rune with a character: «Code points can be represented by multiple runes» — that is, combining marks.https://go.dev/blog/strings
  4. Package strings — Builder and CloneOfficial documentation. The two tools of this lesson. On Builder: «A Builder is used to efficiently build a string using Write methods. It minimizes memory copying.» On Clone, which speaks to the retention trap directly: «Clone returns a fresh copy of s. It guarantees to make a copy of s into a new allocation, which can be important when retaining only a small substring of a much larger string.»https://pkg.go.dev/strings