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.

How to remove duplicate rows in SQL Server

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 deleted

Change 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

Popular posts from this blog

How to Read a SQL Server Execution Plan (Beginner's Guide)

SARGable Queries in SQL Server: Why Functions in WHERE Slow You Down

SQL Server Statistics Explained: Why Your Query Plans Go Wrong