Text Normalization › Unicode Normalization Forms

Michael BrenndoerferUpdated March 19, 202511 min read
Language AI HandbookAll chapters
Part 1 of 67Text as Data
Chapter 2Text Normalization › Unicode Normalization Forms
Reading section 3 of 15 · Unicode Normalization Forms · 11 minSection 3 of 15 · 11 min

Book contents

Section 3 of 15 · 11 min read

Text Normalization › Unicode Normalization FormsOne section · Sections in this chapter3 / 15 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

The Unicode standard defines four normalization forms to address representation ambiguity. Each form serves different purposes, and choosing the right one requires understanding what each form does and what it preserves.

Think of the four normalization forms as two orthogonal dimensions: compose versus decompose, and canonical versus compatibility. The compose/decompose dimension controls whether the form builds combined characters (precomposed) or breaks them apart (decomposed). The canonical/compatibility dimension controls whether the form respects all semantic distinctions (canonical) or aggressively maps visually similar but semantically distinct characters onto a common form (compatibility). Crossing these two dimensions gives you NFC (canonical compose), NFD (canonical decompose), NFKC (compatibility compose), and NFKD (compatibility decompose).

The canonical forms (NFC and NFD) make only those transformations that the Unicode standard considers representations of the same abstract character. They do not change meaning or lose semantic information. If you normalize "café" to NFC or NFD and then display the result, it looks exactly the same and means exactly the same thing. You have just changed the internal byte representation, not the character's identity.

The compatibility forms (NFKC and NFKD) go further. They apply compatibility mappings, which group characters that the Unicode standard considers semantically equivalent but visually distinct. The Latin ligature "fi" and the two-character sequence "fi" are compatibility equivalents: they look nearly the same in most fonts and mean the same thing, but they have different code points. NFKC maps the ligature to the two-character form. This is useful for search and comparison, but it destroys information about the original text's typography.

Unicode Normalization

Unicode normalization converts text into a canonical form where equivalent strings have identical code point sequences. The four forms (NFC, NFD, NFKC, NFKD) differ in whether they compose or decompose characters and whether they apply compatibility mappings. Canonical forms (NFC, NFD) preserve all semantic distinctions. Compatibility forms (NFKC, NFKD) apply additional mappings that may change character identity in exchange for broader equivalence matching.

NFC: Canonical Composition

NFC (Normalization Form Canonical Composition) converts text to its shortest representation by combining base characters with their accents into single precomposed characters where possible. Starting from any valid Unicode string, NFC first applies canonical decomposition (separating all characters into their base plus combining marks), then applies canonical composition (re-merging sequences that have precomposed equivalents).

NFC is the most widely used normalization form in practice because it produces compact representations that match what users expect when they type accented characters on a keyboard. When you press a single key for "é" on a French keyboard, you produce the precomposed form. When a macOS application pastes text, it typically uses NFC. When web browsers submit form data, they use NFC. Normalizing your entire corpus to NFC eliminates the most common source of representation mismatch without any loss of information.

In[5]:
Code
import unicodedata

# Start with decomposed form
decomposed = "cafe\u0301"  # e + combining acute

# Normalize to NFC (composed)
nfc = unicodedata.normalize("NFC", decomposed)

# Compare with precomposed original
precomposed = "café"
Out[6]:
Console
NFC Normalization (Composition):
  Original (decomposed): 'café'
  Code points: ['U+0063', 'U+0061', 'U+0066', 'U+0065', 'U+0301']
  Length: 5

  After NFC: 'café'
  Code points: ['U+0063', 'U+0061', 'U+0066', 'U+00E9']
  Length: 4

  Matches precomposed 'café'? True

The key insight here is what NFC does under the hood. It does not simply look for decomposed sequences and merge them; it applies the full Unicode canonical composition algorithm, which handles edge cases like sequences with multiple combining marks in specific orders. After NFC, the five-code-point sequence "c", "a", "f", "e", U+0301 becomes the four-code-point sequence "c", "a", "f", U+00E9. The combining acute accent has been absorbed into the precomposed "é". The string now equals the original precomposed form.

