AT A GLANCE
| Form | What it does | Typical use |
|---|---|---|
| NFC (Normalization Form C) | Canonical decomposition, then canonical composition — combines base letters with marks into single codepoints where possible | Default for storage, URLs, and most text on the web |
| NFD (Normalization Form D) | Canonical decomposition only — splits precomposed characters into base + combining marks | Text analysis, diacritic-insensitive search, some macOS filesystem APIs |
| NFKC (Normalization Form KC) | Compatibility decomposition, then canonical composition — also folds stylistic/compatibility variants (e.g. ligatures, roman numerals) into plain equivalents | Search, security checks on identifiers, loose matching |
| NFKD (Normalization Form KD) | Compatibility decomposition only, no recomposition | Aggressive text normalization before further processing |
Short Verdict
Unicode allows more than one sequence of codepoints to render as the same visible string. “café” typed on one system can be a single precomposed character (U+00E9, LATIN SMALL LETTER E WITH ACUTE) and on another a base letter plus a separate combining accent (U+0065 U+0301). Both look identical. Neither is “more correct.” But a naive == comparison, a database unique index, or a cache key built from the raw string will treat them as different values. The fix, per the Unicode Standard itself, is to normalize both sides to the same form — usually NFC — before comparing, storing, hashing, or indexing. This is not an edge case for CJK or emoji only; it hits any text with accents, umlauts, or combining marks, which means most non-English (and plenty of English) text that passes through more than one system.
What Unicode Normalization Actually Is
Unicode Standard Annex #15 (UAX #15), the Unicode Normalization Forms specification, defines the problem as canonical equivalence: “a fundamental equivalency between characters or sequences of characters” that represent the same abstract character and are supposed to look and behave identically when rendered correctly. A weaker relationship, compatibility equivalence, covers characters that are related in meaning but may look or behave differently — the roman numeral “Ⅰ” (U+2160) and the letter “I” (U+0049), for instance, which share meaning but not necessarily rendering.
Normalization resolves this by giving every equivalent sequence a single, well-defined representative form. UAX #15 defines four:
- NFD — Canonical Decomposition: break every precomposed character into its base character plus combining marks.
- NFC — Canonical Decomposition followed by Canonical Composition: decompose, then recombine every base-plus-mark sequence back into a single precomposed character wherever one exists.
- NFKD — Compatibility Decomposition: like NFD, but also expands compatibility characters (ligatures, fullwidth forms, superscripts, roman numerals) into their plain equivalents.
- NFKC — Compatibility Decomposition followed by Canonical Composition: NFKC and NFC differ only in that decomposition step.
The mechanics behind composition and decomposition matter for correctness, not just trivia. Every character carries a Canonical_Combining_Class; characters with class zero (“starters”) anchor a run, and non-zero-class combining marks following a starter get sorted by that class under the Canonical Ordering Algorithm — so the same accent marks typed in a different order still normalize identically. The Canonical Composition Algorithm then scans left to right and merges any eligible starter+mark pair into its precomposed codepoint, except for a documented set of characters that UAX #15 excludes from composition entirely (so they never get recombined, by design).
Two properties from the spec matter in practice. First, normalization is stable across Unicode versions: a string normalized under one version of Unicode stays normalized under every later version, which is why it’s safe to normalize once and store the result long-term. Second, and easy to miss, none of the four forms is closed under string concatenation — joining two independently-normalized strings (e.g. gluing a normalized prefix to a normalized suffix at a boundary) can produce a string that is no longer normalized, so systems that build text by concatenation need to re-normalize after assembly, not just at the edges.
Where This Breaks Real Systems
The classic failure mode: a user registers an account with a name typed on a Mac, which may produce decomposed sequences from certain input methods; a second system (a database, a search index, an API from a different vendor) receives the same visual string but precomposed, because its OS or browser normalized on input. "café" == "café" returns false even though a human reading both sees the same word. This shows up as: duplicate user accounts that look identical, failed login/username lookups, broken full-text search, HMAC or hash mismatches on supposedly-identical payloads, and inconsistent sort order. It is especially common at the boundary between macOS (whose HFS+/APFS filesystem APIs have historically normalized filenames toward a decomposed form) and systems on Linux or Windows that leave filenames exactly as given, byte for byte.
NFKC/NFKD introduce a second, security-relevant failure mode in the opposite direction: because they fold visually or semantically similar characters together, they can be used to defeat naive blocklists (a filter that blocks the ASCII word “admin” might not catch a fullwidth or ligature variant until it’s been NFKC-normalized) — but the same folding is exactly why UAX #15 warns compatibility forms “must not be blindly applied to arbitrary text”: they are lossy with respect to formatting and can change meaning (e.g. collapsing a trademark symbol into plain letters) in contexts where that distinction matters, such as user-facing display or legal text.
A Minimal, Correct Example
Python’s standard library implements all four forms via unicodedata.normalize(form, text), with form one of "NFC", "NFD", "NFKC", "NFKD":
import unicodedata
composed = "caf\u00e9" # NFC: single codepoint U+00E9 (é)
decomposed = "cafe\u0301" # NFD: "e" + combining acute U+0301
print(composed == decomposed)
# False -- same visible text, different codepoint sequence
print(unicodedata.normalize("NFC", decomposed) == composed)
# True -- normalize both sides before comparing
print(unicodedata.is_normalized("NFC", composed))
# True -- cheap check before doing the full normalization pass
The Python docs flag the same root issue directly: “Even if two unicode strings are normalized and look the same to a human reader, if one has combining characters and the other doesn’t, they may not compare equal.” The fix is always the same shape — normalize to one form at the boundary (on input, before storage, before comparison) rather than trying to special-case every possible equivalent sequence.
Trade-offs: NFC vs. NFD vs. NFKC/NFKD
- NFC pros: most compact representation; what most web content, URLs, and the W3C’s own guidance for HTML/XML use by default; round-trips cleanly through most legacy systems that expect precomposed accented letters.
- NFC cons: not every combining sequence has a precomposed codepoint, so some marks never recompose — NFC does not guarantee a mark-free string.
- NFD pros: useful when you need to isolate or strip diacritics (e.g. building an accent-insensitive search index by decomposing, then dropping combining-mark codepoints).
- NFD cons: larger byte size; many older libraries and protocols assume precomposed input and mishandle bare combining marks.
- NFKC/NFKD pros: good for loose matching, search, and some identifier-spoofing defenses, since it collapses compatibility variants that would otherwise bypass exact-match filters.
- NFKC/NFKD cons: lossy and one-directional — you cannot recover the original formatting distinctions, so it is unsafe for anything that must preserve exact user input (legal names, file content, cryptographic payloads).
Who Should Use Which Form
Use NFC as the default for anything you store, compare, hash, index, or put in a URL — it’s the form most interoperable systems already expect. Use NFD only when you specifically need to manipulate base characters and marks separately, such as building a diacritic-folding search feature, or when working directly with macOS filesystem APIs that normalize toward it. Use NFKC/NFKD only for non-reversible operations like fuzzy search, spam/abuse filtering, or identifier collision checks — never for data you need to preserve exactly, and never as a silent step before storage, since you’d be discarding information users typed on purpose.
Bottom Line
Most “duplicate account” and “search can’t find text that’s clearly there” bugs involving accented or non-Latin text trace back to one root cause: two systems chose different, equally valid, codepoint sequences for the same visible string, and nobody normalized before comparing. Pick NFC as your storage and comparison default, normalize at every system boundary (not just once at the start), and reach for NFKC/NFKD deliberately and only for lossy, non-reversible matching — never as an invisible default.
FAQ
Is NFC “more correct” than NFD? No. Both represent the exact same abstract text under Unicode’s canonical equivalence; NFC is simply the more common interoperability default because it’s more compact and matches what most legacy encodings and the web already expect.
Does normalizing fix emoji or CJK comparison bugs too? It fixes any text built from combining sequences or compatibility variants that have multiple valid representations — this includes many CJK compatibility ideographs and some emoji ZWJ sequences, though the exact equivalence rules differ by category and UAX #15 should be checked for edge cases.
Can I normalize once and never worry about it again? Normalization is stable across Unicode versions, so a string normalized today stays normalized later — but concatenating two normalized strings is not guaranteed to produce a normalized result, so re-normalize after any operation that joins or edits text, not just at ingestion.
Is NFKC safe for security filtering (e.g. blocking reserved usernames)? It helps catch some lookalike-character bypasses, but it is lossy by design and should be paired with other checks (such as Unicode confusables detection), not treated as a complete defense on its own.
Sources
- Unicode Standard Annex #15: Unicode Normalization Forms (Revision 56, Unicode 16.0.0)
- UAX #15 canonical URL (unicode.org)
- Python 3 documentation: unicodedata — Unicode Database
Related reading on this site: Parsing Explained: Recursive Descent, Parser Combinators, and PEG Grammars and Bloom Filters Explained.
