How to Find and Remove Duplicate Rows in SQL Server
Duplicate rows creep in through double-clicked buttons, retried imports and missing constraints. Removing them in SQL Server is straightforward once you have a repeatable method: define what a duplicate is, choose which copy to keep, delete the rest safely, and then make sure it cannot happen again.

Step 1: define what a duplicate is
Rows rarely match in every column, because each usually has its own ID and timestamp. Decide which columns make two rows "the same". For an employee import, that might be EmployeeCode alone; for payroll lines, perhaps PayrollRunId, EmployeeId and PayCode.
Step 2: count the duplicates
SELECT PayrollRunId, EmployeeId, PayCode, COUNT(*) AS copies
FROM dbo.PayrollLine
GROUP BY PayrollRunId, EmployeeId, PayCode
HAVING COUNT(*) > 1
ORDER BY copies DESC;This tells you how big the problem is before you change anything.
Step 3: see each duplicate with ROW_NUMBER
ROW_NUMBER numbers the copies within each group. The ORDER BY decides which one counts as number 1, the one you keep:
WITH Numbered AS (
SELECT
PayrollLineId, PayrollRunId, EmployeeId, PayCode, Amount, CreatedAt,
ROW_NUMBER() OVER (
PARTITION BY PayrollRunId, EmployeeId, PayCode
ORDER BY CreatedAt ASC, PayrollLineId ASC -- keep the oldest
) AS rn
FROM dbo.PayrollLine
)
SELECT *
FROM Numbered
WHERE rn > 1; -- the rows that would be deletedChange the ORDER BY to keep the newest (CreatedAt DESC) or the most complete row instead. Review this list before deleting anything, especially where the "duplicates" have different amounts.
Step 4: back up the rows you will delete
WITH Numbered AS ( /* same CTE as above */
SELECT *, ROW_NUMBER() OVER (
PARTITION BY PayrollRunId, EmployeeId, PayCode
ORDER BY CreatedAt, PayrollLineId) AS rn
FROM dbo.PayrollLine
)
SELECT *
INTO dbo.PayrollLine_Duplicates_Backup
FROM Numbered
WHERE rn > 1;If anyone questions the cleanup later, you can show exactly what was removed or put it back.
Step 5: delete through the CTE
In SQL Server you can delete directly from a CTE that references a single table, which deletes the matching rows in that table:
WITH Numbered AS (
SELECT ROW_NUMBER() OVER (
PARTITION BY PayrollRunId, EmployeeId, PayCode
ORDER BY CreatedAt, PayrollLineId) AS rn
FROM dbo.PayrollLine
)
DELETE FROM Numbered
WHERE rn > 1;Run it inside a transaction first if you want to check the row count before committing.
Step 6: batch it on large tables
If there are millions of duplicates, delete in batches to avoid long locks and log growth, for example with DELETE TOP (5000) in a loop against the same CTE, until no rows are affected.
Step 7: stop duplicates coming back
Cleanup without prevention means you will be doing this again next month. Add a unique constraint or unique index on the columns that define a duplicate:
CREATE UNIQUE INDEX UX_PayrollLine_Run_Employee_PayCode
ON dbo.PayrollLine (PayrollRunId, EmployeeId, PayCode);Now any attempt to insert a duplicate fails with an error, and the application can handle it, for example by updating the existing row instead. Making imports and API calls idempotent (safe to retry) is the long-term fix.
Exact duplicates with no ID column
If a table has no key at all and rows match in every column, the same ROW_NUMBER approach still works: partition by all the columns and order by any of them. Afterwards, add a primary key.
Quick checklist
- Agree which columns define a duplicate.
- Count, then list the rows that will be removed.
- Choose which copy to keep with
ORDER BY. - Back up, then delete through a CTE (in batches if large).
- Add a unique index so it cannot happen again.
Related reading:
Comments
Post a Comment