Find them with GROUP BY on the columns that define a duplicate and HAVING COUNT(*) > 1. Delete every copy but one with DELETE FROM songs WHERE id NOT IN (SELECT MIN(id) FROM songs GROUP BY title, artist), which keeps the lowest id in each group. In MySQL, wrap that subquery in a derived table to avoid error 1093.
Duplicates usually arrive from an import that ran twice or a form submitted twice. Cleaning them up is three steps: decide which columns make two rows "the same", look at the copies, then delete all but one per group. Every example uses the same small songs table, where the same title by the same artist counts as a duplicate. The examples run on SQLite; the notes say where MySQL, PostgreSQL or SQL Server need something different. Each one runs on this page: hit Run, then edit the query and run it again.
1Find duplicate values with GROUP BY and HAVINGRecommended
GROUP BY title folds every row with the same title into one group, and COUNT(*) counts the rows in it. WHERE can't filter on an aggregate, because it runs before the grouping, so the filter goes in HAVING, which runs after it: HAVING COUNT(*) > 1 keeps only the groups with more than one row.
Output
Both Hello and Help! show 3 copies, and Hurt drops out because it appears once. But the Hello count is misleading: one of the three is the Oasis song, a different record that only shares the title. Grouping by one column is right when that column alone must be unique (a product code, a username); for this table a duplicate is the same title by the same artist, which is the next section. This query runs unchanged in PostgreSQL, MySQL and SQL Server.
2Duplicates across several columns
List every column that has to match in the GROUP BY, and put the same columns in the SELECT so you can see which combination repeats. Wrapping the query in a subquery and adding up copies - 1 tells you how many rows a cleanup will remove, since one row per group stays.
Output
Now Hello by Adele has 2 copies and Help! by The Beatles 3, while the Oasis row is on its own and no longer counts. extra_rows is 3: one surplus Adele row plus two surplus Beatles rows. Leave the id out of the GROUP BY: it is different on every row, so including it would put each row in a group of its own. For more on grouping by a column list, see GROUP BY multiple columns.
3List every duplicate row, not just the groups
GROUP BY collapses each group into one line, so it can't show you the ids you are about to delete. A window function counts the same groups without collapsing anything: COUNT(*) OVER (PARTITION BY title, artist) adds each row's group size as a column, and an outer query keeps the rows where it is above 1. The second query is the older form, for databases without window functions: join the table back to the duplicate groups.
Output
Both queries return the same five rows: ids 2 and 5 for Adele, and 1, 3 and 6 for The Beatles. The window version can't filter on copies in its own WHERE (window functions are computed after WHERE), which is why it sits in a subquery. The join version has a trap with NULL: d.artist = s.artist is never true when both are NULL, so a group of rows with no artist is found by the GROUP BY but then dropped by the join. The window version doesn't have that problem.
4Delete duplicates, keeping the lowest id
To delete all copies but one, pick a survivor in each group and delete everything else. The portable way: SELECT MIN(id) … GROUP BY title, artist returns one id per group, the oldest row if ids only go up, and DELETE … WHERE id NOT IN (…) removes every row that isn't on that list. Rows that were never duplicated are their own group, so their id is on the list too and they stay.
Output
changes() (SQLite's affected-row count) reports 3 deleted rows, the same number section 2 predicted. One row per song is left: ids 1, 2, 4 and 7. Use MAX(id) instead to keep the newest insert. Before running a delete like this on real data, run the subquery or the section 3 listing first, or do it inside a transaction you can roll back. The same statement runs in PostgreSQL and SQL Server.
MySQL rejects it with error 1093, You can't specify target table 'songs' for update in FROM clause, because the subquery reads the table being deleted from. Wrapping the subquery in a derived table makes MySQL build the id list first, and then it works: WHERE id NOT IN (SELECT keep_id FROM (SELECT MIN(id) AS keep_id FROM songs GROUP BY title, artist) AS keepers). MySQL also has a self-join form, DELETE s1 FROM songs AS s1 JOIN songs AS s2 ON s2.title = s1.title AND s2.artist = s1.artist AND s2.id < s1.id, which deletes every row that has a lower-id twin; see delete using a join for that syntax in each database. Both MySQL forms deleted the same 3 rows on MySQL 8.4.
5Choose which copy to keep with ROW_NUMBER()
MIN(id) only keeps the first row inserted. When the survivor should be the newest, the most complete, or picked by any other rule, number the copies in each group with ROW_NUMBER() OVER (PARTITION BY … ORDER BY …). The ORDER BY inside the window decides which row gets number 1, and that is the one you keep: delete every row numbered 2 or more. Here the table has an added date, and the newest copy wins.
Output
This time Adele keeps id 5 (added 2026-03-01) rather than id 2, because the newest copy ranks first; for The Beatles the newest happens to be id 1. The id DESC after added DESC breaks ties, so two copies added the same day still get distinct numbers and exactly one survives. This exact statement ran unchanged on PostgreSQL, MySQL 8.4 and SQL Server (Babelfish), and MySQL raises no error 1093 for it. SQL Server also lets you delete straight from the CTE (DELETE FROM ranked WHERE rn > 1); that shortcut is SQL Server only, PostgreSQL rejects it, and Babelfish, the SQL Server stand-in used to test this page, doesn't support it, so it wasn't run here. The same ROW_NUMBER() pattern with rn = 1 is how you select the first row in each group.
6Which should you use?
| Method | Keeps | Best for |
|---|---|---|
| GROUP BY … HAVING COUNT(*) > 1 | Read only | Finding which values repeat, and how often |
| COUNT(*) OVER (PARTITION BY …) | Read only | Seeing every copy with its id before you delete |
| DELETE … WHERE id NOT IN (SELECT MIN(id) …) | Lowest id | The portable cleanup; MySQL needs the derived-table wrap |
| ROW_NUMBER() OVER (…) in a CTE | Any rule | Keeping the newest or best copy instead of the first |
| DELETE s1 FROM t s1 JOIN t s2 … | Lowest id | MySQL code that already uses multi-table DELETE |
| CREATE UNIQUE INDEX | Prevents them | Making sure duplicates never come back |
7Prevent new duplicates with a UNIQUE index
A cleanup only fixes the past. Once each group is down to one row, a UNIQUE index on the same columns makes the database refuse any insert or update that would create a duplicate again. Create it after the cleanup: on a table that still has duplicates, creating the index fails. To skip a duplicate quietly instead of failing, SQLite and PostgreSQL accept ON CONFLICT … DO NOTHING.
Output
The ON CONFLICT insert adds nothing, so the count stays at 3. The last insert fails with UNIQUE constraint failed: songs.title, songs.artist; PostgreSQL reports the same case as duplicate key value violates unique constraint "songs_title_artist". In your application, catch that error, or use an upsert when the new row should update the existing one instead; see insert or update if it exists.
Frequently asked questions
How do I find duplicate values in a column in SQL?
Group by the column and keep the groups with more than one row: SELECT title, COUNT(*) FROM songs GROUP BY title HAVING COUNT(*) > 1. For duplicates across several columns, list them all: GROUP BY title, artist. To see every duplicate row with its id, use COUNT(*) OVER (PARTITION BY title, artist) in a subquery and filter on it.
How do I delete duplicate rows but keep one?
Keep one id per group and delete the rest: DELETE FROM songs WHERE id NOT IN (SELECT MIN(id) FROM songs GROUP BY title, artist). MIN(id) keeps the oldest row and MAX(id) the newest. To keep a row by another rule, such as the latest date, number the copies with ROW_NUMBER() OVER (PARTITION BY title, artist ORDER BY added DESC) in a CTE and delete the rows numbered above 1.
Why does MySQL give error 1093 when I delete duplicates?
MySQL won't let a DELETE read the same table in a subquery of its WHERE clause, so NOT IN (SELECT MIN(id) FROM songs ...) fails with You can't specify target table 'songs' for update in FROM clause. Wrap the subquery in a derived table, NOT IN (SELECT keep_id FROM (SELECT MIN(id) AS keep_id FROM songs GROUP BY title, artist) AS keepers), or use MySQL's self-join DELETE s1 FROM songs AS s1 JOIN songs AS s2 ON ... AND s2.id < s1.id. Both work on MySQL 8.4, as does the ROW_NUMBER() CTE form.
How do I delete duplicates when the table has no id column?
Use the database's hidden row identifier. In SQLite every ordinary table has a rowid, so DELETE FROM plays WHERE rowid NOT IN (SELECT MIN(rowid) FROM plays GROUP BY title, artist) works. In PostgreSQL, use ctid with a self-join: DELETE FROM plays a USING plays b WHERE a.ctid > b.ctid AND a.title = b.title AND a.artist IS NOT DISTINCT FROM b.artist. In any database you can also copy the distinct rows into a new table with CREATE TABLE plays_clean AS SELECT DISTINCT title, artist FROM plays, drop the old table and rename the new one.
Are NULL values counted as duplicates?
In GROUP BY and PARTITION BY, yes: all NULLs land in one group, so two rows ('Hurt', NULL) show up as 2 copies and the MIN(id) delete removes one of them. A join on a.artist = b.artist misses them, because NULL = NULL is not true; compare with IS NOT DISTINCT FROM instead. A UNIQUE index treats NULLs as different in SQLite and PostgreSQL, so it accepts two ('Hurt', NULL) rows; SQL Server allows only one. PostgreSQL 15+ can reject the second one with UNIQUE NULLS NOT DISTINCT.
How do I count how many duplicate rows there are?
Add up the surplus in each group: SELECT SUM(copies - 1) FROM (SELECT COUNT(*) AS copies FROM songs GROUP BY title, artist HAVING COUNT(*) > 1). That is the number of rows a keep-one cleanup deletes. For the sample table it is 3, matching the 3 rows the MIN(id) delete removes.
Run it yourself
Open any of these in the SQLite playground to change the data, run the queries and share them.
SQLite playground