Skip to content

How to Find Duplicate Customer Records, Even With Typos

Updated November 3, 2026Tested on SynapCores 2.4.3

The questionHow do you find duplicate customer records, even with typos?

Short answer

Exact matching misses most duplicates, because each system types the same customer differently. Work in layers. Normalize first (lowercase emails, digits-only phone numbers) and match exactly: it's cheap and safe. Then compare only records that share a blocking key, such as a postcode, and score how alike their names and addresses are, by spelling or by meaning. Use the scores to rank candidates, merge on exact keys, and send the close calls to a person.

The problem: the same customer is in your CRM as Jonathan Smith, in billing as John D. Smith and in the ERP as Jon Smyth. The emails differ, one phone number has brackets and spaces, and one address says "St" where another says "Street". A GROUP BY email finds nothing. Marketing emails them three times, support sees a third of their history, and your customer count is wrong.

We build SynapCores, and the last part of this page runs in it. Everything before that is plain SQL (run on SQLite) and a few lines of Python, so you can follow it without installing anything of ours.

Why duplicate customer records slip past exact matching

Duplicates differ in four ways, and each needs a different tool:

How the records differ Example from the data below What catches it
Formatting JSmith@Acme.com vs jsmith@acme.com; (415) 555 0142 vs 415-555-0142 Normalize, then match exactly
Typos Jon Smyth vs Jon Smith; O'Neil vs ONeill Spelling similarity (edit distance, trigrams)
Variants Bob vs Robert; Harbour Rd vs Harbor Road; Rome vs Roma Nickname and abbreviation lists, or similarity by meaning
Different people who look alike Jonathan Smith in San Francisco and a Jon Smith in Austin Not a matching problem: blocking, more evidence, and a person who reviews

The first three are about finding more matches. The last one is about not merging people who aren't the same, and it's where most dedup projects go wrong.

The data

Twelve records from three systems. We know the answer, which is the point of a test set: they are 7 real people. Jonathan Smith appears three times, and Robert Garcia, Katherine O'Neil and Mia Rossi twice each. The Jon Smith in Austin is someone else.

sqlSQLite 3.43.2
CREATE TABLE customers (
  id        TEXT PRIMARY KEY,
  source    TEXT,
  full_name TEXT,
  email     TEXT,
  phone     TEXT,
  street    TEXT,
  city      TEXT,
  postcode  TEXT
);

INSERT INTO customers (id, source, full_name, email, phone, street, city, postcode) VALUES
  ('CRM-101',  'crm',     'Jonathan Smith',    'jsmith@acme.com',                 '415-555-0142',    '100 Mission St',        'San Francisco', '94105'),
  ('ERP-202',  'erp',     'Jon Smyth',         'jonathan.smith@acmeholdings.com', '(415) 555 0142',  '100 Mission Street',    'San Francisco', '94105'),
  ('BILL-303', 'billing', 'John D. Smith',     ' JSmith@Acme.com ',               NULL,              '100 Mission St #201',   'SF',            '94105'),
  ('CRM-104',  'crm',     'Robert Garcia',     'rgarcia@fastmail.com',            '312-555-0199',    '742 Evergreen Terrace', 'Chicago',       '60614'),
  ('ERP-205',  'erp',     'Bob Garcia',        'bob.g@work.io',                   NULL,              '742 Evergreen Ter.',    'Chicago',       '60614'),
  ('CRM-107',  'crm',     'Katherine O''Neil', 'koneil@harbourlaw.com',           '617-555-0110',    '18 Harbour Rd',         'Boston',        '02110'),
  ('ERP-208',  'erp',     'Catherine ONeill',  'k.oneil@harbourlaw.com',          '+1 617 555 0110', '18 Harbor Road',        'Boston',        '02110'),
  ('CRM-109',  'crm',     'Jon Smith',         'jon.smith@outlook.com',           '512-555-0177',    '9 Congress Ave',        'Austin',        '78701'),
  ('CRM-110',  'crm',     'Mia Rossi',         'mia.rossi@studio.io',             '+39 06 555 0011', '20 Via Roma',           'Rome',          '00184'),
  ('BILL-311', 'billing', 'Mia R. Rossi',      'Mia.Rossi@studio.io',             NULL,              'Via Roma 20',           'Roma',          '00184'),
  ('CRM-112',  'crm',     'Leo Park',          'leo@parks.dev',                   '416-555-0150',    '44 King St W',          'Toronto',       'M5H 1A1'),
  ('ERP-213',  'erp',     'Eli Tanaka',        'eli.t@example.com',               NULL,              '9-1 Roppongi',          'Tokyo',         '106-0032');

