• new

    The major Release 2026 is live - Bringing Data Observability Into Your Code

  • new

    Contribute to the Future of AI & Data Innovation

  • new

    • Release 2026.06 - Bringing Data Observability Into Your Code

  • new

    • Contribute to the Future of AI & Data Innovation

Orphan Records: How to Find Them with SQL (and Keep Them Out)

|

6

min read

Diagram: payments rows referencing account A-777, which is missing from the accounts table

A payment row says account_id = 884213. The accounts table has no such account. Nothing errors, the load finishes green, and the next morning's branch report is short by exactly that payment, because the report joins payments to accounts and the join quietly drops it. An orphan record is a child row whose foreign key value has no matching row in the parent table it refers to.

Orphan records, or orphaned rows, turn up wherever foreign keys are not enforced: warehouses, staging layers, replicas, pipelines that load children before parents. Finding them is a standard SQL job, the anti join. There are four common ways to write it, and one of them reports "no orphans" the moment a single NULL appears.

This guide covers the patterns, the traps, and how to make the query a check that runs on every load. For the concept itself, see Can Your Data Still Find Its Parents? Understanding Referential Integrity.

Key takeaways

  • Inner joins hide orphan records: unmatched child rows vanish from the result instead of raising an error.

  • Find orphans with an anti join: LEFT JOIN … IS NULL, NOT EXISTS, or EXCEPT on distinct keys.

  • Avoid NOT IN on a nullable column: one NULL in the subquery returns zero rows, which looks like a clean result.

  • Match composite keys on all columns together, and decide deliberately whether a NULL foreign key is an error.

  • A query you remember to run is not a control. In digna the same anti join is one Referential Integrity rule that runs with every inspection.

Table of Contents

  • What is an orphan record?

  • Why do inner joins hide orphan records?

  • How do you find orphan records with SQL?

    • LEFT JOIN … IS NULL

    • NOT EXISTS

    • EXCEPT (MINUS) on distinct keys

  • Why does NOT IN return no rows when there is a NULL?

  • Which anti-join pattern should you use?

  • How do you handle composite keys and NULL foreign keys?

  • How do you find orphan records in large tables?

  • What if the parent table is in another database?

  • How do you turn an orphan query into a standing check?

  • Find them once, then keep them out

What is an orphan record?

An orphan record is a row in a child table whose foreign key points to a parent key that does not exist: a payment whose account_id is not in accounts, or a medication administration whose product_code is not in medications. The row may be perfectly valid on its own. What is broken is the relationship.

Orphans always sit on the child side; an account with no payments is normal. The usual causes are mundane: the parent was deleted or never loaded, the child arrived before tomorrow's master data load, or the key formats differ ('00884213' against 884213, or a trailing space).

Operational databases such as PostgreSQL or Oracle enforce declared foreign keys, but warehouse copies usually don't carry the constraints over. Most cloud warehouses accept a FOREIGN KEY declaration without enforcing it; BigQuery's documentation states that it does not enforce primary and foreign key constraints. We cover why Snowflake, BigQuery, Redshift and Databricks don't enforce foreign keys separately.

Why do inner joins hide orphan records?

An inner join returns only rows that match on both sides, so a child row without a parent is simply not in the result. There is no error, no warning and no NULL to notice. Totals come out lower than they should, and nothing in the report itself shows the difference.

SELECT a.branch_code,       SUM(p.amount) AS total_paidFROM   payments pJOIN   accounts a ON a.account_id = p.account_idWHERE  p.booking_date = DATE '2026-10-08'GROUP  BY
SELECT a.branch_code,       SUM(p.amount) AS total_paidFROM   payments pJOIN   accounts a ON a.account_id = p.account_idWHERE  p.booking_date = DATE '2026-10-08'GROUP  BY
SELECT a.branch_code,       SUM(p.amount) AS total_paidFROM   payments pJOIN   accounts a ON a.account_id = p.account_idWHERE  p.booking_date = DATE '2026-10-08'GROUP  BY

Every payment whose account_id is missing from accounts drops out before the SUM. The quickest way to see the gap is to count both ways:

