← All Kits · SQL Kit · All guides

How to Find Duplicate Rows in SQL (and Decide What Counts as One)

Define the duplicate first, then one GROUP BY finds every copy · Written to be listened to · Part of the Analyst Prep Kit

By the end of this page you can check any table for duplicate rows, list every copy, and mark which one to keep, all with queries you understand. You will also know the step that comes before any query: deciding what "duplicate" means for this table, because two rows can match on everything or on one column, and those are different problems with different fixes. It is about twenty minutes.

Here is what to actually do with it. On the next table you are handed, run one comparison before anything else: COUNT(*) against COUNT(DISTINCT key). If the two numbers differ, the table has duplicates, and now you know before your first report does.

The short version: group by the columns that define a duplicate, keep the groups where COUNT(*) is above one, and mark extras with ROW_NUMBER instead of deleting them.

One picture carries the whole method. Rows that share a key collapse into buckets, and the buckets holding more than one row are your duplicates.

Grouping exposes the duplicates A left-to-right picture in three stages. Stage one is a column of eight row boxes. Three of them carry the same small square marker, and two others share a different marker, showing that they hold the same key value. Stage two shows the rows collapsed into five buckets: one bucket holds the three matching rows, one holds the two matching rows, and three buckets hold a single row each. The two buckets holding more than one row are outlined in a warm warning color and labelled with their counts, three and two. Stage three shows only those two flagged buckets passing through to the result. The picture shows that grouping rows by their key makes every duplicate visible as a bucket whose count is above one, while unique rows form buckets of one and drop away. rows groups count > 1 3 2 1 1 3 copies 2 copies
Group rows by the columns that define a duplicate. Buckets of one are unique and fall away. Buckets above one are your duplicates, and the count says how many copies.
What you'll learn
  1. Decide what counts as a duplicate before you write anything
  2. The ten-second test on any table
  3. List the duplicates with GROUP BY and HAVING
  4. See the full duplicate rows, not just the summary
  5. Mark keepers and extras with ROW_NUMBER
  6. The delete pattern, stated once, with the guard rails
  7. The full before and after
  8. Edge cases that make duplicate hunts go wrong
  9. Why this works
  10. Using this on your own project
  11. The whole thing on one screen
The worked example is real. Every number on this page comes from a 14-row customers table I built with three duplicates seeded on purpose, and every query was run against it in SQLite before its output was pasted here. The table is small enough to check by eye, which is the point: you can confirm every result yourself. If GROUP BY itself is new, read GROUP BY and HAVING first and come back.

Here is the table. Fourteen rows, and the signup system has misbehaved in three different ways.

customer_idemailfull_namecitysignup_date
101ana@keller.comAna KellerAustin2026-01-04
102ben.ortiz@mail.comBen OrtizDallas2026-01-09
103cara.li@mail.comCara LiAustin2026-01-15
104dev.p@mail.comDev PatelHouston2026-01-20
105ella.r@mail.comElla ReyesDallas2026-02-02
106finn.w@mail.comFinn WalshAustin2026-02-11
103cara.li@mail.comCara LiAustin2026-01-15
107gus.m@mail.comGus MoranHouston2026-02-19
108hana.s@mail.comHana SatoDallas2026-02-25
105ella.r@mail.comElla ReyesFort Worth2026-03-01
109ivan.k@mail.comIvan KovaAustin2026-03-06
103cara.li@mail.comCara LiAustin2026-01-15
110jo.b@mail.comJo BrandtHouston2026-03-14
111ben.ortiz@mail.comBenjamin OrtizDallas2026-03-20

1. Decide what counts as a duplicate before you write anything

Before the explanation: customer 103 appears three times with every column identical. Customer 105 appears twice with two different cities. Ben Ortiz's email appears under two different ids. Which of those three are duplicates?

That question has no single answer, and that is the real first step of any duplicate hunt. "Duplicate" is not a property of the data. It is a decision you make about which columns have to match before two rows mean the same thing. There are three common answers, and each one leads to a different query and a different cleanup.

So the fork is: which columns define "the same"? All of them means you are hunting loading accidents. The natural key means you are hunting conflicting versions. Neither means you may be hunting people, and that is a different tool. Everything below works for the first two, and the queries only differ in what you put after GROUP BY.

2. The ten-second test on any table

Before the explanation: without looking back at the table, how would you get one number that says whether any id appears twice?

Count the rows two ways. COUNT(*) counts every row. COUNT(DISTINCT customer_id) counts how many different id values exist. If every id appears once, the two numbers match. Any gap between them is the number of extra copies.

