Normalization
Text Encoding and Unicode
Course 1 · Chapter 5 · Normalization
In Text Encoding and Unicode 1 we met two strings that look identical but compare unequal: "é" as one code point, and "e" followed by a combining accent. That was a preview of a real problem: Unicode often gives several ways to write the same text. A login that accepts "Zoë" on one device and rejects it on another, a search that misses a word, a duplicate row in a database: these usually come from this. The cure is normalization, and this chapter explains what it does and when to use which form.
Two Kinds of "Same"
Unicode defines two levels of equivalence between sequences of code points:
| Kind | Meaning | Example |
|---|---|---|
| Canonical equivalence | Assumed to have the same appearance and meaning; may be substituted for each other | U+00E9 and e + U+0301; the angstrom sign U+212B and U+00C5 |
| Compatibility equivalence | May look different but share a meaning in some contexts; treating them alike is sometimes right and sometimes wrong | The ligature U+FB03 and "ffi"; the superscript U+2075 and "5"; fullwidth A and A |
Canonical equivalence implies compatibility equivalence, but not the other way round (Wikipedia). The first kind is about the same text spelled two ways; the second is about different characters that are close enough for some purposes, such as searching.
The Four Normalization Forms
A normalization form converts a string into one fixed representative of its equivalence class, so that equivalent strings become identical. There are four, built from two operations: decomposition (split into base letter plus marks) and composition (join back where a single code point exists):
| Form | What it does |
|---|---|
| NFD | Canonical decomposition, with combining marks in a set order |
| NFC | Canonical decomposition, then canonical composition: the shortest, "precomposed" form |
| NFKD | Compatibility decomposition, with combining marks in a set order |
| NFKC | Compatibility decomposition, then canonical composition |
The "K" stands for compatibility. Python exposes all four through unicodedata.normalize. Start with the problem from chapter 1:
Either form makes the two strings equal; NFC makes both one code point long and NFD makes both two. UAX #15 states that equivalent strings have "precisely the same normalized form", which is what allows plain binary comparison afterwards. It also states that each form is idempotent: normalizing twice changes nothing.
What Each Form Does to Awkward Characters
Run all four forms over six characters and the differences between the canonical pair (NFC, NFD) and the compatibility pair (NFKC, NFKD) become clear:
Reading the table:
- The angstrom sign and the Swedish letter Å are canonically equivalent, so all forms fold the sign into
U+00C5(NFC) or A plusU+030A, a combining ring (NFD). - The ligature, the superscript, the Roman numeral and the fullwidth letter are only compatibility equivalents: NFC and NFD leave them alone, while NFKC and NFKD replace them with plain letters and digits. A search for "f" matches the ligature under NFKC but not under NFC (Wikipedia).
Combining Marks Have an Order
When a letter carries several marks, the order they are typed in can differ without changing the look. Each mark has a combining class number, and normalization sorts adjacent marks of non-zero class by that number (UAX #15), keeping the original order for marks of equal class, since those interact visually. Python reports the class with unicodedata.combining:
The dot below has class 220 and the circumflex class 230, so both orders sort to the same sequence, and under NFC both become the single code point U+1EC7. In the second part the acute and circumflex share class 230, so their typed order is kept: "e, circumflex, acute" composes to U+1EBF, but "e, acute, circumflex" does not. The last line demonstrates idempotence.
Which Form, When?
- NFC is the usual choice for storing and exchanging text and for comparing strings. It is compact, and it keeps compatibility characters as they were. (I believe the W3C recommends NFC for web content: check, neither source I read said so.)
- NFD is useful as a working form when you want to take apart letters and marks, for example to strip accents (Exercise 3).
- NFKC / NFKD are for matching, such as search or identifier comparison, where folding ligatures and fullwidth letters is wanted. UAX #15 warns they "must not be blindly applied to arbitrary text", because they discard distinctions.
The loss is real. Here a user-name key is built with NFKC followed by casefold() (a stronger lowercase), and the same call is shown ruining a sentence:
Three lessons from the output. First, three spellings of one name collapse to two strings after NFC (the third differs only in case) and to one after casefold. Second, NFKC makes "final" findable (the ligature is gone), but it also turned "2²" into "22", a meaning change, which is why NFKC suits matching and not stored text.
Third, normalized strings are not closed under concatenation (UAX #15): "e" and a lone accent are each already in NFC, but joined they are not, because they now compose into one code point (length 1, not 2). Normalize after you join, not before.
Exercises
Write canon_eq(a, b) and compat_eq(a, b) using NFD and NFKD. Test six pairs: é and e+acute, Å and the angstrom sign, the ffi ligature and "ffi", two mark orders that share a combining class, fullwidth A and A, and a and A. Predict each answer first.
Write key(name) (NFKC then casefold()) and use it to group six user names that differ by composition, case, fullwidth letters, a missing accent and a plural. How many distinct names are there raw, and how many after keying?
Write strip_accents(s): decompose with NFD, drop characters with a non-zero combining class, and recompose with NFC. Try it on "Zoë", "crème brûlée", "Ångström", "øre", "straße" and "Łódź". Which letters does it not change, and why?
Chapter 5 Quick Reference
- Canonical equivalence: same text spelled differently (é vs e+accent); compatibility equivalence: similar characters (ligatures, superscripts, fullwidth)
- NFD = canonical decompose; NFC = decompose then compose; NFKD / NFKC = the same with compatibility mappings
- Equivalent strings share one normalized form; every form is idempotent
- Combining marks are sorted by combining class; marks of the same class keep their order
- Use NFC to store and compare; NFD to take letters apart; NFKC/NFKD only for matching, since they lose information ("2²" became "22")
- Normalization is not closed under concatenation: normalize after joining
- Python:
unicodedata.normalize(form, s),unicodedata.combining(c),str.casefold()