SELECT  (SELECT COUNT(*) FROM payments   WHERE booking_date = DATE '2026-10-08')               AS all_payments,  (SELECT COUNT(*) FROM payments p   JOIN accounts a ON a.account_id = p.account_id   WHERE p.booking_date = DATE '2026-10-08')             AS
SELECT  (SELECT COUNT(*) FROM payments   WHERE booking_date = DATE '2026-10-08')               AS all_payments,  (SELECT COUNT(*) FROM payments p   JOIN accounts a ON a.account_id = p.account_id   WHERE p.booking_date = DATE '2026-10-08')             AS
SELECT  (SELECT COUNT(*) FROM payments   WHERE booking_date = DATE '2026-10-08')               AS all_payments,  (SELECT COUNT(*) FROM payments p   JOIN accounts a ON a.account_id = p.account_id   WHERE p.booking_date = DATE '2026-10-08')             AS

If account_id is unique in accounts, the difference is your orphans plus any NULL account_id rows.

Declared but unchecked keys can make this worse. Amazon Redshift's planner assumes declared keys are valid, and AWS warns that invalid keys can make some queries return incorrect results.

How do you find orphan records with SQL?

You find orphan records with an anti join: a query that returns the child rows for which no matching parent row exists. In SQL you write it as LEFT JOIN … WHERE parent_key IS NULL, as NOT EXISTS, or as EXCEPT on the distinct key values. On non-null keys all three agree.

LEFT JOIN … IS NULL

SELECT p.payment_id, p.account_id, p.amount, p.booking_dateFROM   payments pLEFT   JOIN accounts a       ON a.account_id = p.account_idWHERE  a.account_id IS NULL  AND  p.account_id IS NOT NULL
SELECT p.payment_id, p.account_id, p.amount, p.booking_dateFROM   payments pLEFT   JOIN accounts a       ON a.account_id = p.account_idWHERE  a.account_id IS NULL  AND  p.account_id IS NOT NULL
SELECT p.payment_id, p.account_id, p.amount, p.booking_dateFROM   payments pLEFT   JOIN accounts a       ON a.account_id = p.account_idWHERE  a.account_id IS NULL  AND  p.account_id IS NOT NULL

Test a parent column that cannot be NULL in a matched row, ideally the join key. Put conditions on the parent into the ON clause: WHERE a.status = 'ACTIVE' is never true for an unmatched row, so the query would return nothing.

NOT EXISTS

SELECT p.payment_id, p.account_id, p.amount, p.booking_dateFROM   payments pWHERE  p.account_id IS NOT NULL  AND  NOT EXISTS (         SELECT 1         FROM   accounts a         WHERE  a.account_id = p.account_id       )
SELECT p.payment_id, p.account_id, p.amount, p.booking_dateFROM   payments pWHERE  p.account_id IS NOT NULL  AND  NOT EXISTS (         SELECT 1         FROM   accounts a         WHERE  a.account_id = p.account_id       )
SELECT p.payment_id, p.account_id, p.amount, p.booking_dateFROM   payments pWHERE  p.account_id IS NOT NULL  AND  NOT EXISTS (         SELECT 1         FROM   accounts a         WHERE  a.account_id = p.account_id       )

This reads like the question. Duplicate parent keys don't multiply rows, and NULLs in accounts.account_id can't break it: an equality with NULL never matches.

EXCEPT (MINUS) on distinct keys

SELECT account_id FROM payments WHERE account_id IS NOT NULLEXCEPTSELECT account_id FROM
SELECT account_id FROM payments WHERE account_id IS NOT NULLEXCEPTSELECT account_id FROM
SELECT account_id FROM payments WHERE account_id IS NOT NULLEXCEPTSELECT account_id FROM

This returns the distinct missing key values, not the rows. Often the better first question: a handful of missing accounts can explain thousands of orphaned payments. Oracle has traditionally spelled the operator MINUS; BigQuery requires EXCEPT DISTINCT. Set operators treat two NULLs as equal, one more reason to filter NULL keys explicitly.

Why does NOT IN return no rows when there is a NULL?

NOT IN returns no rows if its subquery contains even one NULL, because SQL's three-valued logic makes every comparison with that NULL unknown. x NOT IN (1, 2, NULL) means x <> 1 AND x <> 2 AND x <> NULL. The last term is never true, so the whole condition is never true.

