pg_similarity is a PostgreSQL extension that adds a library of string similarity and distance functions like Levenshtein,Jaro, Jaro-Winkler, token/set-based like Jaccard, Dice,Cosine and phonetic like Soundex for fuzzy matching directly in SQL.
If you have done fuzzy matching in Postgres before, you might have probably used pg_trgm for typo-tolerant searches, and now you might be wondering why we should use pg_similarity It is a complementary extension for pg_trgm, and not a replacement. It packages several similarity metrics instead of one, which matters a lot for deduplication. That is because different kinds of duplications (typos, word reordering, phonetic near misses ) respond to different kinds of similarity.
In this blog, let’s walk through a real dedup exercise on a sanitized set of customer/vendor records: install pg_similarity test the functions before trusting them, run each comparison function, build a composite match score, and cover a migration pitfall worth knowing about.
These are a few parts covered in this blog post:
- Install
pg_similarity - Sanity-check the functions before using them
- Run the comparison functions
- Build a composite match score
- Migration pitfall: Oracle Jaro-Winkler scale mismatch
Install pg_similarity
To install the pg_similarity extension, please refer to the below mentioned steps
#Install the package (Debian/Ubuntu via PGDG, or bundled on CYBERTEC PGEE):
sudo apt install postgresql-18-similarity
#Create the extension in your target database:
CREATE EXTENSION pg_similarity;
Verify the installation
\dx+ pg_similarity
The following image shows some of the actual functions which are present in the pg_similarity extension:

Sanity-check the functions
Before using the functions, confirm identical strings return the maximum score
SELECT jarowinkler('acme corp', 'acme corp'); -- expect 1
SELECT dice('acme corp', 'acme corp'); -- expect 1
#if this returns 100 instead of 1, you're not looking at the correct function, stop and check \df before writing more SQL against it
Spot-check a known match/non-match pair against intended threshold
SELECT jarowinkler('Acme Corp', 'ACME Corporation');
SELECT jarowinkler('Acme Corp', 'Globex LLC');
Confirm regexp_replace strips ALL punctuation, not just the first hit:
SELECT regexp_replace('A&B CO., LTD.', '[^A-Z0-9]', ' ', 'g');
#the 'g' flag is required in PostgreSQL as Oracle's REGEXP_REPLACE replaces all matches but PostgreSQL's regexp_replace only replaces the first match without it
Expected output: score of 1 for identical strings, a sensible mid-to-high score for the known similar pair, and every punctuation character replaced in the regexp-replace test not just the first one
As we can see that functions are working as expected from the snapshot below

Run the comparison functions
The following dataset (which is a sanitized slice of a customer/vendor table) has been used in the examples within this post:
id company_name contact_name email
101 Acme Corp John Smith jsmith@acme.com
102 ACME Corporation John A. Smith john.smith@acme.com
103 Acme Corp. J. Smith jsmith@acme.com
104 Globex LLC Karen Lee klee@globex.com
105 Globex L.L.C. Karen A Lee k.lee@globex.com
Rows 101–103 are the same account; 104–105 are the same account. A plain GROUP BY clause on the company_name column would count these as four and two distinct customers.
Levenshtein
Levenshtein is good for typos, weak for reordering
SELECT a.id, b.id,
levenshtein(a.company_name, b.company_name) AS edit_distance
FROM customers a
JOIN customers b ON a.id < b.id
WHERE levenshtein(a.company_name, b.company_name) <= 4;
Expected output: “Acme Corp” vs “Acme Corp.” comes back at distance 1 (close). “Acme Corp” vs “ACME Corporation” scores worse than you’d like, purely due to length difference. Treat this as a first-pass filter, not the final word.
Jaro-Winkler
Jaro-Winkler is better for names and short identifiers
SELECT a.id, b.id,
jaro_winkler(a.contact_name, b.contact_name) AS score
FROM customers a
JOIN customers b ON a.id < b.id
ORDER BY score DESC;
Expected output: “John Smith” vs “John A. Smith” scores noticeably higher here than under Levenshtein, since Jaro-Winkler rewards matching prefixes. Good fit for person names and short fields like SKUs or ticket IDs.
Jaccard and Dice
Jaccard and Dice are good when word order doesn’t matter
SELECT a.id, b.id,
jaccard(a.company_name, b.company_name) AS jaccard_score,
dice(a.company_name, b.company_name) AS dice_score
FROM customers a
JOIN customers b ON a.id < b.id
ORDER BY jaccard_score DESC;
Expected output: “Globex LLC” vs “Globex L.L.C.” scores well here even though character-level edit distance is high, because both functions treat strings as sets of tokens/q-grams rather than ordered sequences. Indifferent to punctuation noise and reordering.

