NOT EXISTS for missing rows

Concepts covered: Existence Checks

NOT EXISTS flips the answer: it is TRUE when the subquery returns 0 rows for the current outer row. That makes it the standard tool for orphan hunting, finding rows on one side with no partner on the other. Stores with no orders, users who never logged in, invoices with no payment and products nobody reviewed all share this query shape.

Watch a missing partner keep a row

The animation runs this shape on a support question: which customers have no open ticket? Acme and Initech each have an open ticket, so their subqueries return a row and NOT EXISTS is FALSE. Globex has only a closed ticket, which the status condition inside the subquery filters out, so its subquery comes back empty. Umbrella has no tickets at all. Globex and Umbrella stay.

SELECT c.customer_id, c.company
FROM customers c
WHERE NOT EXISTS (
  SELECT 1
  FROM support_tickets t
  WHERE t.customer_id = c.customer_id
    AND t.status = 'open'
)
customers
customer_idcompany
1Acme
2Globex
3Initech
4Umbrella
support_tickets
ticket_idcustomer_idstatus
T11open
T22closed
T33open

Where the condition sits changes the question

The status check belongs inside the subquery. There it redefines which partner rows count, so a store whose only order was refunded has no paid partner and stays. A condition in the outer WHERE filters the stores themselves, and stores has no status column to test. Each of the 3 tabs below asks a different question of the same 2 tables, which this section aliases as st and od.

stores
store_idstore_name
1Maple Mugs
2Fern Studio
3North Knits
4Pixel Prints
5Tidal Tees
orders
order_idstore_idstatus
1011paid
1021refunded
1033paid
1045refunded
105NULLpaid

No orders

Stores with no order of any kind. Fern Studio and Pixel Prints come back.

SELECT
  st.store_id,
  st.store_name
FROM stores st
WHERE NOT EXISTS (
  SELECT 1
  FROM orders od
  WHERE od.store_id = st.store_id
)
Result
store_idstore_name
2Fern Studio
4Pixel Prints

No paid

Stores with no paid order. Tidal Tees joins them, since its only order was refunded.

SELECT
  st.store_id,
  st.store_name
FROM stores st
WHERE NOT EXISTS (
  SELECT 1
  FROM orders od
  WHERE od.store_id = st.store_id
    AND od.status = 'paid'
)
Result
store_idstore_name
2Fern Studio
4Pixel Prints
5Tidal Tees

Paid only

Stores with a paid order and no refunded one. Only North Knits passes both checks.

SELECT
  st.store_id,
  st.store_name
FROM stores st
WHERE EXISTS (
  SELECT 1
  FROM orders od
  WHERE od.store_id = st.store_id
    AND od.status = 'paid'
)
  AND NOT EXISTS (
    SELECT 1
    FROM orders od
    WHERE od.store_id = st.store_id
      AND od.status = 'refunded'
  )
Result
store_idstore_name
3North Knits

The third tab shows why these filters compose well: each EXISTS or NOT EXISTS is an independent yes-or-no check on the same outer row, so you combine them with AND and OR, grouped by parentheses, the way you would any other condition. Maple Mugs fails there because refunded order 102 makes its NOT EXISTS FALSE.

Support wants every customer who has never had a ticket closed. Using the customers and support_tickets tables from the animation, put the tiles in order. Two tiles are not needed.

NOT EXISTS next to EXCEPT

You already know a set-based way to find missing ids: SELECT store_id FROM stores EXCEPT SELECT store_id FROM orders returns 2 and 4. It answers the same question, with 3 differences that matter in practice.

NOT EXISTS
  • Returns whole outer rows, every column intact
  • Keeps duplicate outer rows as they are
  • The partner test can add any extra condition, such as a status or a date range
EXCEPT
  • Returns only the columns both queries select
  • Removes duplicates from what it returns
  • Compares entire rows for equality and nothing else

To get store names out of EXCEPT you would feed its ids back into another query. NOT EXISTS returns the store rows directly, which is why it is the default for orphan checks in pipelines, such as these:

A typo compares the order to itself. Which stores does this return?

SELECT
  st.store_name
FROM stores st
WHERE NOT EXISTS (
  SELECT 1
  FROM orders od
  WHERE od.store_id = od.store_id
)

About this lesson

This section is part of the Filtering: Advanced lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.