A system can be internally inconsistent without containing invalid UTF
Suppose a browser submits a username containing U+00E9 while a backend stores the canonically equivalent two-scalar sequence U+0065 U+0301. Both represent well-formed Unicode, but binary comparisons, hash digests, length calculations and unique constraints may disagree. Such a mismatch is a normalization policy problem rather than malformed UTF-8 or mojibake. It becomes difficult to diagnose when some services normalize on write, others normalize only on query, and another silently applies NFKC.
A robust architecture specifies which representations are authoritative. Unicode normalization is part of the data contract: where it is applied, which form is used, whether source scalars must be preserved, which UCD version backs it, and what happens to ill-formed input. Stating simply “we use Unicode” does not answer these questions.
Four normalization forms have different preservation properties
| Form | Algorithm | Likely use | Risk |
|---|---|---|---|
| NFC | Canonical decomposition plus composition | Stored normalized text or exact canonical comparison | Original scalar spelling is lost |
| NFD | Canonical decomposition plus mark ordering | Text analysis and canonical keys | More scalars than source for some characters |
| NFKC | Compatibility decomposition plus composition | Explicitly specified identifier/search profiles | Mathematical style, width, enclosure can disappear |
| NFKD | Compatibility decomposition, no recomposition | Some specialized analysis pipelines | Lossy identity and expansion |
No form guarantees that a string can be restored to the exact original scalar sequence. In particular, NFKC changes the identity of many styled mathematical or circled characters. If a product promises faithful copy and paste, it should not overwrite the sole stored copy with compatibility-folded output.
Store raw display text and a derived comparison key separately
One practical schema uses display_text for the user's original well-formed string, a derived canonical_key for exact canonical equivalence (for example NFC), and optional purpose-specific search_key fields for case folding, accent-insensitive search or collation. These keys are deliberately not interchangeable: a binary equality key, a fuzzy search key and a natural-language sort key need different guarantees.
For identifiers, define a profile with allowed scripts and character sets rather than assuming NFKC makes names secure. Case folding and confusables detection are additional operations, each with its own Unicode data dependencies. Keep these transformations out of rich-text editing and mathematical notation data unless there is explicit product justification.
Normalize at agreed boundaries, not opportunistically
A frontend may normalize text before sending JSON, while an API gateway validates only byte well-formedness and a database connector normalizes again. Reapplying the same Unicode normal form is idempotent, but introducing a different form, case mapping or accent stripping at another boundary changes the contract. Designate an authoritative normalization boundary, document client expectations and validate incoming data against the same rule.
Browser strings are UTF-16 code-unit sequences and can contain unpaired surrogates in some circumstances; this is not well-formed Unicode scalar data for interchange. Explicitly reject or replace ill-formed input according to protocol policy before computing identifiers. Silent replacement can make two previously different invalid inputs collide.
JavaScript: derive a canonical key without losing source
function recordText(original) {
if (!original.isWellFormed()) {
throw new TypeError("Ill-formed UTF-16 input");
}
return {
displayText: original,
canonicalKey: original.normalize("NFC"),
normalizationForm: "NFC"
};
}
const a = recordText("e\u0301");
const b = recordText("\u00E9");
console.log(a.displayText === b.displayText); // false
console.log(a.canonicalKey === b.canonicalKey); // true
console.log(recordText("𝐀").displayText.normalize("NFKC")); // A
The example is a data-model illustration rather than a complete security or database schema. It preserves the user's input for display and derives a comparison value. If an application needs to sign the request, specify whether it signs transmitted UTF-8 bytes, source text or the canonical key; each can produce a different digest.
Normalization is not safely chunk-local
Even if two separate chunks are each normalized to NFC, their concatenation may not be NFC. The first chunk could end in e and the second begin with U+0301 COMBINING ACUTE ACCENT; together they compose to U+00E9. Canonical ordering can also require reordering marks across a chunk boundary. Normalizing arbitrary file chunks independently then concatenating their outputs does not implement whole-stream normalization correctly.
const left = "e".normalize("NFC");
const right = "\u0301".normalize("NFC");
const together = left + right;
console.log(together === together.normalize("NFC")); // false
console.log(together.normalize("NFC") === "\u00E9"); // true
UAX #15 defines ways to identify safe boundaries and addresses stream-safe text processing. A streaming normalizer should use a tested implementation with appropriate boundary state; it should not just store the last UTF-16 code unit and assume that is enough context for every combining sequence.
Migrating existing unnormalized records
Before changing the normalization policy on a live dataset, run a read-only audit to identify records whose derived keys collide. For instance, a user table may contain both e + acute and é as separate byte strings. After NFC normalization they become the same key; blindly creating a unique constraint can fail or overwrite data. Design collision-resolution rules that respect ownership, product behavior and auditability.
A safe migration generally computes derived candidate keys, reports collision groups, resolves them under explicit business rules, adds or rebuilds indexes, switches readers/writers to the same policy and preserves enough information for rollback. Cryptographic hashes and signatures based on raw bytes must be recalculated only when the protocol authorizes canonicalization; never silently rewrite signed payloads.
Unicode versions, collation versions and consistency windows
Unicode has strong normalization stability policies for established characters, but implementations may bundle different UCD versions. ICU and CLDR collation data have their own update cadence and can change sorting or equivalence decisions. Record the algorithm name, locale where applicable, and data/library version for derived search and sort keys, especially if serialized to persistent storage or shared across services.
If a website has a browser, Node.js runtime, Python worker and database, test a common fixture set through all four paths. Do not assume that Intl.Collator, database accent-insensitive collation, Python casefold() and NFC define the same equivalence relation. Build keys in one authoritative layer or enforce explicit cross-service conformance tests.
Python validation and non-destructive storage
import unicodedata as ud
def prepare_record(value):
if any(0xD800 <= ord(ch) <= 0xDFFF for ch in value):
raise ValueError("surrogate scalar is not valid interchange text")
return {"original": value, "nfc": ud.normalize("NFC", value)}
a, b = prepare_record("e\u0301"), prepare_record("\u00E9")
assert a["original"] != b["original"]
assert a["nfc"] == b["nfc"]
assert prepare_record("𝐀")["original"] == "𝐀"
print("UCD:", ud.unidata_version)
Comparison profiles should be consistent end-to-end
A system that normalizes a username only after checking the allowlist can introduce new characters or equivalences that the validator did not anticipate, especially with compatibility normalization. Normalize and validate in a documented order; ensure registration, authentication, lookup and uniqueness constraints use the same canonical identity key. UTS #39 supplies confusable and identifier guidance, but normalized text by itself is not proof of safe content.
Search keys can intentionally match more than authentication keys. This is appropriate if they remain separate. A user searching for “cafe” may want results containing “café,” but no one should assume those two distinct strings must be treated as the same account identifier.
A contract-based regression plan
| Fixture | Invariant to assert |
|---|---|
| é versus e + U+0301 | Same NFC key; originals remain distinct |
| U+1D400 versus U+0041 | NFC keys remain distinct; NFKC would collapse them |
| Hangul syllable vs conjoining Jamo | Canonical keys agree |
| NFC chunks joined across accent | Whole-string normalization is still required |
| Existing database colliding keys | Detect and resolve before adding uniqueness constraint |
| Invalid surrogate input | Reject or explicitly replace before UTF-8 interchange |
| ICU or Unicode version upgrade | Re-run fixtures and rebuild version-dependent indexes |
A website offering copyable text should treat preservation as a first-class invariant. The US Fancy Text tool can furnish supplementary mathematical letters, combining marks and other styled characters for round-trip tests. Its original text must remain distinct from any lossy index derived to improve matching.
Standards and implementation references
- UAX #15 — Unicode normalization and streaming
- UAX #44 — UCD versioned properties
- Unicode FAQ — normalization contracts
- UTS #39 — Identifier and security policies
- MDN — String.isWellFormed
- Python unicodedata
Independent technical reference. Preservation, comparison, segmentation and presentation use different Unicode rules.