
A customer list that has been through a few years of web forms, imports and sales people typing fast never has clean duplicates. It has Jon Smith and John Smith, SMITH, JOHN, J. Smith, Smith Plumbing Ltd and Smith Plumbing Limited. SELECT DISTINCT and GROUP BY name find none of them. Fuzzy matching finds them, and also finds the two genuinely different Mary Johnsons who live in different cities.
We wanted numbers, not a demo on six rows, so we built a made-up list where we know the answer: 50,000 rows holding 46,000 real customers and 4,000 extra rows that are the same customer typed differently. Then we ran pg_trgm on it at every threshold and counted how many of the pairs it reported were real duplicates (precision) and how many of the real duplicates it found (recall). All timings are PostgreSQL 18.6 on an Apple silicon Mac mini (10 cores, 16 GB), one session, warm cache. The scripts are at the end.
The test list: 50,000 rows, 4,720 duplicate pairs
Names follow real frequencies: first names from the US Census 1990 name files, surnames from the 2010 Census top 20,000. 70% of customers are people, 30% businesses (<surname> <trade> <suffix>, like Diaz Bakery Ltd). Because the names follow real frequencies, common names repeat the way they do in real lists: 2,181 pairs of different customers have exactly the same name (case ignored). Every customer has their own e-mail address.
The 4,000 extra rows were made from a customer by one of these changes. Some customers got two extra rows, so the list holds 4,720 true duplicate pairs:
10% of the extra rows also have a different city (someone moved, or a business has a second branch). The truth sits in its own table, true_pairs(a, b), which none of the matching queries can see.
Clean the names first
pg_trgm already ignores case and punctuation, so JOHN SMITH and John Smith score 1.0 without any help. What it can't know is that Ltd, Limited, & Co and a leading The carry no meaning. Those words are a large part of a short name's trigrams, so they push real duplicates apart. We kept the original column and added a cleaned copy as a generated column:
If the list arrives as a CSV, do the boring part before it reaches the database. csvCleaner trims stray spaces, collapses double spaces, drops exact duplicate rows and empty rows, and can set one column to Title Case, all in the browser. The legal-suffix rule is specific to your data, so that one belongs in SQL where you can see and change it.
What the similarity numbers look like
similarity(a, b) splits each string into words, pads each word with two spaces in front and one behind, and takes every run of three characters: Jon Smith becomes {" j"," s"," jo"," sm",ith,jon,mit,"on ",smi,"th "}. The score is the shared trigrams divided by all distinct trigrams of the two. The % operator is true when that score is at least pg_trgm.similarity_threshold (default 0.3). Some pairs to calibrate your eye, with the Levenshtein distance (edits needed) next to it:
Two things show up already. Word order doesn't matter to trigrams (Smith, John scores 1.0, Levenshtein says 10), which is good for lists where some people type the surname first. And John Smyth and Joan Smith score exactly the same: a one-letter typo and a different person look identical to any string measure. Only another column can tell them apart.
Thresholds on 50,000 rows: precision and recall
We stored every pair scoring 0.3 or more, once on the raw names and once on the cleaned ones, and joined each against the truth. Recall is out of the 4,720 true pairs:
Read the bottom row first. Even names that are identical after cleaning are the same customer only 34.5% of the time in this list, because common names repeat and because cleaning makes Smith Plumbing Ltd and Smith Plumbing LLC (two firms) the same string. No threshold on the name alone gives a merge list you could run unattended: at 0.5 you catch 95% of duplicates buried in 180,000 pairs; at 0.7 the list is 9,070 pairs, two thirds of them wrong, and you've lost 40% of the duplicates.
Cleaning helped where we expected: at 0.7 it found 116 more real pairs, and at 1.0 it found 581 more (suffix and The changes become exact matches). It also created more same-string pairs of different businesses, which is why its precision at the top end is lower. Recall at 0.5 by kind of change, on cleaned names:
Typos are where the threshold bites: one changed letter in a short name removes up to three of its ten or so trigrams, so most typo pairs land between 0.5 and 0.7. Initials and nicknames hardly survive 0.7. Bob for Robert shares no trigram with it at all; the 75% found at 0.5 are the cases where the surname is long enough to carry the pair.
A second column decides: e-mail and city
The name finds candidates; something else has to confirm them. We scored every candidate pair with two cheap checks, same e-mail (case-insensitive) and same city, and tried a few rules:
That points to a two-step process instead of one magic threshold:
Step 1 is safe in this list because every customer has their own address. Check that in yours first: info@ and accounts@ addresses shared by two firms, or one family address, break it. Count how many distinct names share each address before you trust it.
Step 2 is 2,096 pairs, a couple of hours by eye, and about half of them are real. That's the honest size of the job for 50,000 rows with this much noise. The remaining 250 duplicates are spread thin: the best next slice holds 106 of them in 9,070 pairs. Past that point, accept the loss or ask the customers.

