How do you find duplicate rows and orphaned foreign keys in SQL Server?

Find duplicates by grouping on the columns that should be unique and keeping groups with COUNT(*) > 1; use ROW_NUMBER() to see every extra copy. Find orphaned foreign keys with NOT EXISTS against the parent table, and check sys.foreign_keys for constraints that are disabled or untrusted. To run these on every table, DataOps TestGen generates the duplicate checks from a data profile.

Key points

  • GROUP BY the intended key with HAVING COUNT(*) > 1 lists duplicate groups; ROW_NUMBER() OVER (PARTITION BY the key) marks every copy after the first, which is what you review or delete.
  • SQL Server's default case-insensitive collation groups 'ACME' and 'acme' together, and every collation ignores trailing spaces when comparing strings, so duplicate checks can merge values a downstream system treats as different.
  • NOT EXISTS against the parent table finds orphaned child rows; filter out NULL keys, because a NULL foreign key is a missing reference, not an orphan.
  • A foreign key that was disabled or re-enabled without WITH CHECK shows is_disabled or is_not_trusted = 1 in sys.foreign_keys, and DBCC CHECKCONSTRAINTS lists the rows that violate it.
  • DataOps TestGen generates Duplicate Rows and Unique Values tests from a data profile, and its hand-configured Reference Match test checks that values or value combinations exist in a reference table.

Duplicates and orphans are the two problems constraints were supposed to prevent and often don’t. A staging table has no primary key, a foreign key got disabled during a bulk load, or the relationship was never declared at all. Here’s the T-SQL to find both.

Duplicate rows

List duplicate groups on the columns that should be unique:

SELECT CustomerEmail, OrderDate, COUNT(*) AS copies
FROM dbo.Orders
GROUP BY CustomerEmail, OrderDate
HAVING COUNT(*) > 1
ORDER BY copies DESC;

See every extra copy, keeping the lowest OrderID as the original:

WITH numbered AS (
    SELECT o.*,
           ROW_NUMBER() OVER (PARTITION BY CustomerEmail, OrderDate ORDER BY OrderID) AS copy_no
    FROM dbo.Orders AS o
)
SELECT *
FROM numbered
WHERE copy_no > 1;

To remove them, change the final SELECT * to DELETE once you’ve reviewed the result, inside a transaction.

Watch three SQL Server behaviors:

BehaviorEffect on a duplicate check
Case-insensitive default collationACME and acme count as one value
Trailing spaces ignored in comparisons'ACME' and 'ACME ' count as one value
GROUP BY puts NULLs in one groupRows with NULL keys show up as duplicates of each other

TestGen’s migration docs describe the first two. If another system treats those values as different, group on UPPER(RTRIM(CustomerEmail)) to see what your collation is merging.

Orphaned foreign keys

Child rows whose parent doesn’t exist:

SELECT o.OrderID, o.CustomerID
FROM dbo.Orders AS o
WHERE o.CustomerID IS NOT NULL
  AND NOT EXISTS (
      SELECT 1 FROM dbo.Customers AS c WHERE c.CustomerID = o.CustomerID
  );

The IS NOT NULL filter matters. A NULL key is a missing reference, a different problem, and NOT EXISTS would report it as an orphan.

If a foreign key is declared but orphans exist anyway, the constraint was disabled or isn’t trusted:

SELECT OBJECT_SCHEMA_NAME(fk.parent_object_id) AS schema_name,
       OBJECT_NAME(fk.parent_object_id) AS table_name,
       fk.name AS constraint_name,
       fk.is_disabled,
       fk.is_not_trusted
FROM sys.foreign_keys AS fk
WHERE fk.is_disabled = 1 OR fk.is_not_trusted = 1;

DBCC CHECKCONSTRAINTS ('dbo.Orders') WITH ALL_CONSTRAINTS; lists the rows that violate them. After you fix those rows, ALTER TABLE dbo.Orders WITH CHECK CHECK CONSTRAINT FK_Orders_Customers; makes the constraint trusted again.

Undeclared relationships are the hard case. Many warehouses never declare foreign keys between facts and dimensions, so nothing in sys.foreign_keys tells you to check. You have to know the pairs.

On every table, without the scripts

The queries above cover the tables you thought of. DataOps TestGen covers the rest. It connects with a read-only SQL login and runs data profiling on every column, then:

Each runs as T-SQL inside SQL Server, keeps history, and alerts when counts change. The test types are documented under uniqueness tests and validity tests.

Open-source TestGen is free for one user, one connection, and one project. Start at the SQL Server install page, and see testing SQL Server without writing T-SQL for what else the profile generates.

Frequently asked questions

Short answers to the follow-up questions people ask next.

How do I delete duplicate rows in SQL Server but keep one?

Number the copies with ROW_NUMBER() OVER (PARTITION BY the key columns ORDER BY a tiebreaker such as the identity column) in a common table expression, then delete from the CTE where the row number is above 1. Run it as a SELECT first, inside a transaction, and decide which copy to keep before you delete anything.

Why doesn't my foreign key stop orphaned rows?

Because it may be disabled or untrusted. A constraint turned off with NOCHECK, or turned back on without WITH CHECK, lets existing bad rows stay. Check is_disabled and is_not_trusted in sys.foreign_keys, list violating rows with DBCC CHECKCONSTRAINTS, fix them, then run ALTER TABLE ... WITH CHECK CHECK CONSTRAINT to trust it again.

Why does my duplicate check miss duplicates that another system sees?

Usually collation. SQL Server's default collation is case-insensitive, so 'ACME' and 'acme' group together, and string comparison ignores trailing spaces under any collation. A case-sensitive target, or a file export, treats them as different values. Group on UPPER and RTRIM to see near-duplicates, or use a case-sensitive collation in the query.

Can a tool find duplicates and orphans on every table automatically?

Duplicates, yes. DataOps TestGen's data profiling picks columns that look like a unique key and generates Duplicate Rows and Unique Values tests from them. Orphaned keys need you to name the parent table: TestGen's Reference Match test checks that each value or combination exists there, and it's created by hand.

Try it on your own data

DataOps TestGen is free and open source. Profile a table and generate its tests this afternoon.