Runes, Bytes, and Graphemes in Go
Bytes, runes, and grapheme clusters are not the same length: handling Tamil, ZWJ emoji, and text reversal correctly.
Text processing in Go operates across three distinct layers of abstraction: bytes, runes (Unicode code points), and extended grapheme clusters (user-perceived characters).
Treating code points or bytes as visible characters corrupts non-Latin scripts (like Tamil, Hindi, or Arabic) and multi-code-point emoji sequences.
1. Bytes: UTF-8 Storage Layer
In Go, a string is an immutable, read-only slice of arbitrary bytes. The built-in len(s) function returns the number of bytes, not characters:
s := "வணக்கம்"
fmt.Println(len(s)) // 21 bytes (3 bytes per Tamil code point)Direct indexing s[0] extracts a raw uint8 byte. If you slice a multi-byte UTF-8 sequence halfway through (s[:2]), you produce invalid, corrupted UTF-8.
2. Runes: Unicode Code Points
A rune in Go is an alias for int32, representing a single Unicode Code Point:
rs := []rune(s)
fmt.Println(len(rs)) // 7 runesWhile []rune(s) converts a string to code points, it allocates a new slice on the heap. For zero-allocation iteration, use a range loop or the standard library unicode/utf8 package:
import "unicode/utf8"
// Zero heap allocation
count := utf8.RuneCountInString(s) // 73. Grapheme Clusters: User-Perceived Characters
A single visible character often consists of multiple Unicode code points joined together.
Example A: Tamil Syllables with Virama
In the word "வணக்கம்", the syllable "க்" combines two independent code points:
- Base consonant:
க(U+0B95) - Pulli / Virama combining mark:
்(U+0BCD)
To a human reader, "க்" is 1 character, but to Go’s []rune, it is 2 runes.
Example B: Zero-Width Joiner (ZWJ) Emoji
Consider the family emoji sequence: 👨👩👧👦
emoji := "👨👩👧👦"
fmt.Println(len(emoji)) // 25 bytes
fmt.Println(utf8.RuneCountInString(emoji)) // 7 runes (Man + ZWJ + Woman + ZWJ + Girl + ZWJ + Boy)If you slice this emoji by rune or reverse it with a naive rune loop, the family breaks apart into four separate individuals separated by invisible joiners.
Proper Grapheme Iteration with uniseg
Go’s standard library stops at code points (rune). For Unicode Standard Annex #29 compliant grapheme clustering, use github.com/rivo/uniseg:
package main
import (
"fmt"
"slices"
"github.com/rivo/uniseg"
)
// ReverseGraphemes reverses a string without breaking combining marks or ZWJ sequences
func ReverseGraphemes(s string) string {
var clusters []string
gr := uniseg.NewGraphemes(s)
for gr.Next() {
clusters = append(clusters, gr.Str())
}
slices.Reverse(clusters)
var result string
for _, c := range clusters {
result += c
}
return result
}
func main() {
fmt.Println(ReverseGraphemes("வணக்கம்")) // ம் க க் ண வ (Valid Tamil graphemes preserved)
}Text Processing Reference
| Objective | Technique | Allocation Profile |
|---|---|---|
| Byte count | len(s) |
Zero allocation |
| Code point count | utf8.RuneCountInString(s) |
Zero allocation |
| Decode code points | for idx, r := range s |
Zero allocation |
| Visible character count | uniseg.GraphemeClusterCount(s) |
Zero allocation |
| Safe truncation / display | Segment on grapheme boundaries via uniseg |
Minimal slice allocation |