NFC is the most commonly used normalization form. It produces the most compact representation and matches what most users expect when they type accented characters.

NFD: Canonical Decomposition

NFD (Normalization Form Canonical Decomposition) does the opposite of NFC: it breaks precomposed characters into their base character plus combining marks. Given "é" (U+00E9), NFD produces the sequence "e" (U+0065) followed by combining acute accent (U+0301).

Why would you want the decomposed form? NFD is particularly useful when you need to inspect or manipulate the diacritical components of characters independently. The most common use case is accent stripping: by decomposing to NFD, you can easily identify and remove combining marks (Unicode category "Mn" for Mark, Nonspacing) without any complex character-by-character lookup table. The combining marks are literally separated from their base characters, so filtering them out is a one-line operation.

In[7]:
Code
# Start with precomposed form
composed = "café"

# Normalize to NFD (decomposed)
nfd = unicodedata.normalize("NFD", composed)
Out[8]:
Console
NFD Normalization (Decomposition):
  Original (composed): 'café'
  Code points: ['U+0063', 'U+0061', 'U+0066', 'U+00E9']
  Length: 4

  After NFD: 'café'
  Code points: ['U+0063', 'U+0061', 'U+0066', 'U+0065', 'U+0301']
  Length: 5

Notice that NFD increases the string length from 4 to 5 code points. The single U+00E9 code point has been split into U+0065 (base letter "e") and U+0301 (combining acute accent). The visual appearance is unchanged, but the internal structure has been made explicit. NFD is useful when you need to manipulate accents separately from base characters, such as removing diacritics or analyzing character components.

NFKC and NFKD: Compatibility Normalization

The "K" forms apply compatibility decomposition in addition to canonical normalization. This maps characters that are semantically equivalent but visually distinct, going beyond the conservative approach of the canonical forms.

Compatibility Equivalence

Compatibility equivalence groups characters that represent the same abstract character but differ in appearance or formatting. Examples include full-width vs. half-width characters, ligatures vs. separate letters, and superscripts vs. regular digits. Unlike canonical equivalence, which is always lossless, compatibility equivalence may change the visual appearance of text. A ligature like "fi" looks slightly different from "fi" in many fonts, even though NFKC treats them as equivalent.

Think of NFKC as a "normalize for search" operation. When a user types "financial" with the ligature character (perhaps copied from a PDF), they probably want to find documents containing "financial" with separate letters. NFKC makes this work automatically. Similarly, a user searching for "km" should find "㎞" (the combined kilometer symbol), and a search for "IV" should find "Ⅳ" (the Roman numeral four). Compatibility normalization handles all these cases.

In[9]:
Code
# Characters with compatibility equivalents
test_cases = [
    ("fi", "fi ligature"),
    ("①", "circled digit one"),
    ("Ⅳ", "roman numeral four"),
    ("hello", "full-width hello"),
    ("²", "superscript two"),
    ("㎞", "km symbol"),
]

# Apply NFKC normalization
nfkc_results = [
    (char, desc, unicodedata.normalize("NFKC", char))
    for char, desc in test_cases
]
Out[10]:
Console
NFKC Compatibility Normalization:
-------------------------------------------------------
Original     Description               NFKC Result 
-------------------------------------------------------
'fi'        fi ligature               'fi'
'①'        circled digit one         '1'
'Ⅳ'        roman numeral four        'IV'
'hello'        full-width hello          'hello'
'²'        superscript two           '2'
'㎞'        km symbol                 'km'

NFKC is aggressive. It converts the "fi" ligature to separate "f" and "i" characters, expands the circled digit to just "1", converts the Roman numeral to the plain letter sequence "IV", and transforms full-width characters to their ASCII equivalents. The "㎞" symbol becomes the two-character sequence "km". This is useful for search and comparison but destroys formatting and typographic information. If you are storing text for display, NFKC is too aggressive; use NFC instead. If you are building a search index or comparing strings for equality, NFKC is usually the right choice.

