Inside UTF-8
Text Encoding and Unicode
Course 1 ยท Chapter 2 ยท Inside UTF-8
Text Encoding and Unicode 1 showed that UTF-8 turns each code point into one to four bytes. This chapter opens the box. The rule is small enough to fit in one table, and once you see it you can write an encoder and a decoder in about thirty lines, understand why UTF-8 survives being cut in the wrong place, and see why decoders must refuse certain byte sequences.
The Whole Rule in One Table
UTF-8 puts the code point's bits into a fixed pattern. The first byte says how long the character is; every following byte is marked as a continuation. This is the table from RFC 3629, section 3:
| Code point range | Byte pattern (x = code point bits) | Free bits |
|---|---|---|
| U+0000 to U+007F | 0xxxxxxx | 7 |
| U+0080 to U+07FF | 110xxxxx 10xxxxxx | 11 |
| U+0800 to U+FFFF | 1110xxxx 10xxxxxx 10xxxxxx | 16 |
| U+10000 to U+10FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx | 21 |
Read the leading bits of a byte and you know its role:
0...is a complete one-byte (ASCII) character.10...is a continuation byte, never the start of a character.110...,1110...,11110...are lead bytes for sequences of 2, 3 and 4 bytes. The number of leading 1s is the length.
Worked Example: the Euro Sign
U+20AC is in the three-byte range. Written in 16 bits it is 0010 0000 1010 1100. Split it 4 + 6 + 6 and drop the pieces into the pattern:
Python agrees, and shows the same layout for one character of each length:
Writing an Encoder
The table translates directly into shifts and masks. cp >> 6 drops the low six bits; cp & 0x3F keeps only them; 0x80 | sets the continuation marker. The function also refuses numbers that are not valid
characters (surrogates and values above U+10FFFF), a rule covered below.
The second half is the strongest test available: it compares the hand-built encoder with Python's for every one of the 1,112,064 valid code points, and finds no difference. (Operator precedence matters in the
shifts: in Python >> binds tighter than &, which binds tighter than |, so the expressions need no brackets, but brackets are kinder to readers.)
Self-Synchronising: Recovering from Damage
Because continuation bytes always match 10xxxxxx and no lead byte or ASCII byte does, you can drop into the middle of a UTF-8 stream and find the next character boundary: skip forward past continuation bytes.
Equally, you can find where the current character began by backing up, at most three bytes (Wikipedia). Counting the non-continuation bytes counts the code points without decoding anything:
Starting in the middle of "รฉ" damages only that one character (it becomes the replacement character U+FFFD); the decoder resynchronises at the very next lead byte and the rest is intact. Older multi-byte encodings were not so forgiving
(check: this comparison is from general knowledge). The same property is why a practical text editor can jump to an arbitrary byte offset in a big file.
What a Decoder Must Refuse
The table has a subtle gap: the character U+003F could in principle be written in two bytes (C0 BF) by filling the two-byte pattern with leading zeros. That is an overlong encoding. The standard allows exactly one
way to write each character, and Wikipedia explains the risk: overlong forms can "bypass other security validations", for example a filter that looks for the byte for "/" would miss a two-byte spelling of it. RFC 3629 requires implementations to
protect against decoding such sequences; its example is a naive decoder turning C0 80 into U+0000.
Three more things are invalid:
- Surrogates U+D800 to U+DFFF, reserved for UTF-16 (a later chapter). Their would-be encoding starts
ED A0toED BF. - Values above U+10FFFF, such as the sequence
F4 90 80 80. Earlier standards allowed up to six bytes and 31 bits; RFC 3629 (2003) cut UTF-8 back to four bytes to match the range UTF-16 can reach. - Malformed structure: a continuation byte with no lead byte, or a lead byte not followed by enough continuation bytes.
That also explains the byte values that never appear in valid UTF-8: C0 and C1 could only start overlong forms, and F5 to FF could only lead to values past U+10FFFF. Python's own decoder rejects all of these:
The wording is Python's own; other decoders word these differently. Notice that the surrogate case is reported as an "invalid continuation byte" rather than "surrogate": Python rejects ED A0 because RFC 3629 limits the second byte after
ED to 80-9F. The RFC's other restricted lead bytes work the same way: E0 needs A0-BF, F0 needs 90-BF, F4 needs 80-8F.
Exercises
Encode U+20AC by hand using the table: write its 16 bits, split them 4 + 6 + 6, add the pattern bits, and give the hex bytes. Then reproduce it in Python with shifts and masks only (no .encode() until the final comparison).
Write a strict decode(data) that returns a string and raises ValueError for an invalid start byte, a truncated sequence, a bad continuation byte, an overlong form, a surrogate, and a value above U+10FFFF. Check it agrees with Python on valid text and rejects the five bad inputs from this chapter.
A database column holds at most N bytes. Write safe_truncate(data, limit) that cuts UTF-8 bytes to at most limit bytes without ever splitting a character, using the continuation-byte test. Compare it with a naive slice on a string containing a two-byte, a three-byte and a four-byte character.
Chapter 2 Quick Reference
- Patterns:
0xxxxxxx,110xxxxx 10xxxxxx,1110xxxx 10xxxxxx 10xxxxxx,11110xxx 10xxxxxx 10xxxxxx 10xxxxxx - The count of leading 1s in the first byte is the length;
10xxxxxxis always a continuation byte - Encode with shifts and masks:
0x80 | (cp >> n) & 0x3Ffor each continuation byte - Self-synchronising: skip or back up over bytes where
b & 0xC0 == 0x80to find a boundary (at most three bytes back) - Invalid: overlong forms, surrogates U+D800-DFFF, values above U+10FFFF, stray or missing continuation bytes
- Never valid as bytes:
C0,C1,F5toFF - RFC 3629 limits to four bytes; second-byte limits after
E0,ED,F0,F4enforce the rules above