-- Looks right. Returns nothing if any accounts.account_id is NULL.SELECT p.payment_id, p.account_idFROM   payments pWHERE  p.account_id NOT IN (SELECT a.account_id FROM accounts a);
-- Looks right. Returns nothing if any accounts.account_id is NULL.SELECT p.payment_id, p.account_idFROM   payments pWHERE  p.account_id NOT IN (SELECT a.account_id FROM accounts a);
-- Looks right. Returns nothing if any accounts.account_id is NULL.SELECT p.payment_id, p.account_idFROM   payments pWHERE  p.account_id NOT IN (SELECT a.account_id FROM accounts a);

For a payment whose account exists, one comparison is false and the row is correctly excluded. For a missing account, every comparison with a real key is true but the one with NULL is unknown, so the condition is unknown, and WHERE keeps only true rows. The result is an empty set, indistinguishable from "no orphans".

It fails quietly, with a false all-clear, and warehouse key columns are often not declared NOT NULL. Payments whose own account_id is NULL never qualify either. Use NOT EXISTS, or at least add WHERE a.account_id IS NOT NULL inside the subquery.

Which anti-join pattern should you use?

Use NOT EXISTS as the default for row-level orphan checks, EXCEPT when you want the list of missing key values, and LEFT JOIN … IS NULL when you also want matched counts in the same pass. Use NOT IN only when both columns are guaranteed non-null.

Pattern

Returns

NULL in parent key

NULL foreign key in child

Readability

Typical performance

LEFT JOIN … IS NULL

Child rows

Safe

Reported as orphan unless filtered

Familiar; intent sits in the WHERE clause

Usually planned as an anti join

NOT EXISTS

Child rows

Safe

Reported as orphan unless filtered

Reads like the question

Usually planned as an anti join

EXCEPT / MINUS

Distinct key values

Safe

Returned once as NULL unless filtered

Short and clear for key lists

Deduplicates both sides; good for key-level summaries

NOT IN

Child rows

Unsafe: one NULL returns zero rows

Silently excluded

Reads well, misleads

Fine on non-null columns; can get a worse plan on nullable ones

Most optimizers plan LEFT JOIN and NOT EXISTS alike; check the plan on your platform.

How do you handle composite keys and NULL foreign keys?

For a composite key, match all key columns together in one predicate, never column by column: a row is an orphan when its combination is missing, even if each value exists somewhere on its own. NULL foreign keys need a deliberate decision before you count them: missing reference, or legitimately empty.

Suppose each hospital has its own formulary, keyed by (hospital_id, product_code):

SELECT m.administration_id, m.hospital_id, m.product_codeFROM   medication_administrations mWHERE  m.hospital_id IS NOT NULL  AND  m.product_code IS NOT NULL  AND  NOT EXISTS (         SELECT 1         FROM   hospital_formulary f         WHERE  f.hospital_id  = m.hospital_id           AND  f.product_code = m.product_code       )
SELECT m.administration_id, m.hospital_id, m.product_codeFROM   medication_administrations mWHERE  m.hospital_id IS NOT NULL  AND  m.product_code IS NOT NULL  AND  NOT EXISTS (         SELECT 1         FROM   hospital_formulary f         WHERE  f.hospital_id  = m.hospital_id           AND  f.product_code = m.product_code       )
SELECT m.administration_id, m.hospital_id, m.product_codeFROM   medication_administrations mWHERE  m.hospital_id IS NOT NULL  AND  m.product_code IS NOT NULL  AND  NOT EXISTS (         SELECT 1         FROM   hospital_formulary f         WHERE  f.hospital_id  = m.hospital_id           AND  f.product_code = m.product_code       )

Two separate checks, "the hospital exists" and "the product exists", both pass for a product listed only at hospital A and administered at hospital B. Only the combined predicate catches it. EXCEPT handles composite keys naturally. Don't concatenate keys into one string: '1' || '23' and '12' || '3' collide.

A payment with a NULL account_id doesn't point at a missing account; it points nowhere. A payment must have an account, while an optional reference such as referring_doctor_id may be empty. Exclude NULLs from the orphan query and count them separately:

