Skip to main content

Unicode and String Encoding in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

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.RuneCountInString counts code points, len counts 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() from golang.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

Comments

👍 Was this article helpful?