There's a family of questions that needs no value at all: just a yes or a no. Has this customer ever bought? Does this product have any review? Does this order contain any cosmetics item? It doesn't matter how many, or which, or how much they add up to: what matters is whether at least one exists.

EXISTS is the operator that answers exactly that, and it's probably the subquery you'll write most often in your professional life. Its negation, NOT EXISTS, solves the other half —customers who haven't bought, products nobody has sold— and it does so with a property none of the alternatives has: it's immune to null values. In this lesson you'll see both, you'll compare them with NOT IN, with 03-03's anti-join and with 03-07's EXCEPT, and you'll close with the classic problem of relational division.

Contents

  1. EXISTS: a predicate, not a value
  2. Why it makes no difference what you put in the inner SELECT
  3. Four GreenStore cases with EXISTS
  4. NOT EXISTS: the data set's four gaps
  5. NOT EXISTS against NOT IN: the difference that matters
  6. The four ways of answering "what doesn't match"
  7. Short-circuiting: EXISTS against COUNT(*) > 0
  8. Relational division: the double negation
  9. Common Mistakes and Tips
  10. Exercises
  11. Conclusion

  1. EXISTS: a predicate, not a value

EXISTS (subquery) is a predicate: it returns TRUE or FALSE, never NULL. Its rule is radically simple:

EXISTS is TRUE the moment the subquery produces its first row. If it produces none, it's FALSE.

SELECT c.id, c.name, c.last_name
FROM customers AS c
WHERE EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id)
ORDER BY c.id
LIMIT 4;
id name last_name
1 Lucía Martínez Soler
2 Carlos Ferrer Ibáñez
3 Marta Sanchis Gil
4 Javier Ortega Ruiz

(First 4 of 12 rows: the 12 customers who have bought at some point.)

Three properties set it apart from everything you've seen: it returns TRUE/FALSE and never NULL, so it's immune to 04-03's three-valued logic; it doesn't look at the contents of the subquery, whatever columns it returns; and it stops at the first row, it doesn't count or sum or sort.

And a fourth, decisive one: in practice EXISTS is always correlated. An uncorrelated EXISTSWHERE EXISTS (SELECT 1 FROM orders)— is TRUE or FALSE for every row alike, so it returns either the whole table or no rows at all. It isn't an error, but it isn't any use either. The condition o.customer_id = c.id is what does the work: it turns "are there orders?" into "are there orders for this customer?".

  1. Why it makes no difference what you put in the inner SELECT

These four forms are exactly equivalent and produce the same execution plan:

WHERE EXISTS (SELECT 1     FROM orders AS o WHERE o.customer_id = c.id)
WHERE EXISTS (SELECT *     FROM orders AS o WHERE o.customer_id = c.id)
WHERE EXISTS (SELECT o.id  FROM orders AS o WHERE o.customer_id = c.id)
WHERE EXISTS (SELECT 1/0   FROM orders AS o WHERE o.customer_id = c.id)

The last one is the proof: 1/0 is a division by zero and it raises no error, because PostgreSQL never evaluates the column list of an EXISTS. It only checks whether the query produces rows. The projection is discarded before being computed.

Hence the SELECT 1 idiom, the one you'll see in 90 % of professional code: it's the shortest way of saying "I'm not interested in the contents". SELECT * is just as correct and some style guides prefer it because it stresses the same thing. Pick one and be consistent; in this course we use SELECT 1.

What does matter inside the EXISTS is the WHERE: the correlation. An EXISTS whose WHERE doesn't mention the outer row is almost certainly badly framed.

  1. Four GreenStore cases with EXISTS

Products with at least one review. The EXISTS doesn't say how many or with what rating: only that there is one.

SELECT p.id, p.name AS product, p.price
FROM products AS p
WHERE EXISTS (SELECT 1 FROM reviews AS r WHERE r.product_id = p.id)
ORDER BY p.id;
id product price
1 Extra virgin olive oil 500 ml 12.50
2 Organic brown rice 1 kg 3.90
5 Organic crushed tomato 400 g 1.95
6 Aloe vera face cream 50 ml 18.90
10 Concentrated eco laundry detergent 1 L 11.20

