Pilotbase grid over merge_audit: one duplicate customer's loyalty points, tags, saved card, default address and orders, each recorded as moved, deleted or demoted

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:

sql
create table merge_map (dup_id bigint primary key,      -- the row that goes
                        keep_id bigint not null unique); -- the row that stays

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:

sql
-- every column that points at customers(id), from the catalog
create or replace view customer_refs as
select c.conrelid::regclass as tbl, a.attname as col,
       (select array_agg(pa.attname order by pa.attnum)
          from pg_constraint pk
          join pg_attribute pa on pa.attrelid = pk.conrelid and pa.attnum = any (pk.conkey)
         where pk.conrelid = c.conrelid and pk.contype = 'p' and pa.attname <> a.attname) as key_cols,
       c.confdeltype as on_delete
from pg_constraint c
join pg_attribute a on a.attrelid = c.conrelid and a.attnum = c.conkey[1]
where c.confrelid = 'customers'::regclass and c.contype = 'f';
text
       tbl       |     col     | key_cols | on_delete
-----------------+-------------+----------+-----------
 addresses       | customer_id | {id}     | a          a = no action
 orders          | customer_id | {id}     | a
 payment_methods | customer_id | {id}     | a
 customer_tags   | customer_id | {tag}    | a
 loyalty         | customer_id |          | a
 reviews         | customer_id | {id}     | c          c = cascade

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:

sql
select r.tbl, i.indexrelid::regclass as unique_index, pg_get_expr(i.indpred, i.indrelid) as only_where
from customer_refs r
join pg_index i on i.indrelid = r.tbl and i.indisunique
join pg_attribute a on a.attrelid = i.indrelid and a.attname = r.col and a.attnum = any (i.indkey);
--       tbl       |                unique_index                 | only_where
--  addresses       | one_default_address                         | is_default
--  payment_methods | payment_methods_customer_id_fingerprint_key |
--  customer_tags   | customer_tags_pkey                          |
--  loyalty         | loyalty_pkey                                |

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:

text
table             collision                           rule we used                         pairs hit
loyalty           both have a row (one per customer)  add the points, drop the duplicate's      5,000
customer_tags     same tag on both                    keep one                                  2,047 tags
payment_methods   same card fingerprint on both       keep the survivor's                       1,460
addresses         both have a default address         survivor's stays default, other demoted  5,000
orders, reviews   none (no unique key on customer)    just repoint                                 -

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)

sql
create or replace procedure merge_customers(p_keep_lock text default 'no key update')
language plpgsql as $$
declare r record; n bigint;
begin
  -- 1. lock the pairs in id order (two merges touching the same customer queue instead of deadlocking).
  --    The duplicate is deleted, so FOR UPDATE; the survivor only needs NO KEY UPDATE, which still lets
  --    other sessions insert orders for it (their foreign key check takes FOR KEY SHARE)
  perform 1 from customers where id in (select dup_id from merge_map) order by id for update;
  if p_keep_lock = 'update' then
    perform 1 from customers where id in (select keep_id from merge_map) order by id for update;
  else
    perform 1 from customers where id in (select keep_id from merge_map) order by id for no key update;
  end if;

  -- 2. child rows that would collide with the survivor's own rows
  --    loyalty: one row per customer -> add the points, drop the duplicate's row
  insert into merge_audit (dup_id, keep_id, tbl, action, row_data)
    select m.dup_id, m.keep_id, 'loyalty', 'points', to_jsonb(l) from loyalty l join merge_map m on l.customer_id = m.dup_id;
  update loyalty k set points = k.points + d.points
    from loyalty d join merge_map m on d.customer_id = m.dup_id where k.customer_id = m.keep_id;
  delete from loyalty l using merge_map m where l.customer_id = m.dup_id;
  --    tags and saved cards: the survivor already has the same one -> drop the duplicate's copy
  with d as (
    delete from customer_tags t using merge_map m
     where t.customer_id = m.dup_id
       and exists (select 1 from customer_tags k where k.customer_id = m.keep_id and k.tag = t.tag)
    returning m.dup_id, m.keep_id, to_jsonb(t) as j)
  insert into merge_audit (dup_id, keep_id, tbl, action, row_data) select dup_id, keep_id, 'customer_tags', 'deleted', j from d;
  with d as (
    delete from payment_methods p using merge_map m
     where p.customer_id = m.dup_id
       and exists (select 1 from payment_methods k where k.customer_id = m.keep_id and k.fingerprint = p.fingerprint)
    returning m.dup_id, m.keep_id, to_jsonb(p) as j)
  insert into merge_audit (dup_id, keep_id, tbl, action, row_data) select dup_id, keep_id, 'payment_methods', 'deleted', j from d;
  --    two default addresses: the survivor's stays default
  with d as (
    update addresses a set is_default = false from merge_map m
     where a.customer_id = m.dup_id and a.is_default
       and exists (select 1 from addresses k where k.customer_id = m.keep_id and k.is_default)
    returning m.dup_id, m.keep_id, a.id)
  insert into merge_audit (dup_id, keep_id, tbl, action, row_data)
    select dup_id, keep_id, 'addresses', 'demoted', jsonb_build_object('id', id) from d;

  -- 3. repoint every referencing table the catalog knows about
  for r in select * from customer_refs loop
    execute format(
      'with moved as (update %1$s t set %2$I = m.keep_id from merge_map m where t.%2$I = m.dup_id
                      returning m.dup_id, m.keep_id, jsonb_build_object(%3$s) as k)
       insert into merge_audit (dup_id, keep_id, tbl, action, row_data)
       select dup_id, keep_id, %4$L, ''moved'', k from moved',
      r.tbl, r.col,
      (select string_agg(format('%L, t.%I', c, c), ', ') from unnest(r.key_cols) c),
      r.tbl::text);
    get diagnostics n = row_count;
    raise notice '% rows of % moved', n, r.tbl;
  end loop;

  -- 4. the duplicate customer rows go last, kept whole in the audit
  with d as (delete from customers c using merge_map m where c.id = m.dup_id returning m.dup_id, m.keep_id, to_jsonb(c) as j)
  insert into merge_audit (dup_id, keep_id, tbl, action, row_data) select dup_id, keep_id, 'customers', 'customer', j from d;
