Accent-insensitive and fuzzy matching: unaccent and fuzzystrmatch
Users type "Sao Paulo" for São Paulo, "Patell" for Patel and "Smyth" for Smith. unaccent strips diacritics so searches match regardless of accents, and fuzzystrmatch adds edit distance and phonetic codes. Combined with pg_trgm they cover most "find what I meant" searches.
- Make LIKE and full-text search accent-insensitive with unaccent, including an indexable wrapper
- Use levenshtein, soundex, metaphone and dmetaphone for typo and sound-alike matching
- Choose between fuzzystrmatch, pg_trgm and full-text search
conceptThe concept
unaccent is a text search dictionary plus a function: unaccent('Crème brûlée') returns Creme brulee. Use it in two places: as a filter dictionary in a text search configuration, so to_tsvector removes accents, and in expression indexes for LIKE and equality searches.
The unaccent(text) function is STABLE, not IMMUTABLE, because it looks up its dictionary through the search_path. Indexes need immutable expressions, so the standard pattern is a small IMMUTABLE wrapper that names the schema and dictionary explicitly.
fuzzystrmatch compares strings:
| Function | Measures | Good for |
|---|---|---|
levenshtein(a, b), levenshtein_less_equal(a, b, k) | Insertions, deletions and substitutions needed | Typos in short strings: names, codes |
soundex, difference | Classic 4-character sound code | Rough English surname matching |
metaphone, dmetaphone, dmetaphone_alt | Better phonetic codes, with alternatives | Names in mixed origins |
daitch_mokotoff (16+) | Phonetic codes for Slavic and Germanic names | Genealogy-style name search |
whyWhy it matters
Exact matching fails real users: accents, typos and spelling variants hide records that exist. Customer support search, deduplication of contacts and address matching all need tolerant comparison, and doing it in SQL keeps it close to the data and its indexes.
Use caseReal-world scenario
A support tool searches customers by name. The query unaccents and lowercases input, tries an indexed prefix match first, then falls back to dmetaphone equality on surnames and levenshtein <= 1 on a small candidate set, ranking exact matches above sound-alikes.
Try itExamples
Run and edit these in the interactive lesson.
BEGIN;
CREATE EXTENSION unaccent;
CREATE EXTENSION fuzzystrmatch;
SELECT unaccent('Crème brûlée à São Paulo') AS plain;
SELECT levenshtein('Hyderabad', 'Hyderbad') AS edits,
soundex('Robert') AS robert, soundex('Rupert') AS rupert,
metaphone('Thompson', 10) AS metaphone,
dmetaphone('Schmidt') AS dmetaphone;
ROLLBACK;BEGIN;
CREATE EXTENSION unaccent;
CREATE FUNCTION f_unaccent(text) RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
RETURN public.unaccent('public.unaccent'::regdictionary, $1);
CREATE TABLE city (name text);
INSERT INTO city VALUES ('São Paulo'), ('Zürich'), ('Kraków'), ('Montréal'), ('Sao Tome');
CREATE INDEX city_unaccent ON city (lower(f_unaccent(name)) text_pattern_ops);
SELECT name FROM city WHERE lower(f_unaccent(name)) LIKE 'sao%' ORDER BY name;
ROLLBACK;BEGIN;
CREATE EXTENSION unaccent;
CREATE TEXT SEARCH CONFIGURATION simple_unaccent (COPY = simple);
ALTER TEXT SEARCH CONFIGURATION simple_unaccent
ALTER MAPPING FOR hword, hword_part, word WITH unaccent, simple;
SELECT to_tsvector('simple_unaccent', 'Crème brûlée') AS doc,
to_tsvector('simple_unaccent', 'Crème brûlée') @@ to_tsquery('simple_unaccent', 'creme') AS matches;
ROLLBACK;BEGIN;
CREATE EXTENSION fuzzystrmatch;
SELECT DISTINCT split_part(name, ' ', 2) AS surname,
levenshtein(lower(split_part(name, ' ', 2)), 'patell') AS edits
FROM customers
WHERE levenshtein_less_equal(lower(split_part(name, ' ', 2)), 'patell', 1) <= 1
ORDER BY edits, surname;
SELECT DISTINCT split_part(name, ' ', 2) AS sounds_like_smyth
FROM customers
WHERE dmetaphone(split_part(name, ' ', 2)) = dmetaphone('Smyth');
ROLLBACK;SELECT name, round(similarity(name, 'Ananya Patell')::numeric, 2) AS sim
FROM customers
WHERE name % 'Ananya Patell'
ORDER BY sim DESC, name
LIMIT 3;internalsUnder the hood
levenshtein is O(n times m) per comparison and cannot use an index, so always narrow candidates first (an indexed phonetic code, a trigram index or a prefix). Phonetic functions are deterministic and immutable, so you can store their output in a generated column or index the expression dmetaphone(surname). soundex, metaphone and dmetaphone are designed for English and ignore or mishandle multibyte characters: unaccent before encoding. unaccent's rules live in unaccent.rules in the server's tsearch_data directory and can be extended with your own file.
Avoid / preferDo this, not that
CREATE INDEX ON customers (unaccent(name)); -- fails: not immutable -- or: ALTER FUNCTION unaccent(text) IMMUTABLE;
CREATE FUNCTION f_unaccent(text) RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
RETURN public.unaccent('public.unaccent'::regdictionary, $1);
CREATE INDEX ON customers (lower(f_unaccent(name)));Marking the extension function IMMUTABLE lies to the planner: a different search_path can change its output and corrupt the index. The schema-qualified wrapper is stable by construction.
SELECT * FROM customers WHERE levenshtein(name, $1) <= 2; -- runs on every row
SELECT * FROM customers WHERE name % $1 -- trigram index narrows candidates AND levenshtein_less_equal(name, $1, 2) <= 2;
levenshtein cannot use an index. Narrow with an indexed predicate first, then apply the exact distance.
PitfallsCommon mistakes
- Applying soundex or metaphone to non-English or accented names without unaccent, getting empty or misleading codes.
- Searching with unaccent on the column but not on the user input, or the reverse.
- Using a high levenshtein threshold on short strings: every 3-letter name is within 2 edits of many others.
- Forgetting to rebuild the text search configuration on every database that needs it; configurations are per database.
LimitsWhen not to use it
For long free text, use full-text search or trigrams rather than edit distance. For multilingual name matching at scale or entity resolution with many signals, a dedicated search engine or matching service may be a better fit than SQL functions.
SecuritySecurity notes
Fuzzy search over people's names makes it easier to find records with partial information, which is useful for support staff and risky in public endpoints. Require authentication, rate-limit, cap result counts, and never expose fuzzy lookups over customer data to anonymous users.
ChallengeTry it yourself
A user typed Bëngalooru into the city filter. List the distinct customer cities within 2 edits of that input once accents are removed and both sides are lowercased.
Show one solution
BEGIN;
CREATE EXTENSION unaccent;
CREATE EXTENSION fuzzystrmatch;
SELECT DISTINCT city
FROM customers
WHERE levenshtein_less_equal(lower(unaccent(city)), lower(unaccent('Bëngalooru')), 2) <= 2
ORDER BY city;
ROLLBACK;InterviewQuestions you should be able to answer
How do you make a name search accent-insensitive and still use an index?
levenshtein, soundex or pg_trgm for fuzzy search?
QuizCheck your understanding
4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.