In Pilotbase this is a table you filter one city at a time, sort by name_sim, and fill in same_customer cell by cell; the grid exports to CSV when the merge is done somewhere else. The first rows of Austin show the whole problem: six pairs with identical names at 1.0, where only the e-mail addresses differ, and below them the typos (Landin Dentaal, Martha Magon) that are almost certainly the same customer.
Levenshtein: a tighter check for typos
levenshtein() from fuzzystrmatch counts single-character edits. It has no index support, so use it on the trigram candidates, not on the whole table. Over every candidate pair, counting only names that differ:
Distance 1 plus same city is the best-precision rule we found that doesn't use the e-mail: 89%. But it only covers typos and spacing; swapped order and suffixes score 4 to 10 edits. It's a good way to split step 2 into a quick pile (distance 1) and a careful pile. levenshtein_less_equal(a, b, 2) stops counting once the distance passes 2, which made it 3x faster than levenshtein() in our run (4.1 s against 12.4 s on 50 million pairs). Both are limited to 255 characters per input.
Speed: the index, the join and a GiST trap
Without an index, every pair is compared. On a 10,000-row slice (50 million pairs) that took 50 s with similarity() >= 0.5; the full 50,000 rows would be 25 times as many pairs. With a GIN index the self-join asks the index for each row's candidates instead:
A lower threshold costs more for two reasons: more pairs to return, and more index entries to recheck (at 0.3 each lookup pulled 464 candidates from the index to keep 101). So pick the threshold from the precision table before you run the big join, not after.
We also built a GiST index (gist_trgm_ops), because GiST is the one that supports ORDER BY name_norm <-> 'john smith' LIMIT 5, the nearest names. It was 4.7 MB against GIN's 2.4 MB, and the trap is what the planner did with both present: it picked GiST for the self-join, which ran 28 times slower (7.2 ms per lookup against 0.25 ms at 0.7). Timed on 1,000 single-name lookups at 0.5:
Use GIN for finding duplicates. Add GiST only if you need nearest-first ordering for something like a "did you mean an existing customer?" check on a form, and then check with EXPLAIN that your batch query still uses GIN. If it doesn't, drop the GiST index for the clean-up and build it again afterwards.
MySQL and SQLite
Neither has pg_trgm. MySQL has SOUNDEX() and SOUNDS LIKE (soundex(a) = soundex(b)), and an ngram parser for FULLTEXT that is built for search, not for pair scores. SQLite has no fuzzy matching in the default build (soundex() only with a compile option; the spellfix1 extension has an edit distance if you load it). We measured SOUNDEX on MySQL 8.4 with the same cleaned names, as a stored generated column with an index:
Fast, because it's a plain equality join, and it misses a third of the duplicates. SOUNDEX keeps the first letter and the order of the words, so Smith, John and every J. Smith fall out, and a typo in the first letter does too.
The other route is to compute the same trigram similarity by hand in SQL. It works on both engines (window functions are needed: MySQL 8, SQLite 3.25 and later) and gives exactly pg_trgm's answer: on SQLite 3.54 it returned the same 179,659 pairs at 0.5 that Postgres did. The trick that keeps it from comparing everything with everything is prefix filtering: if two names are at least 0.5 alike, they must share one of their rarest trigrams, so common ones like " j" are never used to pair rows.
It's slow next to the GIN index. SQLite took 5.5 minutes for all 50,000 rows: 73 s to build the 42 million candidate pairs that share a rare-ish trigram, 259 s to score them. On MySQL 8.4 the scoring step over those 42 million pairs stopped with The table '/var/folders/.../#sql...' is full: the implicit temporary table for the GROUP BY outgrew its default limits. Adding a blocking condition fixed both size and time: with ca.city = cb.city in the candidate step, MySQL needed 82 s for 2.5 million candidates and 58 s to score them, and returned the same 14,153 pairs as Postgres' name >= 0.5 and same city rule. For a one-off clean-up that's fine; for anything bigger, block on a column you'd require anyway (city, postcode, first letter of the surname), or copy the table into a Postgres database for the afternoon and use pg_trgm.
Why the CSV tool alone isn't enough
csvCleaner has a "Find near-duplicate rows" option. It sorts the rows by their whole text and compares each row with its next 14 neighbours by edit distance. We replayed that method on the same 50,000 rows:
High precision on whole rows, because a row that differs by one or two characters across name, e-mail and city really is a copy. But it finds a fifth of the duplicates: sorting puts Smith, John far from John Smith, and a new e-mail address changes the row too much. Use it as a quick look at a file before loading (it shows the first 200 pairs), then do the real matching in the database.
The whole process, in order
Step 7 is the one that hurts if done in a hurry: every table with a foreign key to customers needs its rows moved to the surviving id before the duplicate goes. Once two rows are known to be one customer, picking which row survives is the same problem as removing exact duplicates; Removing duplicate rows in SQL: keep the newest per key has that part for Postgres, MySQL and SQLite.
Review the pairs where you can see both rows, sort, filter and edit in the grid, on Postgres, MySQL, SQLite and more. Pilotbase is free for Windows, macOS and Linux, or Docker on a server.
Get PilotbaseClean the CSV before it reaches the database: trim spaces, drop exact duplicates and empty rows, see near-duplicate rows. In your browser, nothing uploaded.
Open csvCleanerRelated: A messy CSV into Postgres without surprises: profile, clean, then load · Case-insensitive text in Postgres: citext, lower() or a nondeterministic collation
Sources: PostgreSQL 18 documentation: pg_trgm (trigram extraction, similarity_threshold default 0.3, GIN and GiST operator classes, <-> only with GiST) and fuzzystrmatch (levenshtein 255-character limit, levenshtein_less_equal, soundex/metaphone not for multibyte text). MySQL 8.4 SOUNDEX(). Names: US Census Bureau 1990 first name files and 2010 surnames. The test list and every number above come from our own run on 2026-10-27: PostgreSQL 18.6, MySQL 8.4.11, SQLite 3.54.0; the list generator and queries can be rerun with the same seed.



