UTF-8 Encoding Explained: Variable-Length Byte Patterns, BOM Pitfalls, and Mojibake Fixes
Every text file, web page, JSON API response, and database text column depends on character encoding β the mapping between abstract characters and bytes. UTF-8 won this war decisively: over 98% of web pages use it. Yet developers still get bitten by encoding bugs: garbled text (mojibake), invisible BOM characters breaking parsers, and emoji counting as two characters in JavaScript.
This article explains UTF-8's variable-length design at the bit level, with hand-verifiable examples.
Why Variable-Length?
Before UTF-8, the dominant encodings were fixed-width: ASCII used 1 byte per character (but only covered English), while UTF-16 and UTF-32 used 2 or 4 bytes for everything. The problem: English text in UTF-32 takes 4Γ the space of ASCII for zero benefit.
UTF-8 (RFC 3629) uses 1 to 4 bytes per character, with a clever prefix scheme that makes the encoding self-synchronizing β you can always tell where a character starts, even mid-stream:
| Code point range | Byte 1 | Byte 2 | Byte 3 | Byte 4 |
|---|---|---|---|---|
| U+0000 β U+007F | 0xxxxxxx |
β | β | β |
| U+0080 β U+07FF | 110xxxxx |
10xxxxxx |
β | β |
| U+0800 β U+FFFF | 1110xxxx |
10xxxxxx |
10xxxxxx |
β |
| U+10000 β U+10FFFF | 11110xxx |
10xxxxxx |
10xxxxxx |
10xxxxxx |
The leading bits tell you the sequence length: 0 = 1 byte, 110 = 2 bytes, 1110 = 3 bytes, 11110 = 4 bytes. Continuation bytes always start with 10. This design means:
- ASCII is valid UTF-8. Every ASCII byte (
0xxxxxxx) decodes identically. This single property drove UTF-8's adoption β existing ASCII systems upgraded seamlessly. - No byte is ambiguous. A byte starting with
10is always a continuation; you never mistake it for a character start.
Note: the original UTF-8 spec allowed up to 6 bytes, but RFC 3629 restricted it to 4 (matching Unicode's U+10FFFF limit). Five- and six-byte sequences are now invalid.
Worked Examples: Bits to Bytes
Example 1: 'A' (U+0041) β 1 byte
U+0041 = 65 decimal = 1000001 binary (7 bits). Fits in the 1-byte pattern 0xxxxxxx:
Code point: 1000001
UTF-8: 0 1000001 β 0x41
Hex: 41. Same as ASCII. β
Example 2: 'Γ©' (U+00E9) β 2 bytes
U+00E9 = 233 decimal = 11101001 binary (8 bits). Needs the 2-byte pattern 110xxxxx 10xxxxxx (11 payload bits).
Pad to 11 bits: 00011101001. Split into 5 + 6:
Payload: 00011 101001
Byte 1: 110 00011 β 11000011 β 0xC3
Byte 2: 10 101001 β 10101001 β 0xA9
Hex: C3 A9. Verify in Python: 'Γ©'.encode('utf-8').hex() β 'c3a9' β
Example 3: 'δΈ' (U+4E2D) β 3 bytes
U+4E2D = 0100111000101101 binary (16 bits). Uses the 3-byte pattern 1110xxxx 10xxxxxx 10xxxxxx (16 payload bits). Split into 4 + 6 + 6:
Payload: 0100 111000 101101
Byte 1: 1110 0100 β 11100100 β 0xE4
Byte 2: 10 111000 β 10111000 β 0xB8
Byte 3: 10 101101 β 10101101 β 0xAD
Hex: E4 B8 AD. Verify: 'δΈ'.encode('utf-8').hex() β 'e4b8ad' β
Example 4: 'π' (U+1F600) β 4 bytes
U+1F600 = 00011111011000000000 binary (20 bits). Uses the 4-byte pattern 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx (21 payload bits). Pad to 21 bits and split 3 + 6 + 6 + 6:
Payload: 000 011111 011000 000000
Byte 1: 11110 000 β 11110000 β 0xF0
Byte 2: 10 011111 β 10011111 β 0x9F
Byte 3: 10 011000 β 10011000 β 0x98
Byte 4: 10 000000 β 10000000 β 0x80
Hex: F0 9F 98 80. Verify: 'π'.encode('utf-8').hex() β 'f09f9880' β
The BOM Problem
The Byte Order Mark (U+FEFF) is an invisible zero-width character that some editors (notably Windows Notepad) prepend to UTF-8 files: bytes EF BB BF.
UTF-8 doesn't need a BOM (byte order is irrelevant β it's a byte sequence, not 16-bit units). But the BOM causes real bugs:
- JSON parsers may choke:
JSON.parse('\uFEFF{"a":1}')throws in strict parsers. - CSV headers get corrupted: the first column becomes
"\uFEFFid"instead of"id", breaking database imports. - Shebang lines break:
#!/usr/bin/env pythonpreceded by a BOM isn't recognized as a shebang on Unix. - String comparison fails silently:
"hello" !== "\uFEFFhello"but they look identical.
Detection and removal:
def strip_bom(text: str) -> str:
"""Remove UTF-8 BOM if present."""
return text.lstrip('\ufeff')
# Or when reading files:
with open('data.csv', encoding='utf-8-sig') as f:
content = f.read() # utf-8-sig automatically strips BOM
// JavaScript
const clean = text.replace(/^\uFEFF/, '');
Python's utf-8-sig codec handles this transparently β use it when reading files from untrusted sources (user uploads, Windows-generated exports).
Mojibake: When Encodings Collide
Mojibake (ζεεγ, "character corruption") happens when bytes in one encoding are decoded as another. The classic case: UTF-8 bytes read as Latin-1 (ISO-8859-1).
Take 'Γ©' (UTF-8: C3 A9). If a Latin-1 decoder reads those two bytes:
Byte C3 β Latin-1 character 'Γ' (U+00C3)
Byte A9 β Latin-1 character 'Β©' (U+00A9)
Result: Γ© becomes ΓΒ©. If you've ever seen ΓΒ© or Γ’β¬β’ on a web page, that's UTF-8 misread as Latin-1 or Windows-1252.
Common causes and fixes:
| Symptom | Cause | Fix |
|---|---|---|
ΓΒ©, Γ’β¬β’ |
UTF-8 decoded as Latin-1/Win-1252 | Set correct charset in Content-Type header and <meta charset> |
???? or οΏ½ |
Decoder hit invalid bytes, replaced with U+FFFD | Ensure source is actually UTF-8; check for truncated multi-byte sequences |
Chinese shows as À¸ |
UTF-8 decoded as Latin-1 | Same as above β declare UTF-8 everywhere |
The nuclear option for already-corrupted data (when you know the misdecoding path):
# Fix double-encoded UTF-8 (UTF-8 bytes misread as Latin-1, then re-encoded as UTF-8)
def fix_mojibake(text: str) -> str:
try:
return text.encode('latin-1').decode('utf-8')
except (UnicodeEncodeError, UnicodeDecodeError):
return text # Not fixable this way
fix_mojibake('ΓΒ©') # β 'Γ©'
JavaScript's String Length Trap
JavaScript strings are UTF-16. Characters outside the Basic Multilingual Plane (emoji, rare CJK, historic scripts) are stored as surrogate pairs β two 16-bit code units:
'π'.length // 2, not 1!
'π'[0] // '\uD83D' (lone surrogate β garbage if used alone)
[...'π'].length // 1 (spread iterates code points, not code units)
'A'.length // 1
'Γ©'.length // 1 (U+00E9 is in BMP)
'δΈ'.length // 1 (U+4E2D is in BMP)
'π'.length // 2 (U+1F600 needs surrogate pair)
This breaks naive string truncation, character counting, and cursor positioning. The fix: use Array.from() or spread syntax for code-point-aware operations, or Intl.Segmenter for grapheme clusters (what users perceive as "characters"):
// Correct emoji-aware truncation
function truncate(str, maxCodePoints) {
return [...str].slice(0, maxCodePoints).join('');
}
truncate('Hello ππ', 7) // 'Hello π' (not 'Hello ποΏ½')
UTF-8 vs UTF-16: When Does It Matter?
| UTF-8 | UTF-16 | |
|---|---|---|
| ASCII/English | 1 byte/char (optimal) | 2 bytes/char (2Γ waste) |
| CJK text | 3 bytes/char | 2 bytes/char (more compact) |
| Emoji | 4 bytes/char | 4 bytes/char (surrogate pair) |
| Web standard | Yes (98%+ of pages) | Rare |
| Self-synchronizing | Yes | No (can misalign on odd byte) |
For most applications, UTF-8 is the right default. UTF-16 only wins for predominantly CJK text, and even then the complexity rarely justifies it.
Try It
Working with text encoding means constantly converting between representations β characters to code points to byte sequences. DailyToolbox's free text tools run entirely in your browser: inspect Unicode code points, convert between encodings, and debug the invisible characters (BOM, zero-width spaces) that cause so many production bugs.
FAQ
Is UTF-8 the same as Unicode?
No. Unicode is the character set β the mapping of abstract characters to code points (U+0041 = 'A'). UTF-8 is one encoding of Unicode β a specific way to represent those code points as bytes. Other encodings include UTF-16 and UTF-32. Think of Unicode as the dictionary and UTF-8 as one particular printing of it.
Why do some files start with invisible characters?
That's the BOM (Byte Order Mark, U+FEFF, bytes EF BB BF). Windows tools add it to mark the file as UTF-8. It's invisible in most editors but breaks parsers, shebangs, and string comparisons. Strip it on input with Python's utf-8-sig codec or an explicit check.
What's the maximum valid Unicode code point?
U+10FFFF. UTF-8's 4-byte pattern can theoretically encode up to U+1FFFFF (21 payload bits), but RFC 3629 restricts it to the Unicode range. Surrogate code points (U+D800βU+DFFF) are also invalid in UTF-8 β they're UTF-16 artifacts and must never appear in UTF-8 byte sequences.
Why does my emoji break when I truncate a string in JavaScript?
JavaScript's .length counts UTF-16 code units, not characters. Emoji like π are stored as two code units (a surrogate pair). Truncating at an odd index splits the pair, producing a lone surrogate (οΏ½). Use [...str] to work with code points instead.