Sections 1 to 5 were run on SQLite 3.43.2 (macOS) and Python 3.9.6 (standard library only), and the outputs are pasted from that run. The SQL is standard, with one exception: in MySQL, || means OR unless PIPES_AS_CONCAT is set, so use CONCAT() there.

1. Exact matches: GROUP BY

sqlSQLite 3.43.2
SELECT email, COUNT(*) AS n
FROM customers
GROUP BY email
HAVING COUNT(*) > 1;
textSQLite 3.43.2
(0 rows)

Zero. jsmith@acme.com and JSmith@Acme.com are different strings, and so are mia.rossi@studio.io and Mia.Rossi@studio.io.

2. Normalize, then match exactly

Most "fuzzy" duplicates are formatting. Lowercase and trim the email. Keep only the digits of the phone, then the last 10 digits, so +1 617 555 0110 and 617-555-0110 agree. (Last 10 is a shortcut for North American numbers. For international data, parse numbers into E.164 format with a library such as libphonenumber.)

sqlSQLite 3.43.2
CREATE VIEW customers_norm AS
SELECT id, full_name, street, city, postcode,
       LOWER(TRIM(email)) AS email_norm,
       SUBSTR(digits, LENGTH(digits) - 9) AS phone_norm
FROM (
  SELECT *,
         REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', ''), '+', '') AS digits
  FROM customers
);

SELECT a.id AS id_a, b.id AS id_b,
       CASE WHEN a.email_norm = b.email_norm THEN 'email' ELSE 'phone' END AS matched_on
FROM customers_norm a
JOIN customers_norm b
  ON a.id < b.id
 AND (a.email_norm = b.email_norm OR a.phone_norm = b.phone_norm)
ORDER BY a.id, b.id;
textSQLite 3.43.2
id_a      id_b     matched_on
--------  -------  ----------
BILL-303  CRM-101  email
BILL-311  CRM-110  email
CRM-101   ERP-202  phone
CRM-107   ERP-208  phone

Four pairs, and every one is right. These are your safest merges, because an exact email or phone is strong evidence. (Shared family phones and work inboxes do exist, so even here, log why two records were merged.) a.id < b.id keeps each pair once and stops a record matching itself.

Robert and Bob Garcia are still apart: different emails, and Bob has no phone. Only their names and address connect them.

3. Blocking: only compare records that could match

To compare names you have to compare pairs, and pairs grow fast: n records make n(n-1)/2 of them. Blocking means only comparing records that share a cheap key. Here, the postcode:

sqlSQLite 3.43.2
SELECT
  (SELECT COUNT(*) FROM customers a JOIN customers b ON a.id < b.id) AS all_pairs,
  (SELECT COUNT(*) FROM customers a JOIN customers b
     ON a.id < b.id AND a.postcode = b.postcode) AS blocked_pairs;
textSQLite 3.43.2
all_pairs  blocked_pairs
---------  -------------
66         6

66 pairs become 6. At a million customers, it's about 500 billion pairs against however many share a postcode, and the join can use an ordinary index.

Blocking also prevents false matches, as the end of section 4 and the SynapCores part show. Its cost is that a duplicate with a typo in the blocking key is never compared. Use two or three different keys (postcode; email domain plus first letter of the surname; last four digits of the phone) and take the union of the candidate pairs.

4. Score the pairs: spelling similarity

SQLite has no edit-distance function, so this step is a few lines of Python using difflib from the standard library. (In Postgres, similarity() from pg_trgm and levenshtein() from fuzzystrmatch do the same job inside the database.) It scores each blocked pair from 0 to 1 on the name, the street, and the whole "name, street, city" string:

pythonPython 3.9.6
import sqlite3
from difflib import SequenceMatcher

def norm(s):
    return " ".join(s.lower().replace(".", " ").replace("'", "").replace("#", " ").split())

def ratio(a, b):
    return SequenceMatcher(None, norm(a), norm(b)).ratio()