end $$;

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:

text
PostgreSQL 18.6, 5,000 pairs, 1,000,000 orders          rows
orders moved                                          25,120
addresses moved                                        6,459
addresses demoted (two defaults)                       5,000
customer_tags moved / deleted (same tag)          7,953 / 2,047
payment_methods moved / deleted (same card)       3,540 / 1,460
loyalty rows added into the survivor's                 5,000
reviews moved                                          2,506
customers deleted                                      5,000

time, alone                                           1.23 s
WAL written                                          53.6 MB
undo (below)                                          0.73 s
Pilotbase query on merge_audit for duplicate 195004 merged into 48925: loyalty points 3264, tags us and eu deleted, a saved card deleted, the default address demoted and moved, and the customer's orders moved one by one

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:

sql
  -- 3. repoint every referencing table, with dynamic SQL from the catalog
  open refs;
  repoint: loop
    fetch refs into v_tbl, v_col, v_keys;
    if done then leave repoint; end if;
    set @sql = concat('insert into merge_audit (dup_id, keep_id, tbl, action, row_data) select m.dup_id, m.keep_id, ''',
                      v_tbl, ''', ''moved'', json_object(', coalesce(v_keys, ''), ') from ', v_tbl,
                      ' t join merge_map m on t.', v_col, ' = m.dup_id');
    prepare s from @sql; execute s; deallocate prepare s;
    set @sql = concat('update ', v_tbl, ' t join merge_map m on t.', v_col, ' = m.dup_id set t.', v_col, ' = m.keep_id');
    prepare s from @sql; execute s; deallocate prepare s;
  end loop;
  close refs;

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:

sql
create or replace procedure unmerge_customers()
language plpgsql as $$
declare r record;
begin
  insert into customers overriding system value
    select (jsonb_populate_record(null::customers, row_data)).* from merge_audit where action = 'customer';
  for r in select * from customer_refs loop
    execute format(
      'update %1$s t set %2$I = a.dup_id from merge_audit a
        where a.tbl = %3$L and a.action = ''moved'' and t.%2$I = a.keep_id and %4$s',
      r.tbl, r.col, r.tbl::text,
      coalesce((select string_agg(format('t.%1$I::text = a.row_data ->> %1$L', c), ' and ') from unnest(r.key_cols) c), 'true'));
  end loop;
  update addresses t set is_default = true from merge_audit a
   where a.tbl = 'addresses' and a.action = 'demoted' and t.id = (a.row_data ->> 'id')::bigint;
  insert into customer_tags select (jsonb_populate_record(null::customer_tags, row_data)).*
    from merge_audit where tbl = 'customer_tags' and action = 'deleted';
  insert into payment_methods overriding system value
    select (jsonb_populate_record(null::payment_methods, row_data)).* from merge_audit where tbl = 'payment_methods' and action = 'deleted';
  update loyalty k set points = k.points - (a.row_data ->> 'points')::int
    from merge_audit a where a.action = 'points' and k.customer_id = a.keep_id;
  insert into loyalty select (jsonb_populate_record(null::loyalty, row_data)).* from merge_audit where action = 'points';
  delete from merge_audit;
end $$;

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:

text
longest wait during a ~1 s merge            PostgreSQL 18.6              MySQL 8.4.11
survivor locked with:                     FOR UPDATE  NO KEY UPDATE    FOR UPDATE  FOR SHARE
new order for a survivor                    1,003 ms       0.5 ms      1,115 ms      5.0 ms
update to a survivor's own row                  -        995 ms      1,100 ms      1,153 ms
new order for an unrelated customer          0.7 ms       0.6 ms        6.4 ms      5.0 ms
- : not measured; all four connections were already waiting on an order for a survivor

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:

text
2.5% of requests for duplicate ids            PostgreSQL 18.6    MySQL 8.4.11
merge time                                            1.0 s           1.2 s
connections stuck behind a duplicate's lock          4 of 4          4 of 4
longest wait                                        975 ms        1,145 ms
writes failing in the second after the merge           915             456
  (foreign key: customer no longer exists)

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:

text
batches of 500 (10 transactions)              PostgreSQL 18.6    MySQL 8.4.11
total time                                           2.95 s          2.31 s
longest batch                                        0.30 s          0.32 s
longest wait for any request                         285 ms          303 ms

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

text
1. pairs into merge_map; one survivor per group; no chains
2. list referencing tables from the catalog (pg_constraint / KEY_COLUMN_USAGE), note ON DELETE CASCADE
3. list unique indexes that include the customer column; write a rule for each collision
4. one transaction per batch: lock duplicates FOR UPDATE, survivors FOR NO KEY UPDATE / FOR SHARE (id order)
5. settle collisions, then repoint every table, then delete the duplicates; audit every change
6. run the undo on a copy and compare table hashes before you trust it
7. keep merge_map (or a merged_into tombstone) so late requests for old ids still land

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 Pilotbase

Exported the tables before and after a merge? csvDiff shows which rows were added, removed or changed, in your browser.

Open csvDiff

Sources: 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.