Contents

Data, SQL and Getting to the Answer

Lesson 49 of 66. Sign in to keep your place.

Module 5 · Data that arrives dirty

Duplicates: finding them, and deciding which row survives

8 min read

Two kinds of duplicate, and what each tool does with themSELECT DISTINCTrow_number = 1Identical rowsCollapsedreal repeats die tooOne keptby a rule you can stateSame key onlyBoth survivethe retry staysOne keptnewest, ties by idDISTINCT keeps any row that differs in any column, so a retry that differs by one timestamp surviveswhile two genuinely separate payments that happen to match are merged. It is wrong in both directionsat once. A ranking with a stated ordering is a decision you can explain.
DISTINCT keeps any row that differs in any column, so a retry that differs by one timestamp survives while two genuinely separate payments that happen to match are merged. It is wrong in both directions at once. A ranking with a stated ordering is a decision you can explain.

Figure

Three kinds of duplicate

The earlier cleaning lesson made the point that whether two rows are “the same” is a business question. This lesson is about the mechanics once the question is answered, and there are three different mechanics because there are three different kinds of duplicate.

Exact duplicates are rows identical in every column. They come from a file pasted twice or a job that ran twice. Key duplicates share the identifying columns and differ elsewhere: the same payment reference with two timestamps, because a retry recorded a second row. Near duplicates share nothing exactly and are still the same thing: ada@example.com and Ada@Example.com, or +91 98765 43210 and 9876543210.

Finding exact and key duplicates

SQL
-- exact: group by everything
SELECT reference, paid_on, amount, city, count(*)
FROM raw_payments
GROUP BY reference, paid_on, amount, city
HAVING count(*) > 1;

-- by key: group by what should be unique
SELECT reference, count(*), min(loaded_at), max(loaded_at)
FROM raw_payments
GROUP BY reference
HAVING count(*) > 1;

The second query’s min and max of the load time tell you whether the duplicates came in one file or across two, which is usually the whole diagnosis. Look at ten of them before deciding anything.

Keeping one, by a rule you can state

The rule is nearly always “the latest” or “the first”, and a window function applies it:

SQL
CREATE VIEW payments_deduped AS
SELECT reference, paid_on, amount, city
FROM (
  SELECT *,
         row_number() OVER (PARTITION BY reference
                            ORDER BY loaded_at DESC, ctid DESC) AS rn
  FROM raw_payments
) t
WHERE rn = 1;

Within each reference, rows are numbered from the most recently loaded, and only number 1 survives. The ctid tiebreaker (the physical row address in Postgres; use a serial id elsewhere) makes the choice deterministic when two rows share a load time, which the ranking lesson explained is not optional. DISTINCT cannot do this job, because it keeps rows that differ in any column and has no notion of “latest”.

The rows that did not survive are WHERE rn > 1, and a count of them, by load file, goes into the load log. A deduplication that cannot say how many rows it removed is a filter, not a decision.

Deleting in place

If the duplicates are in a real table rather than a raw staging table, and the decision is to remove them permanently:

SQL
DELETE FROM payments
WHERE id IN (
  SELECT id FROM (
    SELECT id, row_number() OVER (PARTITION BY reference ORDER BY created_at DESC, id DESC) AS rn
    FROM payments
  ) t WHERE rn > 1
);

Run the inner SELECT first and count it. Then run the DELETE inside a transaction, compare the row count it reports with that count, and only then commit. The module on changing data safely has the full discipline; the short version is that a DELETE without a rehearsal is how a year of payments disappears.

Near duplicates

Normalising the key, as the previous lesson showed, turns most near duplicates into exact ones: lower-case the email, strip the phone to digits, and the GROUP BY finds them. What remains is fuzzy: Priya Sharma and Priya Sharmaa, Bombay Traders and Bombay Trading Co. Postgres’s pg_trgm extension gives similarity(a, b), a score from 0 to 1 based on shared three-letter fragments, and levenshtein(a, b) from fuzzystrmatch counts the edits between them.

SQL
SELECT a.name, b.name, similarity(a.name, b.name)
FROM customers a JOIN customers b ON a.id < b.id
WHERE similarity(a.name, b.name) > 0.6;

This is a self-join over every pair, which is a million comparisons for a thousand customers; on a hundred thousand it needs the extension’s index or a blocking step that only compares names sharing a first letter and a city. And the threshold is a judgement with a cost on both sides. Set it high and Sharma and Sharmaa stay apart; set it low and two different people called Priya Sharma are merged, which loses a customer’s history into somebody else’s and is far harder to undo than a missed match. Review a sample of the proposed merges by hand, log every merge with both original ids, and merge fewer rather than more.

The habit

Say the rule out loud before the query: “one row per reference, the most recently loaded one wins, ties broken by id”. If you cannot say the rule, you are not deduplicating; you are guessing with a window function.

Every lesson here is open. Your progress is recorded once you pass the Building Apps with AI exam.

Check your understanding

A team removes duplicate payments with SELECT DISTINCT * FROM raw_payments. Retried payments, which share a reference but differ by a few seconds in paid_at, all survive. Why, and what should replace it?

Pick the one you would defend. Your answer stays private.

Next lesson · 8 minAssertions: queries that return no rows when everything is right

Stuck on something?Ask Divya, your AI tutor

How to cite: Addaly (2026) ‘Duplicates: finding them, and deciding which row survives’, in Data, SQL and Getting to the Answer. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/data-and-sql/duplicates-and-which-to-keep (Accessed: 13 October 2026).