db = sqlite3.connect("learn.db")
pairs = db.execute("""
    SELECT a.id, a.full_name, a.street, b.id, b.full_name, b.street,
           a.full_name || ', ' || a.street || ', ' || a.city,
           b.full_name || ', ' || b.street || ', ' || b.city
    FROM customers a
    JOIN customers b ON a.postcode = b.postcode AND a.id < b.id
""").fetchall()

rows = []
for id_a, name_a, street_a, id_b, name_b, street_b, ident_a, ident_b in pairs:
    rows.append((ratio(name_a, name_b), ratio(street_a, street_b), ratio(ident_a, ident_b),
                 id_a, name_a, id_b, name_b))

print(f"{'id_a':<9} {'name_a':<17} {'id_b':<8} {'name_b':<17} {'name':>5} {'street':>6} {'whole':>5}")
for name_sim, street_sim, whole_sim, id_a, name_a, id_b, name_b in sorted(rows, reverse=True):
    print(f"{id_a:<9} {name_a:<17} {id_b:<8} {name_b:<17} {name_sim:>5.2f} {street_sim:>6.2f} {whole_sim:>5.2f}")
textPython 3.9.6
id_a      name_a            id_b     name_b             name street whole
CRM-107   Katherine O'Neil  ERP-208  Catherine ONeill   0.90   0.89  0.92
BILL-311  Mia R. Rossi      CRM-110  Mia Rossi          0.90   0.73  0.83
CRM-104   Robert Garcia     ERP-205  Bob Garcia         0.78   0.89  0.88
BILL-303  John D. Smith     CRM-101  Jonathan Smith     0.77   0.88  0.74
BILL-303  John D. Smith     ERP-202  Jon Smyth          0.76   0.78  0.70
CRM-101   Jonathan Smith    ERP-202  Jon Smyth          0.70   0.88  0.88

All six blocked pairs are real duplicates. Read the table three ways:

  • Every true pair scores 0.70 or more on name, and the street scores back them up.
  • Bob Garcia is caught by his surname, not his first name. On first names alone, bob vs robert scores 0.44. Spelling similarity doesn't know nicknames.
  • Score fields separately. The "whole string" column mixes the name with the address, so a shared address lifts the score whoever lives there. Score each field on its own and combine them with rules you can read.

Without blocking, the Jon Smith in Austin would be compared with Jonathan Smith in San Francisco: 0.78, higher than the real Jonathan Smith / Jon Smyth pair at 0.70.

5. From pairs to customers: clusters

Pairs aren't customers. CRM-101 matches BILL-303 and ERP-202, so all three are one person, even though BILL-303 and ERP-202 never matched each other directly. Save the accepted pairs and follow them transitively:

sqlSQLite 3.43.2
CREATE TABLE dup_pairs (id_a TEXT, id_b TEXT);
INSERT INTO dup_pairs VALUES
  ('BILL-303', 'CRM-101'),
  ('BILL-311', 'CRM-110'),
  ('CRM-101',  'ERP-202'),
  ('CRM-107',  'ERP-208');

WITH RECURSIVE
  edges (x, y) AS (
    SELECT id_a, id_b FROM dup_pairs
    UNION
    SELECT id_b, id_a FROM dup_pairs
  ),
  reach (id, member) AS (
    SELECT id, id FROM customers
    UNION
    SELECT r.id, e.y FROM reach r JOIN edges e ON e.x = r.member
  )
SELECT MIN(member) AS cluster_id, id
FROM reach
GROUP BY id
HAVING COUNT(*) > 1
ORDER BY cluster_id, id;
textSQLite 3.43.2
cluster_id  id
----------  --------
BILL-303    BILL-303
BILL-303    CRM-101
BILL-303    ERP-202
BILL-311    BILL-311
BILL-311    CRM-110
CRM-107     CRM-107
CRM-107     ERP-208

dup_pairs holds the four exact-key pairs from section 2. Add Robert and Bob after a review and the 12 records become 7 customers. UNION, not UNION ALL, is what stops the recursion going round in circles.

Transitivity is also a risk: one weak link can chain two households into one. Check the size of every cluster, and send any cluster above two or three records to a person.

How it fits together