SELECT COUNT(*)                    AS total_rows,
       COUNT(DISTINCT customer_id) AS distinct_ids
FROM customers;
total_rowsdistinct_ids
1411

Fourteen rows, eleven distinct ids, so three rows are extra copies of something. This is the query I run on every table anyone hands me, before any join and before any report, because it takes about ten seconds and it changes what I trust. It does not tell you which rows are the copies. It tells you whether the hunt is needed at all, and 14 against 11 says yes.

Run the test on the join key especially. If you are about to join on customer_id and this table has 14 rows for 11 ids, the join will multiply rows and every count downstream will be quietly wrong. Ten seconds here saves an afternoon there.

3. List the duplicates with GROUP BY and HAVING

Before the explanation: the test says three extra rows exist. What would you group by to find out which customers they belong to?

Group by the columns from your step-one decision, count each bucket, and keep only the buckets with more than one row. HAVING is the clause that filters groups after they are formed, which is exactly the moment the count exists.

SELECT customer_id, COUNT(*) AS copies
FROM customers
GROUP BY customer_id
HAVING COUNT(*) > 1
ORDER BY copies DESC;
customer_idcopies
1033
1052

Two customers, five rows between them, three of which are extras. That accounts exactly for the gap in step two: 14 rows minus 11 ids is 3, and here they are, two extra copies of 103 plus one extra of 105.

To hunt whole-row duplicates instead, put every column in the GROUP BY. Now a bucket only forms when rows match on everything.

SELECT customer_id, email, full_name, city, signup_date,
       COUNT(*) AS copies
FROM customers
GROUP BY customer_id, email, full_name, city, signup_date
HAVING COUNT(*) > 1;
customer_idemailfull_namecitysignup_datecopies
103cara.li@mail.comCara LiAustin2026-01-153

Notice what changed. Customer 105 vanished from this result, because her two rows differ on city, so they never land in the same bucket. Say why the two queries disagree about 105, in your own words, before reading on. If you can explain it, you have the whole method: the GROUP BY list is your definition of duplicate, written as code.

4. See the full duplicate rows, not just the summary

Before the explanation: the summary says customer 105 has two copies, but to decide which to keep you need to see both rows side by side. The grouped result cannot show them, because grouping collapsed them. How do you get them back?

Use the summary as a shopping list. First find the ids with duplicates, then pull every row whose id is on that list. IN is the plainest way to say it.

SELECT *
FROM customers
WHERE customer_id IN (
    SELECT customer_id
    FROM customers
    GROUP BY customer_id
    HAVING COUNT(*) > 1
)
ORDER BY customer_id, signup_date;
customer_idemailfull_namecitysignup_date
103cara.li@mail.comCara LiAustin2026-01-15
103cara.li@mail.comCara LiAustin2026-01-15
103cara.li@mail.comCara LiAustin2026-01-15
105ella.r@mail.comElla ReyesDallas2026-02-02
105ella.r@mail.comElla ReyesFort Worth2026-03-01

Five rows, and now the two problems look as different as they are. Customer 103 is three identical rows: a loading accident, nothing to decide. Customer 105 is two versions of one person: Dallas in February, Fort Worth in March, and someone has to say which city is true. A join back to the same summary gives the identical result, and I ran both to confirm: the same five rows either way. Use whichever reads better to you; SQL joins covers the join form.

5. Mark keepers and extras with ROW_NUMBER

Before the explanation: you can now see all five duplicate rows. What single column, added to the table, would let anyone else act on them without redoing your work?

A copy number. ROW_NUMBER() is a window function, which means it computes a value for every row without collapsing anything. PARTITION BY customer_id restarts the numbering for each customer, and ORDER BY signup_date decides who gets number one. So copy number 1 is your keeper and everything above 1 is an extra, by a rule you wrote down.

SELECT customer_id, city, signup_date,
       ROW_NUMBER() OVER (
           PARTITION BY customer_id
           ORDER BY signup_date
       ) AS copy_number
FROM customers
ORDER BY customer_id, copy_number;

The duplicated customers come back numbered like this.

customer_idcitysignup_datecopy_number
103Austin2026-01-151
103Austin2026-01-152
103Austin2026-01-153
105Dallas2026-02-021
105Fort Worth2026-03-012

Every other customer simply gets copy number 1. Wrap it in a subquery and keep copy_number > 1 and you have exactly the three extras, which I ran to confirm: two extra rows for 103 and the Fort Worth row for 105.