(First 5 of 9 rows; the other four are products 12, 15, 16 and 18.) 9 of the 20 products. Compare it with an INNER JOIN on reviews: that would have returned 12 rows, one per review, with the oil, the rice and the cream repeated. EXISTS never multiplies rows, and that's its most practical advantage over the JOIN.

Orders containing some natural cosmetics product. Here the subquery carries its own JOIN:

SELECT o.id AS order_id, o.order_date, c.name || ' ' || c.last_name AS customer, o.status
FROM orders    AS o
JOIN customers AS c ON o.customer_id = c.id
WHERE EXISTS (SELECT 1
              FROM order_lines AS ol
              JOIN products AS p ON p.id = ol.product_id
              WHERE ol.order_id = o.id
                AND p.category_id = 2)
ORDER BY o.id;
order_id order_date customer status
2 2025-03-12 Carlos Ferrer Ibáñez delivered
6 2025-05-23 Ana Belmonte Roca cancelled
9 2025-07-15 Tiago Almeida Nunes delivered
10 2025-08-03 Camille Dubois delivered
15 2025-12-02 Lucía Martínez Soler delivered
18 2026-01-27 Ana Belmonte Roca paid

6 orders out of 20. Order 9 contains two cosmetics items (shampoo and lip balm) and appears only once: with a JOIN 7 rows would have come out and a DISTINCT would have been needed. Employees who have handled at least one order, same pattern:

SELECT e.id, e.name || ' ' || e.last_name AS employee, e.job_title
FROM employees AS e
WHERE EXISTS (SELECT 1 FROM orders AS o WHERE o.employee_id = e.id)
ORDER BY e.id;
id employee job_title
4 Óscar Peris Blasco Sales rep
5 Laia Puig Sanchis Sales rep
6 Marc Estévez Roig Customer support

3 of the 8 employees, exactly the ones 01-06 announced.

Dialect note: in PostgreSQL EXISTS returns a genuine boolean, so you can use it as a column: SELECT c.id, EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id) AS has_bought FROM customers c returns true/false for all 15 customers. SQL Server and Oracle don't allow it and force you to wrap it in a CASE WHEN EXISTS (...) THEN 1 ELSE 0 END. MySQL 8 and SQLite do accept it, returning 1 or 0.

  1. NOT EXISTS: the data set's four gaps

NOT EXISTS is the literal negation: TRUE when the subquery produces no rows at all. It's the natural tool for every question with "without", "never" or "none", and it answers GreenStore's four deliberate gaps with the same template.

-- Customers who have never bought
SELECT c.id, c.name, c.last_name, c.city, c.signup_date
FROM customers AS c
WHERE NOT EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id)
ORDER BY c.id;
id name last_name city signup_date
13 Núria Bosch Ferrer Barcelona 2025-06-20
14 Hugo Iglesias Pardo Zaragoza 2025-09-12
15 Inés Carrasco Vega Valencia 2026-01-08

And the same template, changing only the inner table, gives the products never sold:

SELECT p.id, p.name, p.price, p.stock, p.active
FROM products AS p
WHERE NOT EXISTS (SELECT 1 FROM order_lines AS ol WHERE ol.product_id = p.id)
ORDER BY p.id;
id name price stock active
13 Soy wax candles (pack of 2) 13.75 0 true
19 Natural stick deodorant 50 g 7.80 75 true
20 Spirulina capsules 120 units 16.40 55 false

With NOT EXISTS (SELECT 1 FROM reviews AS r WHERE r.product_id = p.id) over products you get the 11 products with no review, and with NOT EXISTS (SELECT 1 FROM orders AS o WHERE o.employee_id = e.id) over employees, the 5 employees with no orders (Rosa, Andrés, Beatriz, Irene and Daniel).