The table below shows how each normalization form transforms different input characters. NFC and NFD are canonical forms that preserve character identity, while NFKC and NFKD apply compatibility mappings that may change the character representation.

Unicode normalization forms compared. Canonical forms (NFC, NFD) preserve character identity; compatibility forms (NFKC, NFKD) apply aggressive mappings.
InputNFCNFDNFKCNFKD
café (decomposed)cafécafécafécafé
e + ́ (combining)ée + ́ée + ́
fi (ligature)fifififi
① (circled)①①11
hi (full-width)hihihihi

The canonical forms (NFC, NFD) preserve ligatures and special characters, changing only the internal representation. The compatibility forms (NFKC, NFKD) aggressively normalize to base characters, expanding ligatures and converting full-width to half-width. For rows where the input already has a precomposed canonical form (like café before the second row), all four forms produce identical output.

Choosing a Normalization Form

The right form depends on your use case, and using the wrong one can cause subtle problems. Applying NFKC to text you intend to display will change ligatures in ways the original author may not have intended. Applying only NFC to text you intend to search may miss legitimate matches between ligature and expanded forms.

The following table provides a quick reference for common use cases:

Normalization form recommendations by use case.
Use CaseRecommended FormReason
General text storageNFCCompact, preserves visual appearance
Accent-insensitive searchNFD then strip marksEasy to remove combining characters
Full-text searchNFKCMatches variant representations
Security (username comparison)NFKCPrevents homograph attacks
Preserving formattingNFCKeeps ligatures and special forms
In[11]:
Code
def compare_normalization_forms(text):
    """Compare all four normalization forms for a given text."""
    forms = ["NFC", "NFD", "NFKC", "NFKD"]
    results = {}
    for form in forms:
        normalized = unicodedata.normalize(form, text)
        results[form] = {
            "text": normalized,
            "length": len(normalized),
            "codepoints": [f"U+{ord(c):04X}" for c in normalized],
        }
    return results


# Test with a complex example
test_text = "financial résumé ①"
comparison = compare_normalization_forms(test_text)
Out[12]:
Console
Normalizing: 'financial résumé ①'
======================================================================

NFC:
  Result: 'financial résumé ①'
  Length: 17

NFD:
  Result: 'financial résumé ①'
  Length: 19

NFKC:
  Result: 'financial résumé 1'
  Length: 18

NFKD:
  Result: 'financial résumé 1'
  Length: 20

The length differences reveal how each form handles the input. NFD produces the longest output because it decomposes characters into base letters plus combining marks. NFC and NFKC produce shorter outputs by composing characters, with NFKC additionally expanding the ligature "fi" into two separate characters. NFKD combines both effects: it applies compatibility decomposition and canonical decomposition, typically producing the longest result for text with both ligatures and accented characters.

Out[13]:
Visualization
Grouped bar chart comparing string lengths across NFC, NFD, NFKC, and NFKD normalization forms for various text samples.
String length comparison across Unicode normalization forms for different text samples. NFD consistently produces longer strings by decomposing characters, while NFC produces the most compact representation. NFKC and NFKD may increase or decrease length depending on whether compatibility mappings expand or simplify characters.

The chart reveals important patterns. Text with combining diacritics (café, naïve, Ångström) shows significant length increase under NFD decomposition. The metric unit symbols (㎝㎜㎏) and Roman numerals (ⅢⅧ) shrink dramatically under NFKC/NFKD as they are mapped to their multi-character ASCII equivalents, increasing length in code points. The ligature "fi" in "finance" expands from one character to two under compatibility normalization. These length changes are not just numerical curiosities: they affect tokenization boundaries, character-level models, and any processing that iterates over code points.