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.
graph TD
G["Grapheme Cluster (User-Perceived Character)"]
R1["Rune 1 (Base Code Point)"]
R2["Rune 2 (Zero-Width Joiner / Modifier)"]
R3["Rune 3 (Combining Mark)"]
B1["UTF-8 Bytes (1-4 B)"]
B2["UTF-8 Bytes (1-4 B)"]
B3["UTF-8 Bytes (1-4 B)"]
G --> R1
G --> R2
G --> R3
R1 --> B1
R2 --> B2
R3 --> B3
class G diag-ingress;
class R1,R2,R3 diag-compute;
class B1,B2,B3 diag-storage;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.
graph LR
T["வணக்கம் (Tamil)"] -->|"len"| B1["21 Bytes"]
T -->|"utf8.RuneCount"| R1["7 Runes"]
T -->|"uniseg"| G1["5 Graphemes (வ, ண, க், க, ம்)"]
E["👨👩👧👦 (Family Emoji)"] -->|"len"| B2["25 Bytes"]
E -->|"utf8.RuneCount"| R2["7 Runes"]
E -->|"uniseg"| G2["1 Grapheme"]
class T,E diag-ingress;
class B1,B2 diag-storage;
class R1,R2 diag-compute;
class G1,G2 diag-success;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 |