Four business questions, a single template. That uniformity is the main reason to prefer NOT EXISTS: there's no need to decide which column to check with IS NULL, nor to worry about nulls, nor to remember which table goes on the left.

  1. NOT EXISTS against NOT IN: the difference that matters

Now the module's central demonstration. The same question, two forms, two different results.

-- ⚠️ INCORRECT: 0 rows
SELECT e.id, e.name, e.last_name
FROM employees AS e
WHERE e.id NOT IN (SELECT employee_id FROM orders);

-- ✅ CORRECT: 5 rows
SELECT e.id, e.name, e.last_name
FROM employees AS e
WHERE NOT EXISTS (SELECT 1 FROM orders AS o WHERE o.employee_id = e.id);
id name last_name
1 Rosa Alcázar Vives
2 Andrés Company Talens
3 Beatriz Nadal Ripoll
7 Irene Salvador Mira
8 Daniel Vercher Lluch

The first returns zero rows and the second five. The cause, which you already diagnosed in 07-01, is the 10 web-channel orders with employee_id set to NULL: the list is {4, 5, 6, NULL} and 1 <> NULL is UNKNOWN, so NOT IN can't be TRUE for any row. NOT EXISTS doesn't suffer the same fate because it doesn't compare the outer value against a list: it asks whether the subquery produces rows. For employee 1, SELECT 1 FROM orders WHERE employee_id = 1 produces none —the rows with a null employee_id don't satisfy that equality either and are discarded like any other that doesn't match— and NOT EXISTS is TRUE. Three-valued logic acts inside the subquery, where it only decides which rows come back, and it never escapes into the predicate.

flowchart LR
    A["e.id = 1"] --> B{"NOT IN (4,5,6,NULL)"}
    B --> C["UNKNOWN → ❌ discarded"]
    A --> D{"NOT EXISTS<br/>(orders with employee_id = 1)"}
    D --> E["0 rows → TRUE → ✅ kept"]

The rule, in one sentence: NOT EXISTS is safe with nulls and NOT IN isn't. If you pick NOT EXISTS out of habit, you'll never have to wonder whether the subquery's column is nullable.

  1. The four ways of answering "what doesn't match"

In 03-07 it was announced that the four forms would be compared in module 7. Here they are, solving the same question —customers who have never bought— and all returning the same 3 rows (13, 14 and 15):

-- 1. NOT EXISTS
SELECT c.id, c.name FROM customers AS c
WHERE NOT EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id);
-- 2. NOT IN
SELECT c.id, c.name FROM customers AS c
WHERE c.id NOT IN (SELECT customer_id FROM orders);
-- 3. Anti-join (03-03)
SELECT c.id, c.name FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id WHERE o.id IS NULL;
-- 4. EXCEPT (03-07)
SELECT id FROM customers EXCEPT SELECT customer_id FROM orders;
NOT EXISTS NOT IN Anti-join EXCEPT
Behaviour with NULL Safe Dangerous: 0 rows Safe Safe (treats NULL as just another value)
Columns it can return All the outer ones All the outer ones All the outer ones Only the compared ones
Does it remove duplicates? It creates none It creates none It creates none Yes, always
Readability High: it reads like the question Very high… until it fails Medium: you have to know the idiom High, but limited
Typical performance Anti-join in the plan Worse if nulls are possible Anti-join in the plan Needs sorting/dedup
Portability Universal Universal Universal EXCEPT doesn't exist in MySQL 5.7 nor in Oracle (there it's MINUS)

The course's recommendation: use NOT EXISTS. It's safe, universal, it leaves all the outer table's columns available and it reads just like the business question. The anti-join is equally idiomatic and sometimes more natural if you were already joining those tables. EXCEPT, only when you're comparing sets of the same shape and don't need more columns. And NOT IN, only if you can guarantee the subquery's column is NOT NULL — and even then, you gain nothing. This table is closed off and extended with the performance criterion in 07-05.

  1. Short-circuiting: EXISTS against COUNT(*) > 0

It's tempting to write "is there any?" as "is the count greater than zero?". It works and gives the same result —12 rows both— but not the same work:

-- ⚠️ Correct but worse
SELECT c.id, c.name FROM customers AS c
WHERE (SELECT COUNT(*) FROM orders AS o WHERE o.customer_id = c.id) > 0;
-- ✅ Preferable
SELECT c.id, c.name FROM customers AS c
WHERE EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id);
COUNT(*) > 0 EXISTS
Rows the subquery reads All the customer's One: it stops at the first
With a customer of 50,000 orders Counts 50,000 Reads 1
What it tells the reader "how many there are, and compare it" "is there any?"

EXISTS short-circuits: as soon as it finds a row, it stops looking; COUNT can't, because to know how many there are it has to see them all. With 20 orders the difference is unmeasurable; with real tables it separates an instant query from a full scan. And there's a third argument, the most important day to day: EXISTS says what you mean. The same goes for the negation: COUNT(*) = 0 is NOT EXISTS written in a more expensive way.

  1. Relational division: the double negation

We arrive at the classic problem. The question looks innocent:

Which customers have bought products from every category?

And it can't be answered with a plain EXISTS, because EXISTS talks about "some", not about "all". The trick is to reformulate the sentence until the "all" becomes two chained "nones":

"has bought from EVERY category"
≡ "there is NO category from which they have NOT bought"

That reformulation —which in logic is ∀x P(x) ≡ ¬∃x ¬P(x)— translates literally into SQL:

SELECT c.id, c.name || ' ' || c.last_name AS customer
FROM customers AS c
WHERE NOT EXISTS (                              -- there is no category…
        SELECT 1
        FROM categories AS cat
        WHERE NOT EXISTS (                      -- …from which this customer hasn't bought
                SELECT 1
                FROM orders AS o
                JOIN order_lines AS ol ON ol.order_id = o.id
                JOIN products    AS p  ON p.id = ol.product_id
                WHERE o.customer_id  = c.id
                  AND p.category_id  = cat.id))
ORDER BY c.id;
(0 rows)

No customer at all, and the result isn't disappointing but diagnostic: Supplements has never sold anything (€0.00 in the course's figures), so nobody can have bought from all six categories. The query is correct; what's wrong is the question. Reframed over the four main categories —Food, Natural cosmetics, Sustainable home and Drinks— it's enough to narrow the middle subquery:

        SELECT 1
        FROM categories AS cat
        WHERE cat.id IN (1, 2, 3, 4)
          AND NOT EXISTS ( ... )
id customer
1 Lucía Martínez Soler

A single customer. Lucía is the only one who has bought from the four main categories, and she's managed it with three very different orders: number 1 (oil, rice and chamomile tea), number 5 (detergent, scrubber and bags) and number 15 (honey, shampoo and scrubber). It's exactly the profile a marketing team would want to identify: the customer who has explored the entire catalogue.

How to read this query without getting dizzy

Read it from the inside outwards, in three sentences:

Level What it asks For whom
Inner (NOT EXISTS 2) "has this customer not bought anything from this category?" Each customer-category pair
Middle (FROM categories) "is there any category satisfying the above?" Each customer
Outer (NOT EXISTS 1) "does none satisfy it?" → then they bought from them all Each customer

And a warning: there are two correlations in play. The inner one uses c.id (two levels outwards) and cat.id (one). If you forget one, the query is still valid and returns nonsense. Test the inner subquery with fixed values (c.id = 1, cat.id = 3) before assembling the three layers.

Note: there's a more readable alternative with GROUP BY and HAVING COUNT(DISTINCT p.category_id) = 4, shorter and often faster. The double negation deserves to be understood because it's the only one that works when the reference set isn't a simple count —"all the active categories", "all the products in a catalogue that changes"— and because it's the idiom you'll recognise when reading other people's code.

