← Back to all articles

Why Two Identical-Looking Strings Differ: Unicode Normalization

UnicodePitfalls

Where the problem comes from

An accented character has two encodings: a precomposed code point (a single code point for é), or a base letter plus a combining mark (e followed by a combining accent). They render identically but have different bytes and length, so a naive equality check returns false. That is what Unicode normalization fixes.

Four normalization forms

FormBehaviourUse for
NFCCompose to a single code pointDefault for storage and comparison
NFDDecompose to base + marksWhen you need the marks; common in macOS filenames
NFKCCompose + compatibility mappingSearch, dedupe (changes meaning)
NFKDDecompose + compatibility mappingSame as NFKC, decomposed

Use compatibility forms with care: NFKC replaces full-width characters, ligatures (fi) and circled numbers (①) with compatible forms. Great for search, but storing it loses the original text.

Code samples

// JavaScript: normalize before comparing
const equal = 'a'.normalize('NFC') + 'é'.normalize('NFC') === 'é'.normalize('NFC')

// Length differs (emoji and combining marks span multiple code points)
[...'é'].length   // 1 under NFC, 2 under NFD

// Python
import unicodedata
unicodedata.normalize('NFC', s)

Four practical tips

  1. Normalize to NFC before storing so one text has one shape in the database;
  2. Normalize before comparing, especially usernames, emails and tags;
  3. Fix the form before hashing, or the same text yields different digests and dedup breaks;
  4. Don't assume case-folding rules: Turkish casing differs from English, so pass a locale when sorting.

Two more traps

  • Truncation and length limits: emoji, combining marks and flags span several code points; slicing by code point can cut a glyph in half, so slice by grapheme clusters;
  • Order of sanitization: normalize before a blocklist check, or an attacker can bypass keyword filters with an equivalent spelling.

Try it

Hash after normalization: Hashing, JSON formatter.

What NFC and NFD actually break

A character can have several equivalent representations, and different normalisation makes "looks identical" compare unequal:

  1. Duplicate usernames: the same name typed through different input methods may arrive as NFC or NFD, so without normalising on write a "unique" username registers twice;
  2. Filenames and paths: macOS historically normalises filenames to NFD, so syncing across platforms can yield two copies of one file;
  3. Search and sort: skipping normalisation misses visually identical characters and produces ordering that ignores language intuition;
  4. Length validation: strings with combining marks have fewer characters than code points, so code-point limits reject valid input.

Three rules to apply

  • Normalise at the boundary: convert all external input to NFC as it enters the system and keep it consistent in the database;
  • Normalise before comparing: any field used for comparison, lookup or deduplication should be normalised first, with case folding where relevant;
  • Store both: keep the user's original for display and a normalised copy for indexes and unique constraints.

Applying it in databases and search

  1. Put the unique constraint on the normalised column: for usernames and emails, add a column holding the normalised value with the unique constraint, or correct normalisation still cannot stop duplicates.
  2. Normalise both sides before matching: normalise the query string and the stored value, or one-sided handling misses matches.
  3. Pair with a collation: collations differ on punctuation and case, so pick one deliberately for international business and fix it in the database. Normalisation handles equivalence; collation handles order, neither replaces the other.
  4. Watch index size: normalised strings can change length, so size indexes on the maximum normalised length to avoid truncation and false matches.
  5. Migrate existing data: introducing normalisation needs a one-off backfill, and duplicates must be resolved during it or the unique constraint cannot be created.

Normalisation is cheap early and expensive late, setting the rules while the system is small costs far less than migrating and de-duplicating later.