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.
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\0work 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 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:
# 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.
é (U+00E9) ↔ e + ◌́ (U+0065 U+0301) [canonical]
Compatibility equivalence: Visually similar but semantically different. NFKC/NFKD conflate these — information is lost.
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
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).
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:
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 |
| U+202C | Pop Directional Formatting |
Attack: Filenames containing RLO can disguise file extensions:
Displayed: "document[RLO]fdp.exe"
Appears as: "documentfdp.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.
# 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:
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:
# 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.
-- 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
-- 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 bytesVARCHAR(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
# 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
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.
# 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:
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
// 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
"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
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:
# 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)"