Common Mistakes and Tips

  • Writing an EXISTS with no correlation. WHERE EXISTS (SELECT 1 FROM orders) is TRUE for every row and returns the whole table: the condition linking to the outer row is the essential part.
  • Believing that SELECT * inside an EXISTS is slower. It isn't: the column list is never evaluated, and SELECT 1 and SELECT * produce the same plan.
  • Using NOT IN over a nullable column. Zero rows, with no warning at all; NOT EXISTS never has that problem. And writing COUNT(*) > 0 counts every row to answer something the first row settles.
  • Putting an ORDER BY or a LIMIT inside an EXISTS. They don't change the result and they add work. And confusing NOT EXISTS with EXISTS (... WHERE NOT ...). "Has no delivered order" is NOT EXISTS (... status = 'delivered'); "has some non-delivered order" is EXISTS (... status <> 'delivered'). They're different questions.
  • Tip: translate the question word by word. "Customers who have bought" → EXISTS. "Customers who have never bought" → NOT EXISTS. "Customers who have bought from all" → NOT EXISTS ( ... NOT EXISTS ( ... )). And there, be careful not to forget one of the two correlations: it raises no error and the result is rubbish.
  • Tip: use EXISTS when the JOIN would force you into a DISTINCT. If you only want to know whether there's a relationship and you need no data from the other table, avoid the row multiplication at the root. And always test subqueries with fixed values —replace c.id with 1— before wiring up the correlation.

Exercises

Exercise 1

Customer support wants an opinions campaign aimed at customers who have bought at some point but have never written a review. Write the query with EXISTS and NOT EXISTS in the same WHERE clause, showing id, full name, country and number of orders.

Then answer: how many customers are left out by each of the two conditions?

Exercise 2

A colleague needs the orders that contain no food product at all and has written this:

-- ⚠️ Suspicious
SELECT DISTINCT o.id
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id
JOIN products    AS p  ON p.id = ol.product_id
WHERE p.category_id <> 1;
  1. Explain why it's wrong and what question it really answers.
  2. Write it correctly with NOT EXISTS and give the number of rows.
  3. Could you solve it with an anti-join? And with NOT IN? Justify whether they'd be safe.

Exercise 3