Cosine
Cosine is robust for longer free-text fields
SELECT a.id, b.id,
cosine(a.company_name, b.company_name) AS cosine_score
FROM customers a
JOIN customers b ON a.id < b.id
ORDER BY cosine_score DESC;
Expected output: the most forgiving score of the set. Scales reasonably to longer strings like full addresses. Use as a complement to Jaccard/Dice, not a replacement — they tend to agree on easy cases and diverge slightly on ambiguous ones, which is useful signal on its own.

Soundex
Soundex is catching phonetic near-misses
SELECT a.id, b.id
FROM customers a
JOIN customers b ON a.id < b.id
WHERE soundex(a.contact_name) = soundex(b.contact_name)
AND a.contact_name <> b.contact_name;
Expected output: catches pairs like “Catherine” vs “Kathryn” that every function above will miss, since the strings barely overlap. Noisy on its own – use as a tiebreaker alongside a token-based score, not a standalone filter.
Build a composite match score
Blending multiple functions into one score instead of relying on a single metric:
SELECT
a.id, b.id,
a.company_name, b.company_name,
ROUND(
(dice(a.company_name, b.company_name)
+ jaro_winkler(a.contact_name, b.contact_name)
+ CASE WHEN a.email = b.email THEN 1 ELSE 0 END
) / 3.0, 3
) AS match_score
FROM customers a
JOIN customers b ON a.id < b.id
WHERE dice(a.company_name, b.company_name) > 0.4
OR jaro_winkler(a.contact_name, b.contact_name) > 0.85
ORDER BY match_score DESC;
Set thresholds: auto-merge above a high score, queue for manual review in a middle band, leave low scores alone.
Results of the above composite match score:

