Strings, Bytes and Runes in Go: Handling Unicode Correctly
Chapter 02 of 04

Strings, Bytes and Runes in Go: Handling Unicode Correctly

Why len("সাব্বির") is 21: bytes vs runes, UTF-8, ranging over strings and building strings efficiently.

Sabbir Ahmmed3 min read0
On this page

Why does len("সাব্বির") return 21 when the name has 7 characters? Because a Go string is a sequence of bytes, not characters. Understanding bytes, runes and UTF-8 is essential the moment your application handles names, Bangla text, emoji or anything beyond plain English.

Strings are read-only byte slices

A Go string is an immutable sequence of bytes. String literals in Go source files are UTF-8 encoded. ASCII characters take 1 byte, but most other characters take 2 to 4 bytes.

go
1
2
3
4
5
s := "Go"
fmt.Println(len(s))         // 2
fmt.Println(s[0])           // 71  (a byte, the ASCII code of 'G')
fmt.Println(string(s[0]))   // G
fmt.Println([]byte(s))      // [71 111]

Indexing a string gives you a byte (uint8), not a character. That is fine for ASCII and wrong for almost everything else.

Runes: Unicode code points

A rune is an alias for int32 and represents one Unicode code point. Converting a string to []rune decodes the UTF-8 bytes into code points.

runes.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
	"fmt"
	"unicode/utf8"
)

func main() {
	name := "সাব্বির"
	r := []rune(name)

	fmt.Println(len(name))                    // 21 bytes
	fmt.Println(len(r))                       // 7 runes
	fmt.Println(utf8.RuneCountInString(name)) // 7, without allocating a slice
}

Each Bangla letter here is 3 bytes in UTF-8, so 7 runes need 21 bytes.

Ranging over a string decodes runes

for i, v := range str walks the string rune by rune. The index i is the byte offset where each rune starts, which is why it jumps by 3:

go
1
2
3
for i, v := range "সাব্বির" {
	fmt.Printf("%d:%c(%U) ", i, v, v)
}
Output
1
0:স(U+09B8) 3:া(U+09BE) 6:ব(U+09AC) 9:্(U+09CD) 12:ব(U+09AC) 15:ি(U+09BF) 18:র(U+09B0)

Compare that with indexing bytes directly: name[0] is 224, the first byte of স, and name[0:3] happens to be the full letter only because it is exactly 3 bytes.

Warning
Never slice a non-ASCII string at arbitrary byte positions. name[0:2] cuts স in half and produces invalid UTF-8 that prints as �.

Changing characters

Strings are immutable, so you convert to a mutable []rune, change it, and convert back. This is the example from the repository:

go
1
2
3
4
name := "সাব্বির"
r := []rune(name)
r[0] = 'ছ'
fmt.Println(string(r)) // ছাব্বির

Each conversion allocates and copies, which is fine for occasional edits but expensive in a hot loop.

Code points vs what users see

One "visible character" can be several runes. In সাব্বির, the conjunct ব্ব is built from ব, the hasanta ্, and another ব. Emoji with skin tones, and flags, are also multiple code points. These user-perceived characters are called grapheme clusters. The Go standard library counts runes; for true grapheme handling (for example, "truncate to 10 visible characters") use a package such as github.com/rivo/uniseg.

Building strings efficiently

Because strings are immutable, s += x in a loop allocates a new string every time. Use strings.Builder instead:

go
1
2
3
4
5
var b strings.Builder
for i := range 3 {
	fmt.Fprintf(&b, "line %d\n", i)
}
result := b.String()

The strings package has everything else you need: strings.Split, strings.Join, strings.Contains, strings.ToUpper, strings.Fields and more. They are all UTF-8 aware.

Key takeaways

A string is immutable bytes; len returns bytes, not characters.
A rune is an int32 Unicode code point. Use []rune(s) or utf8.RuneCountInString to count characters.
range over a string yields runes and their byte offsets.
Never slice non-ASCII text at arbitrary byte indexes.
Use strings.Builder to build strings in loops.
#Golang#Strings#Unicode

Last updated on

Discussion0