
Finding duplicate customers is the half everyone writes about. Then you have a list of pairs, and every one of them has orders, addresses, saved cards, tags, loyalty points and reviews hanging off it. DELETE FROM customers WHERE id = 195004 either fails on a foreign key, or, worse, succeeds and cascades away something you wanted. The merge is the part that can break things.
This is the merge done properly: a merge_map of pairs, every referencing table found from the catalog rather than from memory, rules for the child rows that collide, an audit row for every change, and an undo that we checked puts every table back byte for byte. We timed it on PostgreSQL 18.6 and MySQL 8.4.11 with 1 million orders, alone and with four connections writing flat out, to see what the locks cost.
The lab: 200,000 customers, 5,000 of them twice
A shop schema with six tables pointing at customers: orders (1,000,000 rows), addresses (one default per customer, enforced by a unique index; 30% have a second address), payment_methods (unique per customer and card fingerprint), customer_tags (primary key customer + tag), loyalty (one row per customer) and reviews (ON DELETE CASCADE). Customers 195,001 to 200,000 are second accounts of 5,000 earlier customers; 40% of them saved the same card as their first account, and tags overlap by chance. The pairs come from wherever you found them (for names, see Near-duplicate customers in a list) and go into one table:
keep_id is unique on purpose: if A and B are both duplicates of C, map both to C, never B to A and A to C in the same run, or the second update moves rows onto a customer that's about to be deleted. Pick the survivor by a rule you can explain later (oldest account, most orders, verified e-mail).
Step 1: ask the catalog what points at customers
A merge script with a hard-coded list of six tables is correct until someone adds a seventh. PostgreSQL's pg_constraint knows every foreign key, the column it uses and its ON DELETE rule:
The last line is why a merge never starts with the delete. Orders would stop it with a foreign-key error, which is the good outcome. Reviews would not: deleting the 5,000 duplicates would silently take 2,506 reviews with them. On MySQL the same list is information_schema.KEY_COLUMN_USAGE where REFERENCED_TABLE_NAME = 'customers', with the delete rule in REFERENTIAL_CONSTRAINTS.
The second question for the catalog: which of those tables have a unique index containing the customer column? Those are the rows that can collide when two customers become one:
Step 2: decide what a collision means, table by table
The catalog can find the collisions; it can't decide them. That's a business rule per table, and it's worth writing down before any code:
Doing it the lazy way, UPDATE ... ON CONFLICT style, isn't available for a plain UPDATE, so the merge deletes (or demotes) the colliding rows first, recording each in the audit, and then repoints what's left. The order of operations is the whole design: lock, settle collisions, repoint everything the catalog found, delete the duplicates last.
The merge, in one transaction (PostgreSQL)
Every change writes one audit row: moved rows keep only their key (the order id), deleted rows and the duplicate customer keep the whole row as jsonb, points keeps the duplicate's loyalty row so the sum can be taken back. For the 5,000 pairs that came to 64,085 audit rows:

In Pilotbase, merge_audit filtered to one duplicate reads like a receipt: customer 195004's 3,264 points went to 48925, two tags and a saved card were already on the survivor, the duplicate's default address was demoted and moved, then each order. (Cast row_data::text in the select to read the JSON in the grid.) That's also the answer when someone asks, three months later, where their order history went.
MySQL 8.4: the same steps, dynamic SQL for the loop
MySQL has no ON CONFLICT for updates either, and no partial unique index: one default address per customer needs a generated column (default_for bigint as (if(is_default, customer_id, null)) stored with a unique key on it, since NULLs don't collide). The collision steps are multi-table UPDATE/DELETE joins. The catalog loop needs PREPARE/EXECUTE, because a stored procedure can't use a table name from a variable directly:
Same data, same rules: 24,885 orders moved (the random order data differs slightly from the PostgreSQL run), 1.13 s, 22.5 MB of redo, undo 0.80 s.
Undo, and the proof that it works
An undo you haven't run is a hope. Ours puts the duplicates back from their saved rows, moves every moved row back by its key, re-promotes the demoted addresses, re-inserts the deleted tags and cards, and subtracts the points:
To check it, the lab hashed every table (all rows, in key order, through md5(string_agg(row::text, '|'))) before the merge and after the undo, and compared. On both engines and every run, all seven tables came back identical: same rows, same values, same ids, including the 5,000 customer rows that had been deleted and the 1,460 cards. Undo took 0.73 to 1.0 s on PostgreSQL and 0.74 to 0.95 s on MySQL. Keep the audit for as long as someone might ask; it's small (64,085 rows here) next to the tables it describes.
One caveat about undo after a while: it restores the old rows, but orders placed for the survivor after the merge stay with the survivor, and an undo can't know which of those the duplicate would have made. Undo is for "we merged the wrong pairs this morning", not for next year.
Locks: what the merge blocks while it runs
The merge took about a second either way. What matters on a live shop is what waits during that second. Four connections ran flat out during each merge (80% inserting orders, 20% updating customer rows), with 10% of their work on the 5,000 survivors. The longest wait per kind of request, while the merge ran:
The duplicate has to be locked FOR UPDATE; it's about to be deleted. The survivor doesn't. A new order checks its foreign key by taking FOR KEY SHARE on the customer row in PostgreSQL (a shared lock in InnoDB), and FOR UPDATE on the survivor blocks exactly that: every order for any of the 5,000 survivors waited for the whole merge. FOR NO KEY UPDATE in PostgreSQL, FOR SHARE in MySQL, still stops two merges or a profile edit from changing the survivor underneath you, and lets orders through without waiting: under a millisecond on PostgreSQL, and on MySQL the same 5 ms worst case as orders for unrelated customers. (A side note from building this: our first version took the survivor lock through EXECUTE format('... for %s', mode) in PL/pgSQL, and it didn't block anything at all, in either mode. Static PERFORM ... FOR UPDATE does. Test a lock by watching another session wait on it.)
Requests for the duplicate: the part that breaks the app
Then we sent 2.5% of the requests to the duplicates themselves, the realistic case of a customer still logged in on their second account:
At 2.5%, every one of the four connections hit a duplicate within milliseconds and sat there until the merge committed. A pool of 20 connections serving a busy site would do the same thing, and for that second the whole app waits. And after the commit, every write for a merged id fails with a foreign-key error. The fix for the second part isn't in SQL: keep merge_map forever and make the app resolve an id through it (login, session restore, saved carts), or keep the duplicate row as a tombstone with a merged_into column for a few days and delete it in a later sweep.
For the first part, smaller transactions. The same 5,000 pairs in batches of 500, each batch its own transaction:
Three times the total time for a third of the worst wait. With a million orders a whole merge is a second, so this is about the tail, not about throughput. On a bigger shop, where a merge takes a minute, batches are what keeps checkout working. Batch by keep_id ranges so a survivor and its duplicates are always in the same batch.
Checklist
Removing exact duplicates with no children is a much smaller job; Removing duplicate rows in SQL: keep the newest per key covers that. And if a repoint is slow, check that every foreign key column has an index: Foreign keys without an index measured what happens otherwise.
Review the pairs, run the merge on a copy and read merge_audit in a grid before production, on PostgreSQL, MySQL and more. Pilotbase is free for Windows, macOS and Linux, or Docker on a server.
Get PilotbaseExported the tables before and after a merge? csvDiff shows which rows were added, removed or changed, in your browser.
Open csvDiffSources: PostgreSQL 18 documentation: explicit row-level locks (FOR UPDATE, FOR NO KEY UPDATE, FOR KEY SHARE conflicts), pg_constraint (confdeltype), pg_index; MySQL 8.4 Reference Manual: locking reads (FOR SHARE / FOR UPDATE), foreign key locking (shared locks on parent rows during checks), KEY_COLUMN_USAGE, prepared statements in stored programs. Our test: PostgreSQL 18.6 and MySQL 8.4.11 on an Apple-silicon Mac mini, October 2026; 200,000 customers, 5,000 duplicate pairs, 1,000,000 orders; writers 4 connections with no pause; scripts reproducible.