Here is the demo video for your reference:
Checks to Perform Before Running this at Scale:
- Check for index support: none of these functions have native index support the way pg_trgm does, via GIN/GiST trigram indexes. Hence a naive self-join will be slow beyond a few tens of thousands of rows.
- Pre-filter (block) before fuzzy-matching: same first letter, same postal code, same normalized email domain run the expensive functions only within each block.
- Normalize before comparing: lowercase, strip punctuation, collapse whitespace before calling any similarity function.
- Run it as a batch job, not inline: write candidate-match pairs to a review table on a schedule rather than scoring live in an application path.
Migration Pitfall: Oracle Jaro-Winkler Scale Mismatch
This came up directly while migrating an Oracle vendor-matching procedure that used UTL_MATCH.JARO_WINKLER_SIMILARITY:
WHERE (
REGEXP_REPLACE(UPPER(VENDOR.VENDOR_NAME), '[^A-Z0-9]', ' ')
LIKE '%' || REGEXP_REPLACE(UPPER(VENDOR_NAME_IN), '[^A-Z0-9]', ' ') || '%'
OR UTL_MATCH.JARO_WINKLER_SIMILARITY(
REGEXP_REPLACE(UPPER(VENDOR.VENDOR_NAME), '[^A-Z0-9]', ' '),
REGEXP_REPLACE(UPPER(VENDOR_NAME_IN), '[^A-Z0-9]', ' ')
) > MATCH_CERTAINTY_IN
)
Two things silently break when this is migrated to PostgreSQL without adjustment – neither throws an error, both just quietly return fewer matches.
Issue 1: Scale mismatch
UTL_MATCH.JARO_WINKLER_SIMILARITY returns an integer on a 0–100 scale. The PostgreSQL equivalent (whether pg_similarity’s jaro_winkler or a fuzzystrmatch-based wrapper) returns a float on a 0–1 scale. If the migrated threshold (MATCH_CERTAINTY_IN, e.g. 85) isn’t rescaled, similarity > 85 can never be true against a 0–1 score — the fuzzy-match branch of the WHERE clause silently stops matching anything, and only the exact/LIKE branch keeps working.
Fix: scale the score at the call site (score * 100) or normalize the threshold to 0–1, consistently across every call site. In our case, we wrote a wrapper functions which will be called first in the functions this function rounds the value and calls the jarowinkler function for similarity search
CREATE OR REPLACE FUNCTION public.jarowinkler_pct(
s1 text,
s2 text)
RETURNS integer
LANGUAGE 'sql'
COST 100
IMMUTABLE PARALLEL SAFE
AS $BODY$
SELECT round(jarowinkler(s1, s2) * 100)::integer;
--if the above change does not work then will proceed with below one
--SELECT round((jarowinkler(s1, s2) * 100)::NUMERIC)::integer;
$BODY$;
ALTER FUNCTION public.jarowinkler_pct(text, text)
OWNER TO postgres;
Issue 2: regexp_replace default behavior
Oracle’s REGEXP_REPLACE replaces all matches by default. PostgreSQL’s regexp_replace replaces only the first match unless you pass the ‘g’ flag:
-- Oracle: strips every non-alphanumeric character
REGEXP_REPLACE(UPPER(name), '[^A-Z0-9]', ' ')
-- PostgreSQL equivalent needs the flag explicitly
regexp_replace(UPPER(name), '[^A-Z0-9]', ' ', 'g')
Drop the ‘g’ and a name like “A&B CO., LTD.” only gets its first punctuation character replaced the rest survives untouched. Vendor and company names routinely carry more than one punctuation mark, so this hits the majority of real names, not an edge case.
Neither bug shows up in a quick smoke test with a handful of clean names – both show up weeks later as a slow, quiet drop in match rate. The sanity checks in part 2 of this post exist specifically to catch this class of issue before it ships.
Conclusion:
In this post, we walked through installing pg_similarity and confirming which similarity functions are actually available, then sanity-checked their output before trusting it (scale, expected values on known pairs). We compared Levenshtein, Jaro-Winkler, Jaccard/Dice, Cosine, and Soundex on the same dataset to see where each one earns its keep, combined several of them into a single composite match score, and covered blocking and normalization to keep that scoring fast at scale. We also walked through a real migration pitfall — scale mismatches and regexp_replace default-flag differences — that surface specifically when porting similarity logic over from Oracle.
A few key takeaways to keep in mind:
- No single similarity function catches every kind of duplication. Typos, reordering, and phonetic near-misses each respond to a different metric — that’s the whole reason to reach for
pg_similarityalongsidepg_trgmrather than instead of it. - Always sanity-check a function’s output range before building a threshold on it. A silent scale mismatch (0–1 vs 0–100) won’t throw an error — it’ll just quietly return fewer matches.
- Pre-filter (block) before running expensive similarity comparisons at scale. None of these functions have native index support like
pg_trgm‘s GIN/GiST trigram indexes, so a naive self-join won’t hold up past a few tens of thousands of rows. - If you’re migrating similarity logic from another database, verify scale and default flag behavior explicitly. A syntactically correct port isn’t necessarily a functionally correct one.
pg_similarity gets you most of the way to a working dedup pipeline with nothing but SQL — but it works best combined with blocking, and only after you’ve tested it against known values first.
If you’re evaluating this for your own data, the cheapest first step isn’t picking a threshold — it’s running the sanity checks in session 2 against a handful of your own known-duplicate and known-distinct pairs before writing a single line of matching logic. The functions behave differently across datasets, and five minutes of spot-checking now saves you from debugging a silent scale mismatch three weeks from now.
