UTF-8: The Engineering Masterpiece

UTF-8 is not merely "variable-length Unicode encoding." It is a carefully engineered prefix code with properties that make it the dominant encoding on the web (98%+ of web pages as of 2024).

Design Properties

1. Self-synchronizing: You can jump to any byte in a UTF-8 stream and find the start of the next character by scanning forward at most 3 bytes. Continuation bytes (10xxxxxx) are distinguishable from leading bytes — you never need to scan backward to determine context.

code
Leading bytes:  0xxxxxxx, 110xxxxx, 1110xxxx, 11110xxx
Continuation:   10xxxxxx

If you land on a 10xxxxxx byte, skip forward until you hit
a byte that doesn't start with 10. That's the next character.

2. Prefix-free: No valid encoding of one character is a prefix of another. This means a byte-oriented search (like grep) works correctly without understanding UTF-8 boundaries — a match for an ASCII string can never accidentally match a fragment of a multi-byte character.

3. ASCII transparency: All ASCII bytes (0x00–0x7F) encode identically in UTF-8. Conversely, no multi-byte character contains bytes in the 0x00–0x7F range. This means:

  • Existing ASCII-based tools (path separators, null terminators, format strings) work unchanged
  • C string functions that look for / or \0 work correctly on UTF-8 strings

4. Byte-order independence: Unlike UTF-16 and UTF-32, UTF-8 has no byte-order ambiguity and requires no BOM.

5. Sorting preservation: UTF-8 byte-order sorting produces the same result as Unicode code point sorting.

Encoding Table

Code point range Byte 1 Byte 2 Byte 3 Byte 4 Bits available
U+0000–U+007F 0xxxxxxx 7
U+0080–U+07FF 110xxxxx 10xxxxxx 11
U+0800–U+FFFF 1110xxxx 10xxxxxx 10xxxxxx 16
U+10000–U+10FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 21

Worked Example: Encoding U+4E2D (中)

code
Code point: 0x4E2D = 0100 1110 0010 1101 (16 bits)
Range: U+0800–U+FFFF → 3-byte form: 1110xxxx 10xxxxxx 10xxxxxx

Distribute 16 bits into slots:
  1110[0100] 10[111000] 10[101101]
       ^^^^     ^^^^^^     ^^^^^^
  Byte 1: 0xE4    Byte 2: 0xB8    Byte 3: 0xAD

Verify: grep for "中" in a UTF-8 file searches for bytes E4 B8 AD.
None of these bytes (0xE4, 0xB8, 0xAD) fall in 0x00–0x7F,
so they cannot be confused with ASCII characters.

Why Not UTF-16?

UTF-16 was designed when Unicode was believed to fit in 16 bits (the "Basic Multilingual Plane" assumption). When Unicode expanded beyond U+FFFF, UTF-16 needed surrogate pairs — making it variable-length too, but without UTF-8's self-synchronization and ASCII compatibility.

UTF-16 disadvantages:

  • Byte-order dependent (requires BOM or external agreement)
  • Contains null bytes for ASCII text (breaks C string functions)
  • Not ASCII-transparent (path separators become two bytes)
  • Still variable-length (2 or 4 bytes) — no simplicity advantage
  • Wastes space for ASCII-heavy text (doubles the size)

UTF-16 persists in Windows APIs, Java char, JavaScript strings, and .NET string — all designed during the "16 bits is enough" era.

Unicode Normalization: Why "Café" ≠ "Café"

The Problem

Unicode allows multiple representations of the same visual character:

python
# These look identical but are different byte sequences:
a = "é"   # U+00E9 (precomposed: LATIN SMALL LETTER E WITH ACUTE)
b = "é"   # U+0065 U+0301 (decomposed: e + COMBINING ACUTE ACCENT)

a == b     # False!
len(a)     # 1
len(b)     # 2

This causes bugs in string comparison, database lookups, filenames, and security checks.

Four Normalization Forms

Form Name Strategy Use case
NFC Canonical Composition Decompose, then recompose Default for web, interchange
NFD Canonical Decomposition Fully decompose macOS filesystem (HFS+)
NFKC Compatibility Composition Compatibility decompose, recompose Search, identifiers
NFKD Compatibility Decomposition Compatibility decompose Collation, matching

Canonical vs Compatibility Equivalence

Canonical equivalence: Same character, different representation. NFC/NFD convert between these without information loss.

code
é (U+00E9) ↔ e + ◌́ (U+0065 U+0301)   [canonical]

Compatibility equivalence: Visually similar but semantically different. NFKC/NFKD conflate these — information is lost.

code
fi (U+FB01) → fi (U+0066 U+0069)   [compatibility: ligature → letters]
① (U+2460) → 1 (U+0031)           [compatibility: circled → plain]
Ⅳ (U+2163) → IV                   [compatibility: roman numeral]

Normalization in Practice

python
import unicodedata

text = "Caf\u0065\u0301"  # "Café" with decomposed é

nfc = unicodedata.normalize('NFC', text)
nfd = unicodedata.normalize('NFD', text)

print(len(text))  # 5 (C, a, f, e, combining accent)
print(len(nfc))   # 4 (C, a, f, é)
print(len(nfd))   # 5 (always fully decomposed)

# Database key comparison MUST normalize first
assert unicodedata.normalize('NFC', user_input) == stored_value

The macOS HFS+ Problem

macOS HFS+ (and APFS for compatibility) stores filenames in NFD form. When you create a file named "café.txt", the filesystem stores it as "cafe\u0301.txt". If your application compares filenames without normalizing, you get phantom "file not found" errors on macOS but not Linux (which stores bytes verbatim).

python
import os
import unicodedata

filename = "café.txt"
os.path.exists(filename)  # May fail on macOS if filename is NFC

# Safe cross-platform comparison
def safe_filename_compare(a, b):
    return unicodedata.normalize('NFC', a) == unicodedata.normalize('NFC', b)

Encoding Security

Overlong UTF-8 Sequences

The UTF-8 specification requires that each code point use the shortest possible encoding. The character / (U+002F) must be encoded as the single byte 0x2F. But technically, the bit pattern allows:

code
U+002F as 1 byte: 2F              (valid)
U+002F as 2 bytes: C0 AF          (ILLEGAL overlong)
U+002F as 3 bytes: E0 80 AF       (ILLEGAL overlong)

Security impact: Early web servers decoded UTF-8 before checking for path traversal. An attacker could bypass ../ detection by encoding the dots and slashes as overlong sequences. The security check saw C0 AE C0 AE C0 AF (not matching "../"), but the decoder produced "../".

CVE-2000-0884 (IIS Unicode directory traversal) exploited exactly this.

Mitigation: All modern UTF-8 decoders reject overlong sequences. Never implement your own UTF-8 decoder — use the platform's validated implementation.

Homoglyph Attacks

Characters from different scripts that appear visually identical:

Latin Cyrillic Confusable?
a (U+0061) а (U+0430) Yes
e (U+0065) е (U+0435) Yes
o (U+006F) о (U+043E) Yes
p (U+0070) р (U+0440) Yes
c (U+0063) с (U+0441) Yes
x (U+0078) х (U+0445) Yes

This enables domain spoofing: аpple.com (Cyrillic а) vs apple.com (Latin a).