text
 sources (CRM, billing, ERP)
          │
          ▼
 normalize ── email lowercased, phone digits, address abbreviations
          │
          ├──► exact match on normalized keys ──► merge (log the key)
          │
          ▼
 block (postcode, email domain + initial, phone last 4) ──► candidate pairs
          │
          ▼
 score each field: name, street (spelling or meaning) ──► rank candidates
          │
          ├── high on name and address ──► review queue (a person)
          └── low                      ──► different
          │
          ▼
 cluster approved pairs ──► one customer id per cluster, with the reason logged

How the options compare

Normalize + exact Spelling similarity (difflib, pg_trgm, levenshtein) Phonetic (Soundex, Metaphone) Embeddings computed by your app (pgvector or a vector DB) EMBED in the database (SynapCores) Record-linkage libraries (Splink, dedupe, Zingg)
Formatting differences Yes Yes Partly Yes Yes (addresses: 0.81 to 0.95 on this page) Yes
Typos (Smyth/Smith) No Yes Often Depends on the model; not tested here Matched on the exact keys in the same query (Jon Smyth: phone) Yes
Nicknames (Bob/Robert) No No No Depends on the model; not tested here Yes here: Bob Garcia / Robert Garcia 0.87 With a nickname list or labelled pairs
Where it runs Any SQL In the database (Postgres extensions) or in your app SQL Server, MySQL, Postgres extension Your app or an API computes vectors; the database compares One SQL statement Python, on DuckDB or Spark
Learns from your data No No No No No Yes: dedupe from pairs you label; Splink estimates field weights from the data

Start with normalize-and-exact; it's often most of the win. If your variants are typos, pg_trgm plus a blocking key on Postgres is the default answer for most teams. For millions of records and many fields, a record-linkage library such as Splink weighs each field by how much agreement on it really tells you, if you can label a few hundred pairs.

What EMBED in the database changes is where the meaning score comes from: the model runs inside the engine, so a name or address similarity is an ordinary expression in the same blocked self-join as the exact keys. On this page it caught the nickname that spelling missed (Bob Garcia / Robert Garcia, 0.87), in one statement with the blocking and the exact-key evidence.

Rank duplicate candidates by meaning in SQL (SynapCores)

This part ran on SynapCores 2.4.3-ce. The embedding model (all-minilm, 384 dimensions) runs inside the engine. There's no API key and no external embedding service.

bashNot run: install command
curl -fsSL https://get.synapcores.com | SYNAPCORES_VERSION=v2.4.3-ce sh

Create the table with normalized keys and two vector columns, one for the name and one for the address. Section 4 showed why: one vector for everything lets the address hide the name.

sqlEngine version 2.4.3
CREATE TABLE customers (
  id         TEXT PRIMARY KEY,
  source     TEXT,
  full_name  TEXT,
  email      TEXT,
  phone      TEXT,
  street     TEXT,
  city       TEXT,
  postcode   TEXT,
  email_norm TEXT,
  phone_norm TEXT,
  name_vec   VECTOR(384),
  addr_vec   VECTOR(384)
);

-- then the same INSERT as in "The data" above (12 rows; the new columns stay empty)
textEngine version 2.4.3
rows_affected
-------------
12

Normalize in the database. The CASE keeps a missing phone as NULL, so records without a phone never "match" each other on an empty string.

