Delete Duplicate Rows in SQL Server and Keep One Copy

Delete duplicate rows by numbering the rows in each group and removing every row after the first. In SQL Server that takes one short statement. The real work is deciding what counts as a duplicate and which copy survives.

Gouache painting of a shelf of paired teacups with the extra twins gathered in a small red crate below

Find the Duplicates Before You Touch Anything

The demo is a customer contacts table with both kinds of trouble. Maya Lopez was typed three times, once with a leading space and a new city. Noah Kim was typed twice, identically. Sam Rivera appears twice, once with capital letters. The script creates a database named DuplicateDemo for this post only, so run it on a test server.

IF DB_ID(N'DuplicateDemo') IS NULL CREATE DATABASE DuplicateDemo;
GO
USE DuplicateDemo;
GO
DROP TABLE IF EXISTS dbo.Contacts;
CREATE TABLE dbo.Contacts (
    ContactID int IDENTITY(1,1) NOT NULL PRIMARY KEY,
    FullName  nvarchar(80)  NOT NULL,
    Email     nvarchar(120) NOT NULL,
    City      nvarchar(60)  NOT NULL,
    CreatedOn date          NOT NULL
);
INSERT INTO dbo.Contacts (FullName, Email, City, CreatedOn)
VALUES (N'Maya Lopez', N'maya@example.com', N'Austin', '2026-01-10'),
       (N'Maya Lopez', N'maya@example.com', N'Austin', '2026-02-14'),
       (N'Maya Lopez', N' maya@example.com', N'Denver', '2026-03-02'),
       (N'Noah Kim', N'noah@example.com', N'Portland', '2026-01-20'),
       (N'Noah Kim', N'noah@example.com', N'Portland', '2026-01-20'),
       (N'Priya Shah', N'priya@example.com', N'Boston', '2026-02-01'),
       (N'Sam Rivera', N'sam@example.com', N'Chicago', '2026-02-11'),
       (N'Sam Rivera', N'SAM@example.com', N'Chicago', '2026-04-05');

To delete duplicate rows safely, first count them. The usual first look is GROUP BY with HAVING COUNT(*) > 1. That keeps only the groups that hold more than one row.

SELECT Email, COUNT(*) AS Copies
FROM dbo.Contacts
GROUP BY Email
HAVING COUNT(*) > 1
ORDER BY Email;
EmailCopies
maya@example.com2
noah@example.com2
sam@example.com2

Maya shows two copies, but there are three. This server compares text without caring about case, which is why SAM matched sam. A trailing space is ignored in a comparison too. A leading space counts, so the third Maya row slipped through.

Clean the key before you compare it. LOWER(TRIM(Email)) removes spaces on both sides and ignores case, even on a case-sensitive database. TRIM removes only the space character by default, so tabs and line breaks from pasted data need more work. It needs SQL Server 2017 or later.

SELECT LOWER(TRIM(Email)) AS CleanEmail, COUNT(*) AS Copies
FROM dbo.Contacts
GROUP BY LOWER(TRIM(Email))
HAVING COUNT(*) > 1
ORDER BY CleanEmail;
CleanEmailCopies
maya@example.com3
noah@example.com2
sam@example.com2

Preview the Rows That Will Go

ROW_NUMBER gives each row a number inside its group. PARTITION BY defines the group, one per clean email address. ORDER BY decides who gets number 1, and that row stays. Here the newest row wins, and the highest ContactID breaks a tie.

The query lives inside a CTE, short for common table expression. A CTE is a named query written at the top of a statement. Run it as a SELECT first. Every row it lists with a number above 1 is a row the delete would remove. Read that list slowly. A wrong rank in a SELECT costs nothing, and a wrong rank in a DELETE costs data.

WITH Ranked AS (
    SELECT ContactID, FullName, Email, City, CreatedOn,
           ROW_NUMBER() OVER (PARTITION BY LOWER(TRIM(Email)) ORDER BY CreatedOn DESC, ContactID DESC) AS KeepRank
    FROM dbo.Contacts
)
SELECT ContactID, FullName, Email, City, CreatedOn, KeepRank
FROM Ranked
WHERE KeepRank > 1
ORDER BY ContactID;
ContactIDFullNameEmailCityCreatedOnKeepRank
1Maya Lopezmaya@example.comAustin2026-01-103
2Maya Lopezmaya@example.comAustin2026-02-142
4Noah Kimnoah@example.comPortland2026-01-202
7Sam Riverasam@example.comChicago2026-02-112

Four rows are on the list. Maya’s newest row, the one with Denver, is safe. That matters, because the newest row holds the latest address. To keep the oldest row instead, change DESC to ASC and run the preview again.

Delete Duplicate Rows Inside a Transaction

Now swap the SELECT for a DELETE to delete duplicate rows for real. SQL Server lets you delete from a CTE that reads one table, and the rows disappear from that table. The OUTPUT clause saves every deleted row into an archive table, so nothing is lost. The preview promised four rows, so the script commits only when exactly four are deleted.

