SQL Server: Find and Remove Duplicate Rows with ROW_NUMBER()
Preview duplicate rows, keep one per group, and delete the rest safely.
- Updated
- Requires
- SQL Server 2008+
- Markdown
Your values
Type your own names and every code block on this page updates, including what Copy copies.
Problem
A table has duplicate rows (for example the same email imported twice) and you need to keep one row per group and delete the others, without guessing which rows will go.
Solution
Number the rows in each duplicate group with ROW_NUMBER(). Row 1 is kept; everything above 1 is deleted. Preview first, then delete through the same CTE.
-- 1) Preview every duplicate group (rn = 1 is the row that will be kept)
WITH ranked AS (
SELECT
t.*,
ROW_NUMBER() OVER (
PARTITION BY t.email -- column(s) that define a duplicate
ORDER BY t.created_at, t.id -- the first row in each group is kept
) AS rn,
COUNT(*) OVER (PARTITION BY t.email) AS copies
FROM target_table t
)
SELECT *
FROM ranked
WHERE copies > 1
ORDER BY email, rn;
-- 2) Delete everything except the first row in each group
BEGIN TRAN;
WITH ranked AS (
SELECT
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY created_at, id
) AS rn
FROM target_table
)
DELETE FROM ranked
WHERE rn > 1;
SELECT @@ROWCOUNT AS rows_deleted;
-- COMMIT;
-- ROLLBACK;
-- 3) Stop it happening again
CREATE UNIQUE INDEX UX_target_table_email
ON target_table (email)
WHERE email IS NOT NULL;
Notes
- Always end the
ORDER BYwith a unique column (likeid). Otherwise ties are broken randomly and the preview may not match what’s deleted. - Use the same
PARTITION BYandORDER BYin both steps. - Rows where the partition column is
NULLare grouped together and treated as duplicates of each other. AddWHERE email IS NOT NULLto both steps if that’s not what you want. - Comparisons follow the column’s collation: under the default case-insensitive collation,
A@x.comanda@x.comare duplicates. To also ignore stray spaces, partition byLTRIM(RTRIM(email)). - If other tables reference the duplicates through foreign keys, repoint those rows to the kept row before deleting.
- To keep a copy of what you delete, see Record Changed Rows with OUTPUT INTO.
Was this useful?
Thanks. What was the problem?