Purchasing wants to know which suppliers have no unsold product: the ones whose references have all been sold at least once. Write it with a double NOT EXISTS. (Hint: same structure as relational division, but the reference set is the supplier's own products.)

Solutions

Solution 1

SELECT c.id,
       c.name || ' ' || c.last_name AS customer,
       c.country,
       (SELECT COUNT(*) FROM orders AS o WHERE o.customer_id = c.id) AS orders
FROM customers AS c
WHERE EXISTS     (SELECT 1 FROM orders  AS o WHERE o.customer_id = c.id)
  AND NOT EXISTS (SELECT 1 FROM reviews AS r WHERE r.customer_id = c.id)
ORDER BY c.id;
id customer country orders
5 Ana Belmonte Roca Spain 2
10 Julien Moreau France 1
12 Diego Ramos Herrera Spain 1

3 customers. How the 15 break down:

Condition Discards Who
EXISTS (orders) 3 Núria, Hugo and Inés: they've never bought, they have nothing to review
NOT EXISTS (reviews) 9 The 9 customers who have already written some review
Survivors 3 Ana, Julien and Diego

All three are perfect targets: they've bought, they're satisfied or we don't know, and we've never asked them for their opinion. Notice that the two conditions are inseparable: with NOT EXISTS (reviews) alone, 6 customers would come out, including three who have bought nothing.

Solution 2

1. What's wrong. The query answers "orders containing some product that isn't food", which is almost the opposite. An order with oil (category 1) and kombucha (category 4) has a line satisfying category_id <> 1, so it shows up — even though it does carry food. The mistake is one of quantifier: a JOIN's WHERE filters lines, and the question is about orders. The DISTINCT disguises the symptom (the repeated rows) without touching the cause.

2. The correct version:

-- ✅ CORRECT
SELECT o.id AS order_id, o.order_date, o.status
FROM orders AS o
WHERE NOT EXISTS (SELECT 1
                  FROM order_lines AS ol
                  JOIN products AS p ON p.id = ol.product_id
                  WHERE ol.order_id = o.id
                    AND p.category_id = 1)
ORDER BY o.id;
order_id order_date status
2 2025-03-12 delivered
5 2025-05-07 delivered
7 2025-06-11 delivered
9 2025-07-15 delivered
10 2025-08-03 delivered

(First 5 of 10 rows; the others are orders 12, 13, 16, 18 and 19.) 10 orders out of 20 carry not a single food product. The original query returned 18 —every order with some line from another category— and eight of them do buy food. Notice the change of logic: the condition p.category_id = 1 is written in the positive inside the EXISTS, and the NOT supplies the negation. That's the pattern of every question with "none".

3. With an anti-join, yes: FROM orders o LEFT JOIN (order_lines ol JOIN products p ON p.id = ol.product_id AND p.category_id = 1) ON ol.order_id = o.id WHERE ol.id IS NULL works, but you have to carry the category condition into the ON (if it goes in the WHERE it degrades the LEFT to an INNER, 03-03), which makes it considerably more fragile to write. With NOT IN it would also worko.id NOT IN (SELECT ol.order_id FROM order_lines ol JOIN products p ON ... WHERE p.category_id = 1)— and it would be safe because order_lines.order_id is NOT NULL. But you'd still be depending on a schema guarantee that could change tomorrow: NOT EXISTS depends on nothing.

Solution 3

SELECT s.id, s.name AS supplier, s.country
FROM suppliers AS s
WHERE NOT EXISTS (
        SELECT 1
        FROM products AS p
        WHERE p.supplier_id = s.id
          AND NOT EXISTS (SELECT 1 FROM order_lines AS ol
                          WHERE ol.product_id = p.id))
ORDER BY s.id;
id supplier country
1 Huerta del Turia Spain
2 BioSierra Ibérica Spain
3 Verde Atlántico Portugal

3 suppliers out of 5. It squares with the three products never sold: number 19 (Deodorant) is from Maison Nature, and number 13 (Candles) and number 20 (Spirulina) are from EcoNordic Supplies. Those two are left out; the other three have placed their entire catalogue.

The structure is identical to section 8's with a single difference: the reference set is correlated (p.supplier_id = s.id) instead of being the whole categories table. It's the most useful variant of the pattern in practice, and its literal reading is "there is no product of theirs for which no sale exists".

Conclusion

EXISTS is the subquery you'll write the most:

  • EXISTS is a predicate: it returns TRUE the moment the subquery produces one row, and never NULL. It doesn't look at the inner SELECT's columns —hence the SELECT 1 idiom, and even SELECT 1/0 works— and it short-circuits.
  • In practice it's always correlated: the condition linking it to the outer row is what gives it meaning.
  • It doesn't multiply rows. The 9 products with a review come out 9 times, not 12; the 6 orders with cosmetics come out 6, not 7. Where a JOIN would call for a DISTINCT, EXISTS doesn't need one.
  • NOT EXISTS solves GreenStore's four gaps with a single template: 3 customers with no purchase, 3 products never sold, 11 products with no review and 5 employees with no orders. And above all: it's safe with nulls and NOT IN isn't — the same question gives 0 rows with NOT IN and 5 with NOT EXISTS, because of the 10 web orders with a null employee_id.
  • You know the four ways of answering "what doesn't match" —NOT EXISTS, NOT IN, anti-join and EXCEPT— with their differences in nulls, available columns, duplicates and portability; the recommendation is NOT EXISTS. And you can translate an "all" with the double negation: no customer has bought from the six categories (Supplements never sold anything) and only Lucía has done so from the four main ones.

In the next lesson, subqueries in SELECT, FROM and WHERE, you'll stop looking at the what to look at the where: what changes depending on the clause you place the subquery in, why a derived table in the FROM needs an alias without exception, how you aggregate twice in a row to reach at last the €36.40 average order value from its source, and how two aggregates of different granularity are crossed so that the €727.95 of product and the €118.25 of shipping add up without inflating each other.

SQL Course

Module 1: Introduction to SQL

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved