Removing Diacritics From Addresses in Python

TL;DR: Decompose with NFKD, drop the combining marks, then apply a small table of language-specific substitutions that decomposition cannot handle — ß, ø, ł, đ. Store the result as a match key beside the original, never in place of it. The surrounding rules are in Unicode and character normalization in Python.

The Function

from __future__ import annotations

import unicodedata

# Characters that NFKD cannot decompose because they are letters in their own
# right, not a base letter plus a mark. Each needs an explicit mapping.
EXPLICIT: dict[str, str] = {
    "ß": "ss", "ẞ": "SS",
    "ø": "o", "Ø": "O",
    "æ": "ae", "Æ": "AE",
    "œ": "oe", "Œ": "OE",
    "ł": "l", "Ł": "L",
    "đ": "d", "Đ": "D",
    "ð": "d", "Ð": "D",
    "þ": "th", "Þ": "TH",
    "ı": "i", "İ": "I",
}

_TRANSLATION = str.maketrans(EXPLICIT)


def fold_to_ascii(text: str) -> str:
    """Return an ASCII match key. NOT a delivery address."""
    text = text.translate(_TRANSLATION)
    decomposed = unicodedata.normalize("NFKD", text)
    stripped = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
    return stripped.encode("ascii", "ignore").decode("ascii")

Order matters: the explicit substitutions run before decomposition, because some of them produce multi-character results that themselves want normalising, and running them afterwards would leave the decomposed marks in place around the substituted letters.

Why Decomposition Alone Is Not Enough

What decomposition handles and what it silently drops Two panels. The decomposable panel shows accented Latin letters splitting into a base letter and a combining mark, which is what allows the mark to be dropped. The non-decomposable panel shows eszett, o-slash, l-stroke and the ae ligature, which have no base-plus-mark form and are simply removed by an ASCII encode unless mapped explicitly. NFKD handles these é → e + ́ French, Spanish ü → u + ̈ German, Turkish ç → c + ̧ French, Portuguese ā → a + ̄ Latvian, transliteration these need explicit rules ß → ss German ø → o Danish, Norwegian ł → l Polish æ → ae Danish, Icelandic Without the explicit table, an ASCII encode deletes the right-hand characters entirely: Straße becomes Strae, Ørsted becomes rsted, and Łódź becomes ód — none of which will ever match anything.

The failure mode in the closing note is worse than it first appears. Straße folding to Strae does not merely fail to match Strasse; it produces a key that matches nothing at all, so the record silently becomes its own singleton in every deduplication run.

The Fold Is a Key, Not an Address

Every rule here applies to the matching key. The delivery address keeps its diacritics, because they are part of the correct spelling of the street and because some postal operators — and every recipient — care about the difference.

Consumer Reads Reason
deduplication, cache keys, joins folded ASCII must be insensitive to spelling variation
provider lookups folded or original, per provider most accept either; test which matches better
printed labels, invoices original, NFC the correct spelling of the place
customer-facing UI original, NFC the customer typed it and expects it back
exports to partners per contract some legacy systems accept ASCII only

The last row is the one that generates real arguments. A partner whose system is ASCII-only forces a lossy export, and the right approach is to send the folded form for that partner while keeping the original internally — not to fold at ingestion and lose it for everyone.

German and Nordic Cases Deserve Care

ß is the most consequential single character in European address data, because German postal convention treats ß and ss as equivalent while a naive fold produces neither. Python’s str.lower() leaves ß intact and str.casefold() converts it to ss, which is why casefold rather than lower is the correct operation when building a key.

Scandinavian ø and å raise a different question: ø folds naturally to o, but å is traditionally written aa in Danish, and a fold to a will not match an older record spelled the traditional way. Where a corpus spans decades, adding å → aa as an alternate key is more effective than choosing one and hoping.

Polish ł is a stroked l rather than an accented one, which is exactly why decomposition misses it, and Turkish dotless ı interacts badly with case folding — I.lower() is i in most locales and ı in Turkish. Since address matching should never be locale-sensitive, map both to i explicitly and take the small loss.

Measuring the Fold

Duplicates found before and after folding Three markets compared. In a German corpus, folding raises the detected duplicate rate substantially because of eszett and umlaut variation. In a French corpus the gain is moderate. In an English corpus the gain is negligible, confirming that folding costs nothing where it is not needed. Duplicate pairs detected, unfolded versus folded keys German corpus +197% with folding French corpus +61% with folding English corpus +3% with folding Outline = unfolded keys · shaded = folded keys. Folding is free where it is unnecessary and decisive where it is not.

Running this comparison on your own corpus is a half-hour exercise and settles the question of whether folding is worth the extra column. On predominantly Anglophone data the answer is usually that it costs little and gains little; on European data it is frequently the single largest improvement available to a deduplication pipeline.

Testing the Fold

A folding function is unusually easy to test well, because its behaviour is fully specified by a table of inputs and outputs and because the interesting cases are all short strings.

Build the suite from four groups. The first is one row per entry in the explicit table, asserting the exact substitution — this catches the case where someone edits the dictionary and reverses a mapping. The second is a set of accented Latin letters covering the marks that appear in your markets, confirming that decomposition and mark removal are working together. The third is a set of strings that must pass through unchanged, so that a regression which starts mangling plain ASCII is caught immediately.

The fourth group is the interesting one: real address strings from each market, with their expected folded form written out. These are the tests that fail when someone reorders the substitution and decomposition steps, because the failure only shows up on a string that needs both — Straße is the canonical example, and a handful like it are worth keeping.

Add a property test if the toolchain supports one: for any input, the output must contain only ASCII characters, and folding an already-folded string must be a no-op. Both properties are cheap to check across thousands of generated inputs and both catch classes of bug that example-based tests miss, particularly around unusual scripts and combining sequences that nobody thought to enumerate.

Finally, keep a golden file of the folded forms for a sample of production addresses and diff it on every change. That file is the fastest way to see the blast radius of an edit to the table, and it makes a deliberate change reviewable rather than a leap of faith.

Edge Cases and Failure Modes

Folding non-Latin scripts. ASCII folding of Greek, Cyrillic or CJK text deletes it entirely. Detect the script before folding and skip it, or key those records on the NFKC form instead — losing the whole address is far worse than keeping a non-ASCII key.

Double folding. Applying the fold to an already-folded string is harmless but wasteful, and it is a sign the pipeline has lost track of which column holds which form. Name the columns distinctly — street and street_key — so the mistake is visible in code review.

Folding before parsing. Diacritics occasionally carry parsing signal, particularly in Spanish ordinals (2ºD) and Portuguese. Parse first, fold second, so the parser sees the original.

Assuming the fold is reversible. It is not: ss could have been ß or ss. Never reconstruct a display value from the key, which is the practical reason the original must be stored rather than derived.

Keep the golden file small enough that a human will actually read the diff — a few hundred representative rows beats a few thousand exhaustive ones, because a diff nobody reads provides exactly as much protection as no diff at all.

One further check worth automating: assert that the folded key for every row in a production sample is non-empty. A key that folds to the empty string means the input was entirely non-Latin and the fold destroyed it, which is a routing bug rather than a folding bug — and it is far easier to catch with a one-line assertion than by noticing that a set of records never deduplicates against anything.

Two Columns, Two Contracts

The rule that the fold is a key and not a value is easiest to hold if the schema states it, so the two columns are named and typed for their purposes rather than left to convention.

Display column and key column, side by side One source value produces two stored columns. The display column keeps the original spelling in NFC and is read by label printing, invoices and the customer interface. The key column holds the folded ASCII form and is read by deduplication, cache lookups and joins. A note warns that the key can never be converted back. street_display Bahnhofstraße 4 NFC, original casing, diacritics intact read by: labels, invoices, the customer UI this is the address street_key bahnhofstrasse 4 folded ASCII, casefolded, squeezed read by: dedup, cache keys, joins this is not an address The arrow only goes one way: ss could have been ß or ss, so a display value can never be rebuilt from a key.

Integration Note

The folded key is what canonical address keys are built from, and it must therefore be covered by the same version discipline: a change to the EXPLICIT table changes every key derived from it. Bumping the normalisation version alongside any edit to that dictionary is what keeps the cache and the deduplication tables consistent, as described in versioning cache keys across normalisation changes.