DROP TABLE IF EXISTS dbo.ContactsDeleted;
CREATE TABLE dbo.ContactsDeleted (
    ContactID int NOT NULL,
    FullName  nvarchar(80)  NOT NULL,
    Email     nvarchar(120) NOT NULL,
    City      nvarchar(60)  NOT NULL,
    CreatedOn date          NOT NULL
);
SET XACT_ABORT ON;
BEGIN TRANSACTION;
WITH Ranked AS (
    SELECT ContactID, FullName, Email, City, CreatedOn,
           ROW_NUMBER() OVER (PARTITION BY LOWER(TRIM(Email)) ORDER BY CreatedOn DESC, ContactID DESC) AS KeepRank
    FROM dbo.Contacts
)
DELETE FROM Ranked
OUTPUT deleted.ContactID, deleted.FullName, deleted.Email, deleted.City, deleted.CreatedOn INTO dbo.ContactsDeleted
WHERE KeepRank > 1;
IF @@ROWCOUNT = 4
    COMMIT TRANSACTION;
ELSE
    ROLLBACK TRANSACTION;
SELECT ContactID, FullName, Email, City, CreatedOn FROM dbo.Contacts ORDER BY ContactID;
SELECT ContactID, Email FROM dbo.ContactsDeleted ORDER BY ContactID;
ContactIDFullNameEmailCityCreatedOn
3Maya Lopez maya@example.comDenver2026-03-02
5Noah Kimnoah@example.comPortland2026-01-20
6Priya Shahpriya@example.comBoston2026-02-01
8Sam RiveraSAM@example.comChicago2026-04-05
ContactIDEmail
1maya@example.com
2maya@example.com
4noah@example.com
7sam@example.com

Four rows remain, one per person, and the Maya address still begins with a space. The second table shows the four deleted rows, safe in the archive. If the count had differed, the script would have rolled back. The archive rows would have gone with it. SET XACT_ABORT ON rolls the transaction back on most run time errors too.

The CTE must read a single table. If it joins two tables, SQL Server refuses with Msg 4405, because the change affects multiple base tables. Rank the rows in one table first, then join. PARTITION BY needs every column listed, because there is no star shortcut. The old text and ntext types can’t be compared at all, so partition by other columns or move to varchar(max).

Tables With No Key

Many import tables have no key, and some rows are identical in every column. The same pattern works to delete duplicate rows here too. Partition by every column, and use ORDER BY (SELECT NULL), because no copy is better than another.

DROP TABLE IF EXISTS dbo.ContactImport;
CREATE TABLE dbo.ContactImport (FullName nvarchar(80) NOT NULL, Email nvarchar(120) NOT NULL);
INSERT INTO dbo.ContactImport (FullName, Email)
VALUES (N'Maya Lopez', N'maya@example.com'), (N'Maya Lopez', N'maya@example.com'),
       (N'Noah Kim', N'noah@example.com'), (N'Noah Kim', N'noah@example.com'),
       (N'Priya Shah', N'priya@example.com');
WITH Ranked AS (
    SELECT ROW_NUMBER() OVER (PARTITION BY FullName, Email ORDER BY (SELECT NULL)) AS KeepRank
    FROM dbo.ContactImport
)
DELETE FROM Ranked WHERE KeepRank > 1;
SELECT FullName, Email FROM dbo.ContactImport ORDER BY FullName;

Five rows went in and three came out: Maya, Noah and Priya, once each. Deleting from the CTE works on a table without a key, because SQL Server tracks each row by its position.

Stop New Duplicates From Arriving

A cleanup fixes today’s mess, and a unique index stops tomorrow’s. A plain unique index on Email isn’t enough. It would accept a value with a leading space, which is what hid the third Maya row. Index the cleaned value instead. A persisted computed column stores LOWER(TRIM(Email)), and the unique index sits on that column.

ALTER TABLE dbo.Contacts ADD CleanEmail AS LOWER(TRIM(Email)) PERSISTED;
CREATE UNIQUE INDEX UX_Contacts_CleanEmail ON dbo.Contacts (CleanEmail);

Now try to add Maya again, with capital letters and a leading space.

INSERT INTO dbo.Contacts (FullName, Email, City, CreatedOn) VALUES (N'Maya Lopez', N' Maya@Example.com', N'Dallas', '2026-05-01');

SSMS Messages tab showing Msg 2601, Level 14, State 1, Line 1: Cannot insert duplicate key row in object dbo.Contacts with unique index UX_Contacts_CleanEmail, and the duplicate key value is (maya@example.com)

The statement fails, even though the new value differs from the stored one in spaces and capitals. The message reads as follows.

Msg 2601, Level 14, State 1, Line 1
Cannot insert duplicate key row in object 'dbo.Contacts' with unique index 'UX_Contacts_CleanEmail'. The duplicate key value is (maya@example.com).
The statement has been terminated.