Mitigations:

  • IDN (Internationalized Domain Name) display rules: browsers show punycode if mixed scripts detected
  • Unicode Security Mechanisms (UTS #39): confusable detection algorithms
  • NFKC normalization before identifier comparison

Bidirectional Override Exploits

Unicode includes directional control characters:

Character Code point Effect
RLO U+202E Right-to-Left Override
LRO U+202D Left-to-Right Override
RLI U+2067 Right-to-Left Isolate
PDF U+202C Pop Directional Formatting

Attack: Filenames containing RLO can disguise file extensions:

code
Displayed: "document[RLO]fdp.exe"
Appears as: "document‮fdp.exe" → shows as "documentexe.pdf"

The user sees "documentexe.pdf" but the actual filename ends in ".exe".

CVE-2021-42574 (Trojan Source): Bidirectional control characters in source code make code review see different logic than what the compiler executes.

python
# Looks like: if access_level != "user":
# Actually:   if access_level != "user‮ ⁦// Check admin⁩⁦":

Mitigations:

  • Strip or reject bidi control characters in user-supplied identifiers
  • Source code editors should render bidi markers visibly
  • GitHub and GitLab now flag files containing bidi control characters

Null Byte Injection

In C-based systems, null byte (0x00) terminates strings. If a higher-level language (PHP, Python 2) passes a string containing null to a C library:

code
User input: "malicious.php\x00.jpg"
PHP check:  endsWith(".jpg") → passes
C fopen():  sees "malicious.php" (stops at \x00)

UTF-8's design prevents this in multi-byte sequences (continuation bytes never equal 0x00), but the issue persists when mixing encoding-unaware C APIs with higher-level strings.

The CJK Encoding Legacy

Before Unicode, each region developed its own encoding:

Encoding Region Characters Bytes/char
Shift_JIS Japan ~7,000 kanji 1–2
EUC-JP Japan (Unix) ~7,000 kanji 1–3
GB2312 China (simplified) 6,763 hanzi 2
GBK China (extended) 21,886 hanzi 1–2
GB18030 China (mandatory standard) 70,000+ 1–4
Big5 Taiwan/HK (traditional) 13,060 hanzi 1–2
EUC-KR Korea 2,350 hangul syllables 1–2

The Mojibake Problem

"Mojibake" (文字化け) occurs when text encoded in one scheme is decoded with another:

python
# UTF-8 bytes for "中文" decoded as Latin-1
"中文".encode('utf-8')        # b'\xe4\xb8\xad\xe6\x96\x87'
b'\xe4\xb8\xad\xe6\x96\x87'.decode('latin-1')  # '中æ\x96\x87' (garbled)

# GBK bytes for "中文" decoded as UTF-8
"中文".encode('gbk')          # b'\xd6\xd0\xce\xc4'
b'\xd6\xd0\xce\xc4'.decode('utf-8', errors='replace')  # '��ζ' (garbled)

GB18030: China's Mandatory Standard

GB18030 is notable because:

  • It is legally mandatory for software sold in China (GB18030-2022)
  • It is a superset of GBK (backward compatible)
  • It encodes the full Unicode repertoire (1–4 bytes)
  • It is an alternative to UTF-8 for full Unicode coverage

MySQL: utf8 vs utf8mb4

The Famous Bug

MySQL's utf8 charset uses a maximum of 3 bytes per character. This means it cannot store characters above U+FFFF — which includes all emoji, many CJK extension characters, and mathematical symbols.

sql
-- FAILS: emoji stored in utf8 column
INSERT INTO messages (text) VALUES ('Hello 😀');
-- Error: Incorrect string value '\xF0\x9F\x98\x80' for column 'text'

-- The 😀 emoji is U+1F600, requiring 4 UTF-8 bytes (F0 9F 98 80)

The Fix: utf8mb4

sql
-- Correct: use utf8mb4 for full Unicode support
ALTER TABLE messages CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- For new tables
CREATE TABLE messages (
    id INT PRIMARY KEY,
    text VARCHAR(255) CHARACTER SET utf8mb4
) DEFAULT CHARSET=utf8mb4;

-- Connection charset
SET NAMES utf8mb4;

utf8mb4 is "real UTF-8" — it supports 1–4 bytes per character and the full Unicode range.

Index Length Impact

When converting from utf8 to utf8mb4, index key length increases:

  • VARCHAR(255) with utf8: max index size = 255 × 3 = 765 bytes
  • VARCHAR(255) with utf8mb4: max index size = 255 × 4 = 1020 bytes

InnoDB's default index prefix limit is 767 bytes. After converting to utf8mb4, a VARCHAR(255) column with a full-length index may exceed this limit. Solutions:

  • Use innodb_large_prefix=ON (default in MySQL 5.7+)
  • Reduce VARCHAR length to 191 (191 × 4 = 764 < 767)
  • Use prefix indexes: INDEX (text(191))

Python: str vs bytes Boundary

Python 3's Clean Model

python
# str: sequence of Unicode code points (abstract text)
text = "Hello 中文"
type(text)       # <class 'str'>
len(text)        # 8 (code points, not bytes)

# bytes: sequence of raw bytes (encoded data)
encoded = text.encode('utf-8')
type(encoded)    # <class 'bytes'>
len(encoded)     # 12 (H,e,l,l,o,space = 6 bytes + 中 = 3 + 文 = 3)

# You cannot mix str and bytes
text + encoded   # TypeError

The Encoding Boundary Rule

code
     str (text)              bytes (wire/disk)
  ┌─────────────┐         ┌─────────────────┐
  │ Unicode code │ encode  │ Raw byte stream  │
  │ points       │────────→│ (UTF-8, GBK, …) │
  │              │←────────│                  │
  │              │ decode  │                  │
  └─────────────┘         └─────────────────┘
                  ↑
           Encoding boundary:
           all I/O crosses this

Rule: decode at system boundaries, work with str internally, encode at output.

python
# Reading a file
with open('data.txt', encoding='utf-8') as f:
    text = f.read()  # returns str (decoded)

# Network response
response = urllib.request.urlopen(url)
raw = response.read()            # bytes
text = raw.decode('utf-8')       # str

# Writing output
with open('output.txt', 'w', encoding='utf-8') as f:
    f.write(text)  # str → bytes happens implicitly

Surrogate Escapes (surrogatepass)

Python uses surrogate escapes to handle filenames on Unix that aren't valid UTF-8:

python
import os

# Unix filenames are bytes, not guaranteed UTF-8
# Python decodes them with 'surrogateescape' error handler
entries = os.listdir('.')  # Returns str, but may contain surrogates

# A filename with invalid UTF-8 byte 0xFF:
# Python represents it as U+DCFF (a surrogate code point)
# This round-trips correctly back to the original bytes

The WHATWG Encoding Standard

The web platform uses the WHATWG Encoding Standard (not IANA charset registry) to determine how character encoding labels map to decoders.

Key behaviors:

  • "ascii" label maps to windows-1252 decoder (not true ASCII)
  • "iso-8859-1" label maps to windows-1252 decoder
  • There are only ~40 supported encodings; all others fall back to UTF-8
  • GB18030 decoder also handles GBK and GB2312 labels
  • UTF-8 is the default encoding for <meta charset> if none specified
javascript
// TextDecoder follows WHATWG Encoding Standard
const decoder = new TextDecoder('windows-1252');
const text = decoder.decode(bytes);

// 'fatal' mode throws on invalid sequences instead of replacing
const strictDecoder = new TextDecoder('utf-8', { fatal: true });
try {
  strictDecoder.decode(invalidBytes);
} catch (e) {
  // TypeError: invalid UTF-8 sequence
}

Grapheme Clusters: What Users Perceive as "Characters"

The String Length Lie

javascript
"café".length            // 4 or 5 (depends on normalization)
"👨‍👩‍👧‍👦".length      // 11 (7 code points as UTF-16 units)
"🇯🇵".length            // 4 (2 regional indicator symbols, each a surrogate pair)
"நி".length              // 2 (Tamil NI = consonant + vowel sign)

// What users perceive:
// "café"    = 4 characters
// "👨‍👩‍👧‍👦" = 1 character (family emoji)
// "🇯🇵"    = 1 character (flag)
// "நி"      = 1 character (syllable)

Intl.Segmenter: The Correct Solution

javascript
function graphemeLength(str) {
  const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
  return [...segmenter.segment(str)].length;
}

graphemeLength("👨‍👩‍👧‍👦")  // 1
graphemeLength("café")         // 4 (NFC) or 4 (NFD, one grapheme cluster per visual char)
graphemeLength("🇯🇵")         // 1

Implications for Text Processing

Operation Naive (code units) Correct (grapheme clusters)
Truncation May split emoji in half Preserves complete characters
Cursor movement Jumps inside emoji Moves between visual characters
Input counting "280 characters" means different things Twitter uses NFC scalar count
Substring May create invalid sequences Clean boundaries

Encoding Detection Heuristics

When no metadata declares the encoding, detection relies on statistical heuristics:

python
# chardet / charset-normalizer approach:
# 1. Check BOM (definitive if present)
# 2. Try UTF-8 strict decode — if valid, almost certainly UTF-8
# 3. Check for encoding-specific byte patterns:
#    - Shift_JIS: 0x81-0x9F or 0xE0-0xEF as lead byte
#    - GBK: 0x81-0xFE as lead byte, 0x40-0xFE as trail
#    - EUC-KR: 0xA1-0xFE as lead and trail
# 4. Character frequency analysis (language model)

import charset_normalizer

results = charset_normalizer.from_bytes(raw_bytes)
best = results.best()
print(best.encoding)    # e.g., 'utf-8', 'gbk', 'shift_jis'
print(best.language)    # e.g., 'Chinese', 'Japanese'

The fundamental limitation: encoding detection is heuristic, never certain. A byte sequence may be valid in multiple encodings. The only reliable approach is to declare the encoding explicitly (HTTP Content-Type header, HTML meta charset, BOM, or out-of-band metadata).

Summary

Principle Implementation
Always use UTF-8 HTTP Content-Type: text/html; charset=utf-8, <meta charset="utf-8">, database utf8mb4
Normalize before comparing unicodedata.normalize('NFC', text) or text.normalize('NFC')
Decode at boundaries Read bytes → decode → work with text → encode → write bytes
Never roll your own UTF-8 decoder Use platform APIs; custom decoders miss overlong/surrogate validation
Treat encoding labels skeptically WHATWG maps "ascii" to windows-1252; declared encoding may be wrong
Count graphemes for UI Intl.Segmenter (JS), grapheme crate (Rust), ICU (C/C++)
Reject bidi overrides in identifiers Strip U+202A–U+202E, U+2066–U+2069 from user-supplied code/names

References

  • Unicode Standard, Version 15.1 — unicode.org/versions/latest
  • RFC 3629 — UTF-8, a transformation format of ISO 10646
  • WHATWG Encoding Standard — encoding.spec.whatwg.org
  • Unicode Technical Report #15 — Unicode Normalization Forms
  • Unicode Technical Standard #39 — Unicode Security Mechanisms
  • MySQL Reference Manual — "The utf8mb4 Character Set (4-Byte UTF-8 Unicode Encoding)"