SELECT COUNT(*)                      AS total_rows,       COUNT(*) - COUNT(account_id)  AS
SELECT COUNT(*)                      AS total_rows,       COUNT(*) - COUNT(account_id)  AS
SELECT COUNT(*)                      AS total_rows,       COUNT(*) - COUNT(account_id)  AS

How do you find orphan records in large tables?

On large tables, count before you list, compare distinct key values rather than every row, and restrict the child side to the latest load or partition. Keep the parent side complete: a payment booked today may reference an account opened years ago, so never filter the parent by load date.

  1. Count first. Run the anti join as COUNT(*). Fetch rows only when the count is non-zero, and then with a limit.

  2. Compare distinct keys. Reduce the child side to SELECT DISTINCT account_id first. Distinct keys are usually far fewer than rows.

  3. Filter to the latest load. Restrict the child to the newest partition or load_date so the engine can prune.

  4. Keep the join column comparable. Same data type on both sides, no CAST or TRIM in the predicate, which can block index use and pruning. Fix formats in the load instead; see our guide to data cleaning in SQL.

  5. Record the result. Store date, rows evaluated and orphans found to see the trend.

WITH child_keys AS (  SELECT DISTINCT account_id  FROM   payments  WHERE  load_date = DATE '2026-10-08'    AND  account_id IS NOT NULL)SELECT COUNT(*) AS missing_account_idsFROM   child_keys kWHERE  NOT EXISTS (         SELECT 1 FROM accounts a WHERE a.account_id = k.account_id       )
WITH child_keys AS (  SELECT DISTINCT account_id  FROM   payments  WHERE  load_date = DATE '2026-10-08'    AND  account_id IS NOT NULL)SELECT COUNT(*) AS missing_account_idsFROM   child_keys kWHERE  NOT EXISTS (         SELECT 1 FROM accounts a WHERE a.account_id = k.account_id       )
WITH child_keys AS (  SELECT DISTINCT account_id  FROM   payments  WHERE  load_date = DATE '2026-10-08'    AND  account_id IS NOT NULL)SELECT COUNT(*) AS missing_account_idsFROM   child_keys kWHERE  NOT EXISTS (         SELECT 1 FROM accounts a WHERE a.account_id = k.account_id       )

This counts missing keys, not orphaned rows; pull the rows for those keys afterwards.

What if the parent table is in another database?

An anti join only works when one query engine can read both tables. If payments sit in the warehouse and accounts in the core banking database, plain SQL cannot see both, so you copy one side across, use a database link or federated query, or run the check in a tool that can reach both connections.

A copied master table is another pipeline with its own lag: a stale copy reports false orphans or misses real ones. Database links depend on platform support and need security approval for every new connection.

How do you turn an orphan query into a standing check?

You turn an orphan query into a standing check by running it on every load, recording passing and failing counts, setting a threshold for failure, and keeping the failing rows where the owning team can see them. In digna that is one Referential Integrity rule, configured in a dialog, with no SQL to write.

digna Data Validation has three kinds of rule: Rule, Uniqueness and Referential Integrity. The referential one is this article's anti join: given columns on one data source and a matching set on another, digna keeps the distinct values from the other table and fails any row that does not join. It executes inside your source database; your data never leaves your infrastructure. Since Release 2026.01 it works across different database connections within the same project, without replicating data.

The screenshots below use digna's demo data for Danubia Kliniken, a fictional Austrian hospital group.

On 2026-04-22 the wards recorded 82 administrations of Coavira 2.5 mg (product code 3858646), not yet in the pharmacy product master. Every report joining doses to products showed 0 doses of it while nurses had given 82. The rule hc_product_in_master failed: 4,244 of 4,326 rows passed. By hand, you would need:

SELECT m.*FROM   hospital_medication_administrations mWHERE  m.product_code IS NOT NULL  AND  NOT EXISTS (         SELECT 1         FROM   hospital_medications p         WHERE  p.product_code = m.product_code       )
SELECT m.*FROM   hospital_medication_administrations mWHERE  m.product_code IS NOT NULL  AND  NOT EXISTS (         SELECT 1         FROM   hospital_medications p         WHERE  p.product_code = m.product_code       )
SELECT m.*FROM   hospital_medication_administrations mWHERE  m.product_code IS NOT NULL  AND  NOT EXISTS (         SELECT 1         FROM   hospital_medications p         WHERE  p.product_code = m.product_code       )

The Invalid Records view shows that result set without anyone writing it:

digna Invalid Records view, filter Failed, check Full - hc_product_in_master, listing medication administrations with hospital, ward, department, product_code 3858646 and medication_name Coavira 2.5 mg

Invalid Records, filtered to Failed: every orphaned dose with hospital, ward, department, product code and medication name.

Setting up the rule:

  1. Configuration → data source hospital_medication_administrations → tab Data Validation → Add Rule. The Add Data Validation Rule dialog opens.

  2. Enter a Name (hc_product_in_master) and a Description.

  3. Set Type to Referential Integrity and pick Attributes product_code.

  4. Under must exist in, pick the Data Source hospital_medications and its Attributes product_code. For a composite key, pick several columns on both sides in the same order; lists of different length are rejected.

  5. Choose Threshold Mode Absolute or Relative and set the Info threshold and Warn threshold (here: Absolute, Info 0, Warn 1). Save. The rule runs with every inspection, scheduled or on demand.

digna Add Data Validation Rule dialog for hc_product_in_master: Type Referential Integrity, Attributes product_code, must exist in Data Source hospital_medications, Attributes product_code, Threshold Mode Absolute, Info 0, Warn 1

The whole rule: two attribute lists, a target data source and two thresholds.

Referential Integrity skips NULLs, like the IS NOT NULL filter above; if presence is required, add a separate Rule with product_code IS NOT NULL. Above the Info threshold the status is Uncertain, above the Warn threshold Failed, so with Info at 0 any orphan takes the rule off Passed. The failing records are the same query with the pass condition negated, exportable, and results can notify the team that owns the data.

Since Release 2026.06 rules can also live as code via the Python SDK (pip install digna-sdk). For the full walkthrough, see how to set up a referential integrity check in digna or the 2:26 setup video.

Find them once, then keep them out

For investigating orphan records: NOT EXISTS for rows, EXCEPT for missing keys, never NOT IN on a nullable column, and a deliberate decision about NULLs. Keeping them out takes more: enforce foreign keys where the database can, load parents first, handle late-arriving master data explicitly, and run the referential check on every load.

To see that check running against your own tables, inside your own infrastructure, book a demo with the digna team.

Frequently asked questions

How do I find orphan records in SQL?

Use an anti join that returns child rows with no matching parent. The most robust form is NOT EXISTS with a correlated subquery on the key; LEFT JOIN … WHERE parent_key IS NULL gives the same result. Filter NULL foreign keys out first and count them as a separate completeness check.

Why does NOT IN return no rows when the subquery contains a NULL?

Three-valued logic is the reason. x NOT IN (1, 2, NULL) expands to x <> 1 AND x <> 2 AND x <> NULL, and the last comparison is unknown, never true. WHERE keeps only true rows, so the query returns nothing, which looks exactly like a clean result.

Is NOT EXISTS faster than LEFT JOIN IS NULL?

Usually neither is faster: most modern optimizers plan both as an anti join, so the choice comes down to readability. NOT IN is the outlier. On nullable columns it can get a worse plan, and it returns no rows at all when the subquery contains a NULL.

What is the difference between an orphan record and a NULL foreign key?

An orphan record points to a parent key that does not exist, while a NULL foreign key points nowhere. They need different checks: referential integrity for orphans, and an IS NOT NULL rule where the reference is mandatory. digna's Referential Integrity rule skips NULLs for exactly this reason.

How can I check for orphan records automatically after every load?

Turn the anti join into a scheduled check with thresholds and stored results. In digna that is one Referential Integrity rule in Data Validation: pick the columns, pick the data source they must exist in, set Info and Warn thresholds, save. It runs inside your database with every inspection.

✦ Generated with Artifical Intelligence

Share on X
Share on X
Share on Facebook
Share on Facebook
Share on LinkedIn
Share on LinkedIn

Meet the Team Behind the Platform

A Vienna-based team of AI, data, and software experts backed

by academic rigor and enterprise experience.

Meet the Team Behind the Platform

A Vienna-based team of AI, data, and software experts backed by academic rigor and enterprise experience.

Product

Integrations

Resources

Company

INDEXED BYIndexerNow INDEXED BYIndexerNow