Go Module 4 – Arrays, slices, maps and strings
Strings, bytes and runes: UTF-8 done right
Why len("नमस्ते") is 18 in Go: strings hold UTF-8 bytes, indexing reads bytes, range reads runes, and conversions copy. Count, build and cut text safely.
What you will learn
- Explain how a Go string stores text as read-only UTF-8 bytes, and predict len for text that is not ASCII
- Iterate over a string by byte and by rune, and count runes with utf8.RuneCountInString
- Convert between string, []byte and []rune, and predict what each conversion copies
- Build strings with strings.Builder, and split, cut and compare them with the strings package
- Limit text to n runes without splitting one, and handle invalid UTF-8 and characters made of several runes
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how a Go string stores text as read-only UTF-8 bytes, and predict len for text that is not ASCII
- Iterate over a string by byte and by rune, and count runes with utf8.RuneCountInString
- Convert between string, []byte and []rune, and predict what each conversion copies
- Build strings with strings.Builder, and split, cut and compare them with the strings package
- Limit text to n runes without splitting one, and handle invalid UTF-8 and characters made of several runes
Before you start
On this page
len("नमस्ते") is 18 in Go, although the word is written with 6 code point: The number Unicode gives a character, written U+00E9 for é. A Go rune holds one code point, and UTF-8 encodes it in one to four bytes.: Go strings hold bytes, and the
UTF-8: The encoding of Go source code and string literals: each Unicode character takes one to four bytes, so 'A' takes one byte and '₹' three. len of a string counts these bytes. More in “Basic types: numbers, strings, bools, conversion” encoding takes three bytes for each of these Devanagari code points. Code that counts,
indexes or cuts text by bytes is right for English and wrong for most of the world’s writing.
You will see the bytes of a string, loop over it by byte and by rune: Go's type for one Unicode character, an alias of int32 that holds its code point. A rune such as ₹ takes several bytes in UTF-8, and ranging over a string gives one rune per pass. More in “Control flow: if, for, range and switch”, and convert it to
[]byte and []rune knowing what each conversion copies. The programs here all ran with Go 1.27.1, and each shows
the output that run produced.
A string is a read-only sequence of UTF-8 bytes
The specification defines a string as a sequence of bytes, and Go source files are UTF-8, so a string literal holds the UTF-8 bytes of its text. This program prints four strings with their lengths and their bytes in hex:
// bytes.go: a string is a sequence of bytes, and UTF-8 uses one to four of them per code point.
package main
import "fmt"
func main() {
for _, s := range []string{"go", "héllo", "€5", "नमस्ते"} {
fmt.Printf("%-8q len %2d bytes % x\n", s, len(s), s)
}
s := "héllo"
fmt.Println(s[0], s[1], s[2]) // indexing gives bytes, as numbers
} Output
"go" len 2 bytes 67 6f "héllo" len 6 bytes 68 c3 a9 6c 6c 6f "€5" len 4 bytes e2 82 ac 35 "नमस्ते" len 18 bytes e0 a4 a8 e0 a4 ae e0 a4 b8 e0 a5 8d e0 a4 a4 e0 a5 87 104 195 169
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run bytes.go
ASCII letters took one byte each, so “go” has length 2. “é” took two bytes, c3 a9, and “€” three, so “héllo” has
length 6 and “€5” length 4. Each Devanagari code point of “नमस्ते” took three bytes, 18 in all. len counts bytes,
always, and indexing a string gives a byte, not a character: s[1] of “héllo” is 195, the first half of é.
Strings are also immutable. The specification says that once a string is created, its contents cannot change, and the compiler refuses to assign to a byte of one:
// immutable.go: a byte of a string cannot be assigned.
package main
import "fmt"
func main() {
s := "hello"
s[0] = 'H'
fmt.Println(s)
} Output (exit status 1)
Printed as an error (standard error)
# command-line-arguments ./immutable.go:8:2: cannot assign to s[0] (neither addressable nor a map index expression)
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run immutable.go
cannot assign to s[0] (neither addressable nor a map index expression): to change text, build a new string. That
rule is what lets Go strings be shared freely, sliced without copying and used as map keys.
Checkpoint
Check: Strings as UTF-8 bytes
Indexing reads bytes; range reads runes
A loop over the indexes visits bytes. A range loop over a string decodes it instead: each pass gives the byte offset where a code point starts and that code point as a rune:
// runes.go: a byte loop and a range loop over the same string, and the count of runes.
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "héllo"
for i := 0; i < len(s); i++ {
fmt.Printf("%d:%x ", i, s[i]) // one byte per pass
}
fmt.Println()
for i, r := range s {
fmt.Printf("%d:%c ", i, r) // one rune per pass, with the byte offset where it starts
}
fmt.Println()
fmt.Println(len(s), utf8.RuneCountInString(s))
fmt.Println(len("नमस्ते"), utf8.RuneCountInString("नमस्ते"))
} Output
0:68 1:c3 2:a9 3:6c 4:6c 5:6f 0:h 1:é 3:l 4:l 5:o 6 5 18 6
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run runes.go
The byte loop printed six bytes, c3 and a9 among them. The range loop printed five runes, and its offsets jumped from 1 to 3, because é covers offsets 1 and 2. Step through the range loop pass by pass:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The first pass. The loop starts at byte offset 0. Each pass decodes one rune from the UTF-8 bytes and gives the loop two values, the offset where the rune starts and the rune itself.
- é takes two bytes. At offset 1 the bytes c3 and a9 together encode é, U+00E9. The loop gets i = 1 and r = é, then moves on by two bytes.
- Offset 3, not 2. The next pass starts at offset 3. Offset 2 holds the second byte of é, which can never start a rune, so the loop never stops there.
- One byte each. The l at offset 4 and the o at offset 5 are ASCII, one byte each, so these passes move on by one byte.
- Five passes, six bytes. At offset 6 the string ends. The loop made 5 passes over 6 bytes, which is why runes.go printed 6 for len and 5 for utf8.RuneCountInString.
So utf8.RuneCountInString is the count of runes, 5 for “héllo” and 6 for “नमस्ते”, while len stays the count of
bytes. A rune is an int32 that holds one code point; the specification calls the values a range loop produces over
a string exactly that, Unicode code points. A byte that is not valid UTF-8 comes out of the loop as the
replacement character: U+FFFD, the code point that stands in for bytes that are not valid UTF-8. A range loop over a string gives it for each invalid byte., U+FFFD.
Key idea
len and indexes count bytes; range and utf8.RuneCountInString count runes. Choose by what the code needs, and never index by bytes into text that is not ASCII.
Checkpoint
Check: Byte loops and rune loops
Converting to []byte and []rune copies
A string’s bytes cannot change, so converting it to a slice that can change must copy them. This program changes the copies and prints the string afterwards:
// convert.go: []byte and []rune conversions copy, so changing them leaves the string alone.
package main
import "fmt"
func main() {
s := "héllo"
b := []byte(s) // a copy of the 6 bytes
b[0] = 'H'
fmt.Println(string(b), s)
r := []rune(s) // a copy, decoded: one int32 per code point
r[1] = 'e'
fmt.Println(string(r), s, len(r))
fmt.Println(string(rune(233)), string([]byte{0xc3, 0xa9})) // both are "é"
} Output
Héllo héllo hello héllo 5 é é
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run convert.go
| Conversion | What you get | Copies? |
|---|---|---|
[]byte(s) |
the bytes of s, which can be changed | yes, every byte |
[]rune(s) |
the decoded runes, one int32 each | yes, and decodes |
string(b) |
a string of the bytes | yes |
string(r) |
the UTF-8 encoding of the runes | yes, and encodes |
string(rune(233)) |
“é”, one character | a new string |
Changing b[0] gave “Héllo” and left s as it was, and changing r[1] gave “hello”. A []rune is the way to read
code points by position, r[1] being é, at four bytes a rune: 20 bytes for these 5 runes against 6 for the string.
Checkpoint
Check: Conversions that copy
The strings package does the rest of the work on text, and four ideas come with it. strings.Builder builds a
string piece by piece without copying everything each time, which a loop of += does on every pass. Functions such as
strings.Cut and strings.Fields take text apart without counting indexes, and strings.EqualFold compares two
strings without regard to case. Cutting text to a length is where bytes and runes matter most: s[:n] counts bytes,
so it can end in the middle of a rune. And a character on screen, a grapheme cluster: What a reader sees as one character, which can be several code points: a letter and an accent, an emoji with a modifier, or a Devanagari conjunct. Unicode Standard Annex 29 defines the boundaries., can take several runes, as a letter with an accent or an emoji with a skin tone does, so even a rune
count can split one.
Building and taking apart text with the strings package, measured Pro: opens with a Pro, Premium or Ultimate pass
Text work in Go has two costs worth knowing, the copying that += does and the slices that splitting builds. This part
measures the first, shows the strings functions that avoid both, and ends with case-insensitive comparison.
In this part:
- += copies everything built so far; strings.Builder does not
- Cut, CutPrefix and CutLast take text apart without indexes
- Fields, Split and the iterator forms
- Comparing without case
3 runnable examples (Go) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Invalid UTF-8, characters made of several runes, and safe truncation Pro: opens with a Pro, Premium or Ultimate pass
Real text arrives with broken bytes, accents stored two ways and emoji made of several code points. This part shows what Go does with each, and builds the two helpers that text handling needs most: cutting to a length and taking initials.
In this part:
- Invalid bytes decode to U+FFFD
- One character on screen can be several runes
- Cutting by runes, and taking initials
3 runnable examples (Go) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise
Practice covers the bytes of a string, byte and rune loops, and conversions, opening with the idea your earlier answers showed least sure. Every option comes with the reason it is right or the step it skips:
Practice that adapts to you
Practise this lesson
9 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): Why is len(“héllo”) 6 in Go?
Because len counts bytes, and Go strings hold UTF-8: h, l, l and o take one byte each and é takes two, c3 a9.
The word has 5 runes, which utf8.RuneCountInString reports, and a range loop over it makes 5 passes.
Three longer questions of the module bank build on this lesson:
-
Mid
A notification service shortens each message to fit a 160-byte field with msg[:160]. Messages in Hindi sometimes end in a replacement character on phones. Explain what happens, fix it, and say what can still go wrong with emoji.
-
Fresher to mid
A report builder adds 50,000 lines of about 40 bytes to a string with report += line, and the job is slow and uses far more memory than the 2 MB report. Explain the cost with numbers and rewrite it.
-
Mid to senior
A sign-up form must limit usernames to 20 characters. The Go check len(name) > 20 rejects many names written in Devanagari that look short. What should "characters" mean here, and how do you implement and test the limit?
Key takeaways
- Go strings are read-only sequences of bytes, UTF-8 for text, so
lencounts bytes: 6 for “héllo” and 18 for “नमस्ते”. - Indexing a string gives a byte; a range loop gives runes and the byte offset where each starts, and
utf8.RuneCountInStringcounts runes. []byte(s)and[]rune(s)copy, so changing the slice never changes the string;[]runereads code points by position.
Terms in this lesson
- code point
- The number Unicode gives a character, written U+00E9 for é. A Go rune holds one code point, and UTF-8 encodes it in one to four bytes.
- grapheme cluster
- What a reader sees as one character, which can be several code points: a letter and an accent, an emoji with a modifier, or a Devanagari conjunct. Unicode Standard Annex 29 defines the boundaries.
- replacement character
- U+FFFD, the code point that stands in for bytes that are not valid UTF-8. A range loop over a string gives it for each invalid byte.
References
- Strings, bytes, runes and characters in Go (The Go Authors)
- The Go Programming Language Specification: String types (The Go Authors)
- The Go Programming Language Specification: For statements with range clause (The Go Authors)
- The Go Programming Language Specification: Conversions to and from a string type (The Go Authors)
- Package strings (The Go Authors)
- Package unicode/utf8 (The Go Authors)
- Go 1.24 Release Notes: strings iterator functions (The Go Authors)
- Go 1.27 Release Notes: strings.CutLast (The Go Authors)
- RFC 3629: UTF-8, a transformation format of ISO 10646 (IETF)
- Unicode Standard Annex #29: Unicode Text Segmentation (Unicode Consortium)
- Package encoding/json: Marshal (The Go Authors)
- Package golang.org/x/text/unicode/norm (The Go Authors)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress