Go strings are UTF-8 encoded byte sequences. The standard library’s unicode and unicode/utf8 packages cover basic rune classification and encoding. For production-quality internationalization — correct string comparison, sorting, grapheme clusters, and non-UTF-8 encoding conversion — the extended golang.org/x/text packages are the right tool.
For the byte/rune fundamentals see Go bytes runes and Unicode.
UTF-8 Encoding Mechanics
Understanding UTF-8 encoding helps you predict how many bytes a string takes and why byte indexing of strings is dangerous for non-ASCII text:
| Character | Unicode code point | UTF-8 bytes | Byte count |
|---|---|---|---|
A |
U+0041 | 0x41 |
1 |
é |
U+00E9 | 0xC3 0xA9 |
2 |
世 |
U+4E16 | 0xE4 0xB8 0x96 |
3 |
😀 |
U+1F600 | 0xF0 0x9F 0x98 0x80 |
4 |
ASCII characters (U+0000–U+007F) are single-byte in UTF-8 — identical to ASCII encoding. Characters U+0080–U+07FF take 2 bytes, U+0800–U+FFFF take 3, and emoji/supplementary characters take 4.
import "unicode/utf8"
s := "Hello, 世界!"
fmt.Println(len(s)) // 14 bytes
fmt.Println(utf8.RuneCountInString(s)) // 10 characters
// Encode a rune to bytes
buf := make([]byte, utf8.UTFMax) // UTFMax = 4
n := utf8.EncodeRune(buf, '世')
fmt.Printf("世 = %X (%d bytes)\n", buf[:n], n) // E4 B8 96 (3 bytes)
// Decode a rune from bytes
r, size := utf8.DecodeRuneInString("世界")
fmt.Printf("first rune: %c, byte width: %d\n", r, size) // 世, 3
The Normalization Problem
Two strings that look identical can have different byte representations. The letter é can be encoded as:
- U+00E9 (precomposed: é as a single code point)
- U+0065 U+0301 (decomposed: e followed by combining acute accent)
Both render identically, but strings.Compare and == treat them as different:
composed := "café" // U+00E9 for é (NFC form)
decomposed := "cafe\u0301" // e + combining acute (NFD form)
fmt.Println(composed == decomposed) // false — different bytes!
fmt.Println(len(composed)) // 5 bytes
fmt.Println(len(decomposed)) // 6 bytes
This causes subtle bugs when comparing user input (which might come in any form) against stored data (which might be in a different form). The fix is to normalize to a canonical form before comparison.
go get golang.org/x/text
import "golang.org/x/text/unicode/norm"
// NFC: Canonical Decomposition followed by Canonical Composition
// (most common form; what databases and most systems produce)
nfc := norm.NFC.String(decomposed)
fmt.Println(nfc == composed) // true — both are now NFC
// NFD: Canonical Decomposition
// (base characters with combining marks as separate code points)
nfd := norm.NFD.String(composed)
fmt.Println(len(nfd)) // 6 — é is decomposed to e + accent
// NFKC: Compatibility Decomposition + Composition
// (normalizes visual variants: fi → fi, ① → 1, etc.)
nfkc := norm.NFKC.String("first") // fi ligature
fmt.Println(nfkc) // "first" — ligature expanded
Rule: normalize user input to NFC before storing and before comparison. NFC is the form expected by most databases, REST APIs, and file systems.
Grapheme Clusters: What Users Actually See
A “character” from a user’s perspective is often multiple code points. An emoji with skin tone modifier is 2 code points. A family emoji can be 7+. Korean syllables with jamo can be split into components. A “letter” + combining mark is 2 code points in NFD form.
utf8.RuneCountInString counts code points, not visible characters. For correct visible character counting and string truncation, use grapheme clusters from golang.org/x/text/unicode/grapheme:
import "golang.org/x/text/unicode/grapheme"
// Family emoji: 👨👩👧👦 = 7 code points (4 persons + 3 zero-width joiners)
family := "👨👩👧👦"
fmt.Println(utf8.RuneCountInString(family)) // 7
fmt.Println(grapheme.GraphemeClusterCount(family)) // 1 — one visible character
// Correct string truncation by visible characters
func truncateByGrapheme(s string, maxClusters int) string {
count := 0
for i := 0; i < len(s); {
_, size := grapheme.FirstGraphemeCluster(s[i:], -1)
if count >= maxClusters {
return s[:i] + "…"
}
i += size
count++
}
return s
}
For most internal processing — splitting on ASCII delimiters, extracting field values, URL parsing — rune-level iteration is sufficient. Grapheme clusters matter for user-visible text manipulation: truncating bios, counting tweet characters, word wrapping in UI.
Correct Unicode String Comparison
Case-folding and locale-aware comparison require golang.org/x/text/cases and golang.org/x/text/collate:
import (
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// Case-fold for case-insensitive comparison (handles Unicode correctly)
c := cases.Fold()
fmt.Println(c.String("CAFÉ") == c.String("café")) // true
fmt.Println(c.String("Straße") == c.String("STRASSE")) // true (German ß folds to ss)
// ToUpper/ToLower with locale — Turkish 'i' vs English 'I'
turkishUpper := cases.Upper(language.Turkish)
englishUpper := cases.Upper(language.English)
fmt.Println(turkishUpper.String("istanbul")) // İSTANBUL (dotted İ)
fmt.Println(englishUpper.String("istanbul")) // ISTANBUL (dotless I)
Note: strings.ToLower and strings.ToUpper use Unicode’s case mapping but without locale awareness. For user-facing text in languages with locale-sensitive case rules (Turkish, Azerbaijani, Lithuanian), use golang.org/x/text/cases.
Locale-Aware Sorting
Standard sort.Strings sorts byte values, which doesn’t match alphabetical order in most languages. German umlauts, French accents, and Spanish ñ all sort in unexpected positions:
import (
"golang.org/x/text/collate"
"golang.org/x/text/language"
)
words := []string{"Österreich", "Adalbert", "Zürich", "Bayern", "Äpfel"}
// Byte sort — wrong for German
sort.Strings(words)
// ["Adalbert", "Bayern", "Zürich", "Österreich", "Äpfel"]
// ↑ Wrong: Ä and Ö sort after Z instead of near A and O
// Locale-aware sort — correct
cl := collate.New(language.German)
cl.SortStrings(words)
// ["Adalbert", "Äpfel", "Bayern", "Österreich", "Zürich"]
// ↑ Correct: Ä sorts near A, Ö sorts near O
For user-facing sorted lists (names, places, words), always use locale-aware collation.
Handling Non-UTF-8 Encodings
Legacy systems, old files, and some protocols use encodings other than UTF-8: Latin-1, Windows-1252, Shift-JIS, GB18030. Convert them to UTF-8 before processing:
import (
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform"
"io"
"os"
)
// Read a Windows-1252 encoded file and convert to UTF-8
func readWindows1252(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
// Wrap the reader with a decoder
decoder := charmap.Windows1252.NewDecoder()
reader := transform.NewReader(f, decoder)
data, err := io.ReadAll(reader)
return string(data), err
}
// Convert UTF-8 string to Latin-1 bytes for legacy system
func toLatin1(s string) ([]byte, error) {
encoder := charmap.ISO8859_1.NewEncoder()
result, _, err := transform.String(encoder, s)
return []byte(result), err
}
The golang.org/x/text/encoding package covers dozens of legacy encodings. For unknown encoding detection, golang.org/x/text/encoding/charmap combined with golang.org/x/net/html/charset can detect the encoding from HTML meta tags or byte order marks.
Input Validation
Always validate UTF-8 encoding for input from external sources before processing:
import "unicode/utf8"
func validateUTF8(input string) error {
if !utf8.ValidString(input) {
return fmt.Errorf("input contains invalid UTF-8 sequences")
}
return nil
}
// Check for control characters and private-use code points
func sanitizeUserInput(s string) string {
return strings.Map(func(r rune) rune {
// Drop control characters (except tab, newline, carriage return)
if unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r' {
return -1 // drop the rune
}
// Replace replacement character (invalid UTF-8 decoded character)
if r == utf8.RuneError {
return -1
}
return r
}, s)
}
Summary
- UTF-8 encodes Unicode code points as 1–4 bytes;
utf8.RuneCountInStringcounts code points,lencounts bytes - NFC and NFD are different byte representations of the same visible text — normalize to NFC before comparison and storage using
golang.org/x/text/unicode/norm - Grapheme clusters (
golang.org/x/text/unicode/grapheme) are what users perceive as characters — use them for visible length and truncation, not rune count - Case-insensitive comparison requires
cases.Fold()fromgolang.org/x/text/cases— handles ß, Turkish dotted/dotless i, and other non-ASCII case mappings - Locale-aware sorting with
golang.org/x/text/collate— byte sorting produces wrong alphabetical order for most non-ASCII text - Convert legacy encodings (Latin-1, Windows-1252) to UTF-8 using
golang.org/x/text/encoding+transform.NewReader
Resources
- golang.org/x/text
- unicode/utf8 package
- Go Blog: Strings, bytes, runes and characters
- Unicode normalization FAQ
Comments