Unicode in depth: planes, properties and normalisation

BMP and supplementary planes, surrogate ranges, combining marks, and the four normalisation forms that decide whether two visually identical strings are equal.

Code points, planes and surrogates

RangeNameSizeNotes
U+0000-U+FFFFBasic Multilingual Plane65,536One UTF-16 code unit each
U+10000-U+1FFFFSupplementary Multilingual Plane65,536Historic scripts, emoji
U+20000-U+2FFFFSupplementary Ideographic Plane65,536Rare CJK ideographs
U+E0000-U+EFFFFSpecial-purpose plane65,536Tags, variation selectors
U+D800-U+DFFFSurrogates2,048Not characters; only valid in UTF-16 pairs
s = "A\U0001F600"          # A plus a smiley
len(s)                     # 2 code points
[len(ch) for ch in s]      # [1, 1] — Python indexes by code point

import unicodedata
hex(ord(s[1]))             # '0x1f600'
unicodedata.name(s[1])     # 'GRINNING FACE'

A lone surrogate in a UTF-8 document is invalid data. Encoders reject it; some languages silently substitute U+FFFD, which is why broken emoji often appear as a replacement character.

Combining marks and the same string twice

import unicodedata

a = "caf\u00e9"            # e with acute, one code point  (NFC-like)
b = "cafe\u0301"           # e followed by combining acute  (NFD-like)

a == b                      # False — different code points
len(a), len(b)              # (4, 5)

unicodedata.normalize("NFC", a) == unicodedata.normalize("NFC", b)   # True
unicodedata.normalize("NFD", a) == unicodedata.normalize("NFD", b)   # True
FormMeaningTypical use
NFCCanonical compositionStorage and comparison; the web default
NFDCanonical decompositionText processing and sorting by base letter
NFKCCompatibility compositionIdentifiers and search; folds ligatures and full-width forms
NFKDCompatibility decompositionFuzzy matching, indexing
None (raw)As typedDisplay of user content you must not alter

Compatibility normalisation loses information: (full-width one) becomes 1, and fi as a ligature becomes two letters. That is desirable for search and dangerous for passwords or display.

Comparing, hashing and storing

  • Normalise to NFC before storing, unless you have a reason not to.
  • Normalise to NFC (or NFKC for identifiers) before comparing or hashing.
  • Normalise and case-fold before building a username uniqueness index.
  • Never normalise a password except for the documented form; changing it later locks users out.
  • Unicode data changes between releases, so normalisation is not perfectly stable across versions — pin the runtime for reproducibility.
import unicodedata

def canonical_key(s: str) -> str:
    # casefold handles more than lower(), including the German sharp s
    return unicodedata.normalize("NFC", s).casefold()

canonical_key("STRASSE") == canonical_key("strasse")   # True
canonical_key("Stra\u00dfe")                            # 'strasse'
⚠️
Do not normalise user-generated content that you must reproduce exactly — original spelling, poetic text and code samples. Normalise a derived key for search, and keep the original bytes for display.

FAQ

Should I store NFC or NFD?
NFC, because it is what the web platform and most databases assume. NFD is useful as an intermediate step for sorting or stripping diacritics.
Why do two identical-looking usernames collide?
They almost certainly differ in normalisation. Normalise and case-fold the value before the uniqueness check, and store the normalised form as the key.

UTF-16, UTF-32, BOM and endianness Mojibake: diagnosing and fixing broken text

Last refreshed 2026-09-18.