Here is the fork on keep-versus-delete, because it is a real decision. Deleting extras makes the table clean but destroys the evidence, and if your keeper rule was wrong, the right row is gone. Marking extras keeps every row and adds a column that says which one the reports should use. Marking costs one column. Deleting costs the ability to change your mind. That is why working analysts mark first and delete rarely: filter reports to copy_number = 1 and you get every benefit of the delete with none of the risk. Notice the marking rule is visible in the query: for 105, ORDER BY signup_date made the newer Fort Worth row the extra. If the business says newest wins, flip the order to signup_date DESC and the keeper flips too. The rule is code, so it can be reviewed and argued with.

The window function family goes much further than this one trick, and window functions walks through it properly. If you want the pattern in your fingers rather than your bookmarks, type the query yourself a few times in the SQL Drill.

6. The delete pattern, stated once, with the guard rails

Sometimes the answer really is to remove rows, usually for whole-row loading accidents like customer 103. Here is the pattern, once. In SQLite every row has a hidden rowid, so keep the smallest rowid per customer and delete the rest. Other databases use ctid, a system id, or a ROW_NUMBER subquery, but the shape is the same.

DELETE FROM customers
WHERE rowid NOT IN (
    SELECT MIN(rowid)
    FROM customers
    GROUP BY customer_id
);

I ran it on the worked table, and the counts after were 11 rows and 11 distinct ids: the ten-second test now passes. Two guard rails, and they are not optional.

7. The full before and after

Same table, same question: which customers have duplicate rows, and which copy should stand?

Before

SELECT customer_id, COUNT(*)
FROM customers
GROUP BY customer_id
HAVING COUNT(*) > 1;

Correct, and not enough to act on. It names 103 and 105 but shows none of their rows, states no keeper rule, and gives the next person nothing to review.

After

-- ============================================================
-- STEP 2: Which customer rows are duplicates, and which stands?
-- WHY: 14 rows but only 11 distinct customer_ids, so 3 rows
--      are extra copies. Reports must count customers once.
-- RULE: keeper = earliest signup_date per customer_id.
--      105's city conflict (Dallas vs Fort Worth) goes to the
--      data owner; nothing is deleted here.
-- ============================================================
--SELECT each customer row with its copy number:
--   ROW_NUMBER restarts at 1 for every customer_id,
--   ordered by signup_date, so copy_number 1 = keeper
SELECT customer_id, email, full_name, city, signup_date,
       ROW_NUMBER() OVER (
           PARTITION BY customer_id
           ORDER BY signup_date
       ) AS copy_number
FROM customers
ORDER BY customer_id, copy_number;

Every row is present, the keeper rule is written where a reviewer can disagree with it, the known conflict is named, and downstream queries just filter to copy_number = 1. The comment format is the one from how to comment SQL so it teaches.

Now picture running the marking query on your own biggest table, partitioned by whatever its natural key is. Which column would you put in the ORDER BY to decide the keeper, and could you defend that choice to the person who owns the data?

8. Edge cases that make duplicate hunts go wrong

Before the explanation: your duplicate query returns zero rows, but a report downstream still double-counts customers. What are the ways both of those can be true at once?

Five that each cost someone real time.

You grouped by the wrong definition. This is the zero-rows answer. Grouping by every column finds no duplicates in a table where the same id appears twice with different cities, exactly like customer 105. When the whole-row query comes back clean, run the natural-key version before declaring the table clean.

The duplicates are not in this table. A join can multiply rows even when both tables pass the ten-second test on their own keys, if you join on a column that is not unique in either table. When a report double-counts, test the join key on each side, not just the id.

Near-matches slip every exact grouping. Trailing spaces, capital letters, and spelling variants are all different values to GROUP BY. My table holds one of these on purpose: Ben Ortiz and Benjamin Ortiz. Grouping by email catches that pair, and I ran it: ben.ortiz@mail.com shows 2 copies under 2 different ids. But grouping by name never will, and true fuzzy matching is entity resolution, not a bigger GROUP BY.

NULLs group together. Two rows with a missing email land in the same bucket, because grouping treats all NULLs as one value. A bucket of blanks with a count of 40 is not 40 duplicates. It is 40 rows missing their key, which is a different finding worth reporting on its own.

Duplicates found during a migration are a symptom, not a chore. When profiling a source system before a migration turns up duplicates, the count is evidence about how the old system behaved: retried saves, merged offices, users working around a bug. Log where they came from before anyone cleans them, because the pattern predicts what else is wrong. Profiling data before a migration covers that workflow.

Why this works

The method leans on one property of grouping. Grouping partitions rows into disjoint sets by value, so two rows land in the same set exactly when they match on every grouped column. That makes the GROUP BY list a precise, executable statement of your duplicate definition, rather than a vague intention. The relational model has treated tables as sets of rows this way from the start (Codd, 1970, Communications of the ACM, 13(6), 377–387), which is also why exact matching is all a database gives you natively, and why same-person-different-spelling needs separate tooling.