For a load that runs every few minutes, the unique index is the better answer than a nightly cleanup. A load that contains a duplicate fails as a whole, though. You can filter the duplicates out before the insert. The index option IGNORE_DUP_KEY = ON is another route. It stores the new rows and prints Duplicate key was ignored for the rest. The load succeeds while rows are dropped. Use it only when that’s what you want.

When Deleting Is the Wrong Tool

You could argue that for a table with hundreds of millions of rows, deleting is a poor choice. Copy the rows you keep into a new table and swap the names. That avoids one huge delete and a huge log. It’s a fair point, and it costs a maintenance window plus rebuilding the indexes and constraints.

If you do delete, work in batches. One transaction with millions of rows grows the transaction log, while small batches let the log be reused between them. Ranking needs a sort unless an index helps. On 50,000 test rows, the plan for the ranking query has a Sort operator. With an index on the cleaned column, CreatedOn DESC and ContactID DESC, in the query’s order, the Sort disappears. A plain index on Email doesn’t help LOWER(TRIM(Email)), which is why the computed column matters.

What to Remember

Find the duplicates with a cleaned key. Preview with the same CTE as a SELECT. Delete inside a transaction, and commit only when the row count matches the preview. Choose the row to keep with ORDER BY, because the database can’t guess which copy is the right one. For a big cleanup, copy the table first, since a committed delete can’t be undone.

When I finish a cleanup, I add the unique index on the cleaned value before anyone loads data again. Without it, the duplicates return and the delete becomes a weekly chore. When you finish testing, remove the example database.

USE master;
GO
ALTER DATABASE DuplicateDemo SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE DuplicateDemo;

A duplicate row is not a data problem, it is a rule nobody wrote down.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

Duplicate Records, SQL Delete, SQL Scripts
Previous Post
A Health Check for an Inherited Server
Next Post
Hunting Suspicious Objects After a SQL Server Compromise

Related Posts

146 Comments. Leave new

  • NIce post.. helped me a lot…

    Reply
  • Hello Pinal Sir ,
    I am little bit confuse in one thing that if we have thousands of records in a table then how can we find the duplicate records and delete it by CTE. In your example , u mention only 7 rows data…Please guide

    Reply
  • Pinal,

    This works great!!! In order to keep the records where that were changed most recently, I changed the order by so that the most recently changed row was the first row – order by lastname, firstname, changedate desc.

    As always I appreciate your blogs!

    Reply
  • hi pinal,

    I usually select duplicate rows by the other way:

    select x,x,sum(1)
    from TBL
    order by x,x

    Reply
  • GRACIAS ME AHORRASTE UN TEDIOSO TRABAJO!!!.EXCELENTE APORTE.

    Reply
  • i used this valuable query but its throw an error like “semi colon missing in previous line of query”. if i put semi colon, is there any problem that terminate the stored procedure. after this i using some queries whether it will execute or not ?

    Reply
  • Awesome and neat way to do it than putting in a staging table.

    Reply
  • very clear demonstration, easy to understand.

    Reply
  • thanks Pinal!!!!

    Reply
  • Thanks Pinal Dave.

    Reply
  • Worked Beautifully!!! Thanks Pinal Dave!

    Reply
  • It worked !! thanks a lot

    Reply
  • Great!!! Thanks a lot!!!

    Reply
  • Great technique. Thanks for the informative post.

    Reply
  • Hi Pinal,

    I have below script and want to delete duplicated but when i run the query then i got an error msg like
    ”Msg 4405, Level 16, State 1, Line 2
    View or function ’employeeDetails’ is not updatable because the modification affects multiple base tables.”

    with employeeDetails
    (SNO,ID,Title,FName,Lname,Contact,Payrate)
    as
    (select ROW_NUMBER()over(PARTITIOn by a.employeeID order by c.PayFrequency)SNO, a.EmployeeID,a.Title,b.FirstName,b.LastName,b.Phone,c.PayFrequency from HumanResources.Employee a inner join Person.Contact b on a.ContactID=b.ContactID
    inner join HumanResources.EmployeePayHistory c on a.EmployeeID=c.EmployeeID)
    delete from employeeDetails where sno>1
    Please help

    Thanks
    Jaya sharma

    Reply
  • perfect trick, thank you so much… u’ve help me….

    Reply
  • Dinesh Panchal
    April 6, 2017 7:09 pm

    Thank Very much this query helped me lot

    Reply
  • Hi, Pinal, is this applicable/advisable to use in a million records/rows?
    Will it affect the performance?

    thanks,

    Reply
  • deleting rows in cte, but how does it deletes rows from original table – what is the inner algorithm behind it. I checked many blogs but no answer is satisfactory yet.

    Reply
  • Prashant Agrawal
    July 2, 2023 5:42 am

    Your youtube video demonstrates an ENTIRELY DIFFERENT method than that which this page discusses. Very confusing!

    Reply

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.