1. A string in Go: bytes, UTF‑8, and len
When you are just starting to program, you want to believe in a simple picture of the world: a string is a sequence of letters, so len(s) is the number of letters. That belief lasts exactly until your first encounter with a word like “ééé” or “世界”. Go is not being picky: it simply tells the truth that a string is a sequence of bytes, and its length is also in bytes.
In Go, a value of type string stores a sequence of bytes. When you write len(s), you get the number of bytes, not the “number of letters”. For plain English text (ASCII) this often matches, because one character is encoded as one byte. But once you move to UTF‑8, one character can take more than one byte.
Let’s look at a very short example:
package main
import "fmt"
func main() {
s := "Go"
fmt.Println(len(s)) // 2
}
And that is not a surprise: two letters, two bytes.
Now the same trick, but with a non-ASCII string:
package main
import "fmt"
func main() {
s := "ééé"
fmt.Println(len(s)) // 6 (bytes), not 3
}
UTF‑8: characters can be “wide”
If you have heard “UTF‑8” and thought “that’s about pain and suffering”, you are not alone. But at a basic level the idea is simple: one Unicode character can take from 1 to 4 bytes. That is exactly why len("ééé") is not 3, and len("世") can look like “something is broken”.
It is important to lock in one fact: Go source files must be UTF‑8—this is part of the language “rules of the game”, and because of that Go code and tooling do not need to support dozens of encodings. This does not make text processing magical, but it does make it more predictable.
You can think of UTF‑8 as “packing” text into bytes:
flowchart LR
A["String (string)"] --> B["UTF-8 bytes"]
B --> C["'H' = 1 byte"]
B --> D["'é' = 2 bytes"]
B --> E["'世' = 3 bytes"]
B --> F["emoji are often 4 bytes"]
Go stores bytes. But when you need to walk over “characters”, rune and range enter the game.
rune is a code point, not a “string of length 1”
The word rune sounds like we are about to summon a dragon, but the reality is more down-to-earth: rune is an integer that represents a single Unicode code point (i.e., a “character code”). In Go, character literals in single quotes ('A', '世', '\n') have type rune by default, and rune is an alias for int32.
The most important part: rune is a number. You can print it as a number, or as a character (if you pick the right format).
package main
import "fmt"
func main() {
r := '世'
fmt.Printf("%T\n", r) // int32
fmt.Printf("%d\n", r) // 19990 (code)
fmt.Printf("%c\n", r) // 世
}
Notice the “mode switch” via the format:
- %d prints a number (the code),
- %c prints the character,
- %T prints the type.
2. Iterating over a string: range and byte indexes
range over a string decodes UTF‑8
Now for the main magic of the day (legal, no license required): when you write for ... range s for a string s, Go walks the string as UTF‑8 text and on each iteration gives you one character as a rune.
The syntax is:
for i, r := range s {
// i — byte index
// r — rune (character)
}
It is important not to fall into the trap: i is a byte position in the string, not the “character number”. This is super useful, but if you mix it up, it will hurt.
Let’s see it live:
package main
import "fmt"
func main() {
s := "Hi, 世界"
fmt.Println("len bytes:", len(s)) // len bytes: 10
for i, r := range s {
fmt.Printf("i=%d r=%c\n", i, r)
}
}
Typical output will look roughly like this (the exact indexes matter; the character order is obvious):
- i=0 r=H
- i=1 r=i
- i=2 r=,
- i=3 r=
- i=4 r=世
- i=7 r=界
And here you can see the key point: '世' starts at byte 4, and the next character '界' starts at byte 7. That means '世' took 3 bytes.
How to count “length in characters” via range
After the previous section, a practical question appears: “OK, len(s) is bytes. How do I get the number of characters?”
The basic approach, which requires no new concepts: just count the number of range iterations. Each iteration is one rune.
package main
import "fmt"
func main() {
s := "Hi, 世界"
n := 0
for range s {
n++
}
fmt.Println("runes:", n) // runes: 6
}
Why 6? Because the characters are: H, i, ,, space, 世, 界.
If you want to wrap this as a function (and we already know functions), you can do this:
package main
import "fmt"
func runeCount(s string) int {
n := 0
for range s {
n++
}
return n
}
func main() {
fmt.Println(runeCount("ééé")) // 3
}
This trick is used very often in application code, because it is simple and readable: “count runes via range”.
Why s[0] is not “the first character of the string”
Almost every beginner (and many experienced devs, honestly) writes s[0] at least once and expects the “first letter”. In Go, s[0] is the first byte, not the first character. For ASCII it matches; for UTF‑8 it does not.
Here is a demonstration:
package main
import "fmt"
func main() {
s := "ééé"
fmt.Println(s[0]) // 195 (first UTF-8 byte, not 'é')
}
The correct “first character” can be extracted via range: we just take the first iteration and exit.
package main
import "fmt"
func firstRune(s string) (rune, bool) {
for _, r := range s {
return r, true
}
return 0, false
}
func main() {
r, ok := firstRune("ééé")
fmt.Println(ok, string(r)) // true é
}
Here we return a bool to correctly handle the empty string. This is a slightly more “grown-up” style: an empty string is a normal case, not a reason to panic.
3. Practice: limits and slicing by runes
Limiting text length in “characters”
Imagine we are writing a small console to-do list program, and for now our tasks are just strings (no structs yet—we will get there later). We want to set a human-friendly limit: for example, a task title should be no longer than 20 characters so the output does not turn into a wall of text.
If we do this via len(title), then titles containing non-ASCII characters can be “punished” more than plain English, because they take more bytes. So we should check by runes.
Let’s create a function isTooLongTitle that compares the number of runes to a limit:
package main
import "fmt"
func isTooLongTitle(title string, maxRunes int) bool {
n := 0
for range title {
n++
}
return n > maxRunes
}
func main() {
fmt.Println(isTooLongTitle("buy milk", 20)) // false
fmt.Println(isTooLongTitle("read a very long book", 20)) // true
}
Note: this logic works the same for ASCII, accented characters, and “世界”, because we count characters as rune, not bytes.
How to take the first N runes without []rune
Sometimes you need to take “the first N characters” of a string. The most straightforward path is to convert to []rune, but that is a separate topic (and a separate memory cost). For now, let’s do a trick that fits entirely into the material about range: we will find the byte position where the Nth rune ends, and slice the string by bytes—but at the correct boundary.
The idea is: range gives us i, the byte index of the start of the current rune. If we count runes, then when we reach the Nth one, we know the byte boundary.
package main
import "fmt"
func prefixRunes(s string, n int) string {
if n <= 0 {
return ""
}
count := 0
cut := len(s)
for i := range s {
if count == n {
cut = i
break
}
count++
}
return s[:cut]
}
func main() {
fmt.Println(prefixRunes("Hi, 世界!", 5)) // Hi, 世
}
Why does this work? Because i, coming from range, always points to the beginning of the next UTF‑8 character. That means s[:i] always cuts the string on character boundaries, not in the middle of bytes.
There is a subtle point here: in for i := range s, the variable i runs over the indexes of rune starts, and the first iteration is almost always i=0. That is why we carefully count count and choose cut when we have “already collected N runes”.
Self-check table: bytes and runes
It is very useful to “see with your eyes” the difference on the same strings a couple of times. The table below is like an X-ray: it shows what is usually hidden.
| String | (bytes) |
Rune count (via ) |
Comment |
|---|---|---|---|
|
|
|
ASCII: 1 character = 1 byte |
|
|
|
accented characters are often 2 bytes per letter |
|
|
|
one character, but 3 bytes |
|
|
|
mix of ASCII and CJK |
If your program “breaks” on non-ASCII strings, there are usually two reasons: either you counted bytes as characters, or you sliced the string using the wrong index (in the middle of a UTF‑8 character). Today we addressed both problems.
4. Common mistakes when working with rune and range
Error #1: assuming len(s) is the number of characters.
This is a very human mistake, because on English examples everything looks perfect. But in Go, len(s) returns the number of bytes in the UTF‑8 representation of the string. As soon as accented characters or “世界” show up, the length suddenly becomes larger. The fix is a habit: “len for string is bytes; range is characters”.
Error #2: thinking s[i] returns a character.
Indexing a string in Go returns a single byte (uint8), i.e., a fragment of UTF‑8, but not necessarily a whole character. On ASCII it matches; on UTF‑8 it usually does not. If you need an actual character, take a rune via range (or another character-aware method), not via s[i].
Error #3: confusing the meaning of i in for i, r := range s.
The variable i here is the byte position of the start of the next rune, not the “character number”. If you start using i as a character counter, you are almost guaranteed to get skewed results. A good habit is to name things explicitly: byteIndex for i and a separate count for the rune number.
Error #4: slicing a string s[a:b] “by characters” by plugging in a character counter.
String slicing cuts by bytes. If you plug a “character number” into that, the string may be cut in the middle of a UTF‑8 character and the result becomes invalid text. The correct approach is either to find byte boundaries via range (as in prefixRunes), or to use a different data representation if the task requires frequent character-based operations.
Error #5: not handling the empty string when trying to get the first character.
A “give me the first character” function must work correctly for "". If you do not plan for this, you will either return garbage or start writing hacks. A simple and readable approach is to return (rune, bool), where the bool says “did we find a character at all”.
GO TO FULL VERSION