The marking habit has a plainer justification: it keeps every decision reversible. The keeper rule lives in an ORDER BY that a reviewer can read and flip, and the extras stay on disk until someone with authority over the data says otherwise. A delete embeds the same rule invisibly and enforces it permanently.

There is also a reason this page keeps asking you to answer before it explains. Attempting an answer before seeing the solution measurably improves what you retain, even when your attempt is wrong (Bisra, Liu, Nesbit, Salimi, & Winne, 2018, Educational Psychology Review, 30(3), 703–725), and practicing retrieval outperforms rereading the same material (Roediger & Karpicke, 2006, Psychological Science, 17(3), 249–255). Deciding whether customer 105 counts as a duplicate, before I told you it depends, is why the three definitions will come back to you at a keyboard.

Using this on your own project

Auditing every table you own for duplicates in one sitting is miserable, and you will abandon it around table four. Do this instead, in order.

  1. Run the ten-second test on the one table your current report depends on most. COUNT(*) against COUNT(DISTINCT key). If they match, stop. You have your answer for today.
  2. If they differ, write down your duplicate definition in one sentence before touching the keyboard. "Same customer_id" and "identical row" lead to different queries, and picking one out loud stops you blending them.
  3. List the offenders with GROUP BY and HAVING, then pull the full rows back with IN. Read them. The rows themselves usually tell you which of the three problems you have.
  4. Mark keepers with ROW_NUMBER and a written rule. Filter reports to copy number 1. Do not delete anything this week.
  5. Take the conflict rows to whoever owns the data. Ella Reyes's two cities are not a query problem, and pretending otherwise just moves the error into your report.

If you have paper nearby and five minutes, one optional drawing locks the method in. Draw eight rows, give three of them a shared mark, collapse the rows into buckets, and write each bucket's count beside it. Circle the buckets above one. Redrawing that from memory is a fair test of whether you own the idea.

More detail on this, and more like it. Every how-to sits in one place on the guides index: SQL, Tableau, data migration, and the working habits around them.

The whole thing on one screen

This is the retrieval sheet. Cover the right column, work down the left, and say each answer out loud before you check it.

ConceptWhat it does
First stepDecide which columns define "the same". That decision picks the query.
Whole-row duplicateEvery column matches. Usually a loading accident. Safe to remove once marked.
Natural-key duplicateSame real-world id, other columns differ. Someone must pick the true version.
Same person, different spellingNo exact match exists. That is entity resolution, a separate tool.
The ten-second testCOUNT(*) vs COUNT(DISTINCT key). Any gap is the number of extras.
List duplicatesGROUP BY key HAVING COUNT(*) > 1. The GROUP BY list is your definition.
See the full rowsWHERE key IN (the summary), or join back to it. Same rows either way.
Mark keepersROW_NUMBER() OVER (PARTITION BY key ORDER BY rule). Copy 1 keeps, above 1 is extra.
Why mark, not deleteMarking is reversible and the keeper rule stays visible. Deleting is neither.
The delete patternKeep MIN(rowid) per key, delete the rest. SELECT it first. Keep the original table.
Zero duplicates foundCheck the other definition before declaring the table clean.
Report double-counts anywayTest the join key on both sides. Joins multiply rows that each look clean alone.
NULL keysAll NULLs share one bucket. That count is missing data, not duplicates.
During a migrationDuplicates are evidence about the source system. Log them before cleaning them.
The one habit to keep. If you take nothing else from this page, run COUNT(*) against COUNT(DISTINCT key) on every table before you trust a number from it. Ten seconds, and it is the difference between finding the duplicates and having your stakeholder find them. If a duplicate hunt breaks in a way this page does not cover, there is a general diagnosis loop for being stuck.

One last thought, and I would genuinely like other people's answers. My favorite duplicate ever found was a customer who existed twice because two offices both swore they had onboarded her first. What is the strangest reason a duplicate has turned up in your data, and did the query find it or did a person?

References

The row count is too high and every row looks unique on screen.

SQL for Analysts is 458 pages, GROUP BY and HAVING used to find what is duplicated before it reaches a total.

SQL for Analysts, $19 →
Practice the pattern with real tables in front of you.

The SQL Kit runs SQLite in the browser, so you can seed duplicates, hunt them down, and watch the counts change as you go. Worked examples, flash cards, and a mock exam sit alongside it. Nothing to install, no account needed.

Open the SQL Kit →