sqlEngine version 2.4.3
UPDATE customers
SET email_norm = LOWER(TRIM(email)),
    phone_norm = CASE WHEN phone IS NULL THEN NULL ELSE SUBSTR(
                   REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', ''), '+', ''),
                   LENGTH(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', ''), '+', '')) - 9) END;
textEngine version 2.4.3
rows_affected
-------------
12

Embed the name and the address, two EMBED calls in one statement:

sqlEngine version 2.4.3
UPDATE customers
SET name_vec = EMBED(full_name),
    addr_vec = EMBED(street || ', ' || city);
textEngine version 2.4.3
rows_affected
-------------
12

Score every blocked pair, with the exact-key evidence, in one statement. Blocking, the exact keys and the meaning scores are one query over one copy of the data.

sqlEngine version 2.4.3
SELECT a.id AS id_a, a.full_name AS name_a,
       b.id AS id_b, b.full_name AS name_b,
       ROUND(COSINE_SIMILARITY(a.name_vec, b.name_vec), 2) AS name_sim,
       ROUND(COSINE_SIMILARITY(a.addr_vec, b.addr_vec), 2) AS addr_sim,
       CASE WHEN a.email_norm = b.email_norm OR a.phone_norm = b.phone_norm
            THEN 'yes' ELSE 'no' END AS exact_key
FROM customers a
JOIN customers b
  ON a.postcode = b.postcode
 AND a.id < b.id
ORDER BY name_sim DESC;
textEngine version 2.4.3
id_a      name_a            id_b     name_b            name_sim  addr_sim  exact_key
--------  ----------------  -------  ----------------  --------  --------  ---------
BILL-311  Mia R. Rossi      CRM-110  Mia Rossi         0.97      0.87      yes
CRM-104   Robert Garcia     ERP-205  Bob Garcia        0.87      0.81      no
BILL-303  John D. Smith     CRM-101  Jonathan Smith    0.78      0.89      yes
CRM-101   Jonathan Smith    ERP-202  Jon Smyth         0.57      0.95      yes
CRM-107   Katherine O'Neil  ERP-208  Catherine ONeill  0.54      0.88      yes
BILL-303  John D. Smith     ERP-202  Jon Smyth         0.51      0.84      no

Read it the same three ways as section 4:

  • Robert / Bob is second, at 0.87, with no shared email or phone. This is the pair the exact keys can't see, and meaning puts it near the top. Here meaning beats spelling.
  • The exact keys carry the rest. Jon Smyth (0.57) and Catherine ONeill (0.54) are already matched on phone, so their scores don't have to decide anything.
  • Addresses all come out close: Ter./Terrace 0.81, Harbour Rd/Harbor Road 0.88, Rome/Roma 0.87, SF/San Francisco 0.84 and 0.89.

Nicknames: does meaning know Bob is Robert? Compare the bare names:

sqlEngine version 2.4.3
SELECT ROUND(COSINE_SIMILARITY(EMBED('Bob Garcia'),   EMBED('Robert Garcia')), 2) AS bob_robert,
       ROUND(COSINE_SIMILARITY(EMBED('Bob'),          EMBED('Robert')), 2)        AS bob_robert_first,
       ROUND(COSINE_SIMILARITY(EMBED('Bobby Garcia'), EMBED('Robert Garcia')), 2) AS bobby_robert;
textEngine version 2.4.3
bob_robert  bob_robert_first  bobby_robert
----------  ----------------  ------------
0.87        0.59              0.85

Here meaning helps: Bob Garcia scores 0.87 against Robert Garcia, and Bobby Garcia 0.85. On first names alone, Bob against Robert is 0.59, where difflib gave bob / robert 0.44. Test the nicknames your customers actually use.

Without blocking. The same score with the postcode condition removed, to see what blocking was protecting you from:

sqlEngine version 2.4.3
SELECT a.id AS id_a, a.full_name AS name_a, a.city AS city_a,
       b.id AS id_b, b.full_name AS name_b, b.city AS city_b,
       ROUND(COSINE_SIMILARITY(a.name_vec, b.name_vec), 2) AS name_sim
FROM customers a
JOIN customers b ON a.id < b.id
ORDER BY name_sim DESC
LIMIT 8;
textEngine version 2.4.3
id_a      name_a            city_a         id_b     name_b            city_b         name_sim
--------  ----------------  -------------  -------  ----------------  -------------  --------
BILL-311  Mia R. Rossi      Roma           CRM-110  Mia Rossi         Rome           0.97
CRM-101   Jonathan Smith    San Francisco  CRM-109  Jon Smith         Austin         0.9
CRM-104   Robert Garcia     Chicago        ERP-205  Bob Garcia        Chicago        0.87
BILL-303  John D. Smith     SF             CRM-109  Jon Smith         Austin         0.8
BILL-303  John D. Smith     SF             CRM-101  Jonathan Smith    San Francisco  0.78
CRM-109   Jon Smith         Austin         ERP-202  Jon Smyth         San Francisco  0.62
CRM-101   Jonathan Smith    San Francisco  ERP-202  Jon Smyth         San Francisco  0.57
CRM-107   Katherine O'Neil  Boston         ERP-208  Catherine ONeill  Boston         0.54

The Jon Smith in Austin, a different person, is second (0.90 against Jonathan Smith) and fourth (0.80 against John D. Smith), above the true pair John D. Smith / Jonathan Smith (0.78). Blocking on postcode removes those comparisons before any score is computed.

One decision per pair. Turn the scores into outcomes you can act on. Exact-key matches merge. A high name score and a high address score goes to review, never straight to a merge. Everything else is different.

sqlEngine version 2.4.3
SELECT id_a, id_b, name_sim, addr_sim, exact_key,
       CASE
         WHEN exact_key = 'yes'                     THEN 'merge'
         WHEN name_sim >= 0.50 AND addr_sim >= 0.80 THEN 'review'
         ELSE 'different'
       END AS decision
FROM (
  SELECT a.id AS id_a, b.id AS id_b,
         ROUND(COSINE_SIMILARITY(a.name_vec, b.name_vec), 2) AS name_sim,
         ROUND(COSINE_SIMILARITY(a.addr_vec, b.addr_vec), 2) AS addr_sim,
         CASE WHEN a.email_norm = b.email_norm OR a.phone_norm = b.phone_norm
              THEN 'yes' ELSE 'no' END AS exact_key
  FROM customers a
  JOIN customers b ON a.postcode = b.postcode AND a.id < b.id
) p
ORDER BY decision, name_sim DESC;
textEngine version 2.4.3
id_a      id_b     name_sim  addr_sim  exact_key  decision
--------  -------  --------  --------  ---------  --------
BILL-311  CRM-110  0.97      0.87      yes        merge
BILL-303  CRM-101  0.78      0.89      yes        merge
CRM-101   ERP-202  0.57      0.95      yes        merge
CRM-107   ERP-208  0.54      0.88      yes        merge
CRM-104   ERP-205  0.87      0.81      no         review
BILL-303  ERP-202  0.51      0.84      no         review

The thresholds are the lowest scores of the two duplicates without an exact key, minus 0.01: fitted to these 12 rows, so set yours from labelled pairs. The result is 4 merge and 2 review. Out of 66 possible pairs, the reviewer sees two, with Robert / Bob at the top.

That's the design this page recommends: meaning-based scores find candidates and rank them; exact keys and a person decide.

Catch the duplicate at sign-up. The cheapest duplicate is the one you never create. Before inserting a new customer, look for existing records in the same postcode with a similar name:

sqlEngine version 2.4.3
SELECT id, full_name, street,
       ROUND(COSINE_SIMILARITY(name_vec, EMBED('Bobby Garcia')), 2)                      AS name_sim,
       ROUND(COSINE_SIMILARITY(addr_vec, EMBED('742 Evergreen Terrace, Chicago')), 2)    AS addr_sim
FROM customers
WHERE postcode = '60614'
ORDER BY name_sim DESC
LIMIT 3;
textEngine version 2.4.3
id       full_name      street                 name_sim  addr_sim
-------  -------------  ---------------------  --------  --------
ERP-205  Bob Garcia     742 Evergreen Ter.     0.86      0.81
CRM-104  Robert Garcia  742 Evergreen Terrace  0.85      1.0

Bob and Robert both come back, 0.01 apart and at the same address. That's exactly the moment to ask "Is one of these you?" on the sign-up form.

Group the pairs into customers. SynapCores runs the recursive CTE from section 5 unchanged. Save the merge pairs, plus the review pairs a person approved, then cluster:

sqlEngine version 2.4.3
CREATE TABLE dup_pairs (id_a TEXT, id_b TEXT);
INSERT INTO dup_pairs VALUES
  ('BILL-303', 'CRM-101'),
  ('BILL-311', 'CRM-110'),
  ('CRM-101',  'ERP-202'),
  ('CRM-107',  'ERP-208'),
  ('CRM-104',  'ERP-205');   -- Robert / Bob, approved in review

WITH RECURSIVE
  edges (x, y) AS (
    SELECT id_a, id_b FROM dup_pairs
    UNION
    SELECT id_b, id_a FROM dup_pairs
  ),
  reach (id, member) AS (
    SELECT id, id FROM customers
    UNION
    SELECT r.id, e.y FROM reach r JOIN edges e ON e.x = r.member
  )
SELECT MIN(member) AS cluster_id, id
FROM reach
GROUP BY id
HAVING COUNT(*) > 1
ORDER BY cluster_id, id;
textEngine version 2.4.3
cluster_id  id
----------  --------
BILL-303    BILL-303
BILL-303    CRM-101
BILL-303    ERP-202
BILL-311    BILL-311
BILL-311    CRM-110
CRM-104     CRM-104
CRM-104     ERP-205
CRM-107     CRM-107
CRM-107     ERP-208

Twelve records, seven customers: these four clusters, plus the Austin Jon Smith, Leo and Eli on their own. If you'd rather keep identifiers as a graph (customers linked through shared emails, phones and addresses), the identity-resolution recipe does the same grouping in Cypher, on the same engine.

Keep vectors fresh. When a customer edits their name or address, re-embed that row in the same statement, or the old vector keeps matching:

sqlEngine version 2.4.3
UPDATE customers
SET street   = '742 Evergreen Terrace, Apt 2',
    addr_vec = EMBED('742 Evergreen Terrace, Apt 2, Chicago')
WHERE id = 'ERP-205';
textEngine version 2.4.3
rows_affected
-------------
1

Clean up when you're done:

sqlEngine version 2.4.3
DROP TABLE IF EXISTS dup_pairs;
DROP TABLE IF EXISTS customers;

Thresholds, and why "similar" isn't "same person"

Set the cut-off from labelled pairs, not from a blog post (this one included). Mark a few hundred candidate pairs as same or different, and for each threshold count wrong merges and missed duplicates. A wrong merge is usually worse (one customer sees another's orders), so accept more misses and send the band in between to a person.

Similar is not the same. Every method on this page measures how alike two records look. Exact evidence is what proves two records are one person: a verified email, a phone number, a date of birth, an order history. That's why "merge" on this page needs an exact key, and everything softer goes to review.

Keep the reason. Store which rule merged each pair, keep the original records, and check cluster sizes: a large cluster usually means one bad link.

Where to go next

Try it: download SynapCores Community Edition, load the 12 records above, and see which pairs your current dedup catches. Then run the blocked self-join with EMBED and look at what lands in your review queue.

Frequently asked questions

How do you find duplicate records?
Exact duplicates are one query: GROUP BY the columns that identify a record and keep the groups with HAVING COUNT(*) > 1. Real duplicates are rarely exact, so normalize first (lowercase and trim emails, keep only the digits of phone numbers) and group again. For typos and spelling variants, compare records within a block, such as the same postcode, and score how similar their names and addresses are.
What is a fuzzy name match?
A fuzzy name match treats two names as the same when they are close rather than identical. "Jon Smyth" and "Jon Smith" differ by one letter. Common methods are edit distance (Levenshtein), Jaro-Winkler, trigram overlap and phonetic codes such as Soundex and Metaphone. Embeddings compare meaning instead of spelling, which helps with nicknames. Each gives a score, and you choose the cut-off.
Is fuzzy matching accurate?
As accurate as your threshold and your data allow. Label a few hundred candidate pairs from your own data, count wrong merges and missed duplicates at each threshold, and send the band in between to a person. On this page's 12 records, exact keys settle four of the six duplicate pairs, and the scores put the other two at the top of the review queue.
How do I match first-name variations such as Bob and Robert?
Spelling similarity can't know that Bob is Robert: "bob" and "robert" score 0.44 with Python's difflib. Join a nickname table (public lists of English given-name variants exist) before you compare, so Bob, Bobby and Rob map to Robert. Embeddings help too: in our test, "Bob Garcia" scored 0.87 against "Robert Garcia" and "Bobby Garcia" 0.85. Test the nicknames your own customers use.
How do I check for typos when comparing two strings in SQL?
It depends on the database. SQL Server and MySQL have SOUNDEX built in (and SQL Server has DIFFERENCE). Postgres has levenshtein() and dmetaphone() in the fuzzystrmatch extension, and similarity() in pg_trgm. SQLite has none of these by default, so compute the score in your application. Use them inside a blocked self-join, not across the whole table.
How do I find duplicate customers without comparing every row with every other row?
Use blocking: only compare records that share a cheap key, such as postcode, email domain plus surname initial, or the last four digits of the phone. n records have n(n-1)/2 pairs: 12 records make 66, and 1 million make about 500 billion. Blocking our 12 by postcode leaves 6. Use two or three different blocking keys and union the candidates, so one typo in the postcode doesn't hide a duplicate.