PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 14: Search

Accent-insensitive and fuzzy matching: unaccent and fuzzystrmatch

Level 3: Advanced SQL25 min

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.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • 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:

FunctionMeasuresGood for
levenshtein(a, b), levenshtein_less_equal(a, b, k)Insertions, deletions and substitutions neededTypos in short strings: names, codes
soundex, differenceClassic 4-character sound codeRough English surname matching
metaphone, dmetaphone, dmetaphone_altBetter phonetic codes, with alternativesNames in mixed origins
daitch_mokotoff (16+)Phonetic codes for Slavic and Germanic namesGenealogy-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.

Strip accents and compare sounds
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;
Robert and Rupert share a soundex code, which shows how coarse soundex is.
An indexable accent-insensitive search
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;
The wrapper names the schema and the dictionary, so its result never depends on search_path; that is what makes IMMUTABLE honest. text_pattern_ops lets the index serve LIKE prefix searches.
Full-text search that ignores accents
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;
unaccent runs as a filter dictionary first, then simple lowercases. For stemming, copy english instead of simple and map to unaccent, english_stem.
Typo-tolerant name lookup
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;
levenshtein_less_equal stops early once the distance exceeds the limit, which makes it much cheaper as a filter.
Combining with pg_trgm for ranked fuzzy search
SELECT name, round(similarity(name, 'Ananya Patell')::numeric, 2) AS sim
FROM customers
WHERE name % 'Ananya Patell'
ORDER BY sim DESC, name
LIMIT 3;
Trigram similarity with a GIN or GiST trigram index is usually the first tool for fuzzy search over larger tables; levenshtein and phonetic codes refine the candidates.

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

Avoid
CREATE INDEX ON customers (unaccent(name));  -- fails: not immutable
-- or: ALTER FUNCTION unaccent(text) IMMUTABLE;
Prefer
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.

Avoid
SELECT * FROM customers
WHERE levenshtein(name, $1) <= 2;   -- runs on every row
Prefer
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
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?
Install unaccent, create an IMMUTABLE SQL wrapper that calls unaccent with a schema-qualified dictionary, and build an expression index on lower(wrapper(name)), adding text_pattern_ops or a trigram opclass if you search by prefix or similarity. Apply the same expression to the search input. For full-text search, create a text search configuration that maps word tokens through unaccent before the stemmer.
levenshtein, soundex or pg_trgm for fuzzy search?
pg_trgm first for most cases: it is indexable with GIN or GiST and handles typos in longer strings. levenshtein gives an exact edit distance but cannot use an index, so apply it to a narrowed candidate set. Phonetic codes such as dmetaphone match names that sound alike but are spelled very differently, and can be indexed as expressions; they are English-oriented and need unaccent for accented input.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation