Subqueries
Checking for related rows with EXISTS and NOT EXISTS
A very common database question is:
Does a related row exist?
For example:
1234567Has this customer ever placed an order?Does this product appear in any order?Does this customer have no orders?Has this user already completed this lesson?PostgreSQL provides:
1EXISTSfor exactly this kind of question.
Creating a customer without orders
Every customer in our seeded database that has orders would answer EXISTS the same way, so add one temporary customer with none:
1234567891011121314INSERT INTO customers ( id, email, full_name, country, created_at)VALUES ( 10001, 'NoOrders@Example.com', 'Customer Without Orders', 'IN', '2025-01-01');We won't create any orders for this customer.
Now we have two useful cases:
12345Customer 1→ has ordersCustomer 10001→ has no ordersEXISTS
Suppose we want customers who have at least one order.
Run:
1234567891011SELECT c.id, c.full_nameFROM customers cWHERE c.id IN (1, 10001) AND EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.id )ORDER BY c.id;You should see only customer 1:
123 id | full_name----+------------ 1 | Customer 1The important part is:
12345EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.id)For each customer being considered, the subquery asks whether a matching order exists.
If the subquery produces at least one row:
1EXISTS → TRUEIf it produces no rows:
1EXISTS → FALSEConceptually:
12345678910111213Customer 1 │ ▼look for orders with customer_id = 1 │ ▼matching row exists │ ▼TRUE │ ▼keep customerwhile:
12345678910111213Customer 10001 │ ▼look for orders with customer_id = 10001 │ ▼no matching row │ ▼FALSE │ ▼remove customer12345customers ordersid = 1 ──────▶ customer_id = 1 ✓ ──▶ EXISTS = TRUE ──▶ keptid = 10001 ──────▶ (no row) ──▶ EXISTS = FALSE ──▶ droppedThis is a correlated subquery
Look at:
1WHERE o.customer_id = c.ido.customer_id belongs to the inner query.
But:
1c.idbelongs to the outer query.
The inner query therefore depends on the current customer from the outer query.
This is a correlated subquery.
Conceptually:
1234567outer rowcustomer id = 1 │ ▼subquery checks ordersWHERE customer_id = 1Then for another outer row:
1234567outer rowcustomer id = 10001 │ ▼subquery checks ordersWHERE customer_id = 10001Correlation describes this logical dependency.
It doesn't guarantee that PostgreSQL physically executes one complete subquery for every outer row. The planner can transform the query and choose an efficient execution strategy.
You can see that for yourself later in this chapter, where the execution plan for a correlated EXISTS turns out to contain no per-row loop at all.
Why SELECT 1?
Inside EXISTS, we wrote:
1SELECT 1instead of:
1SELECT *or:
1SELECT o.idFor EXISTS, the selected value normally doesn't matter.
PostgreSQL cares about:
1Did the subquery return a row?not:
1What value did that row contain?So this:
12345EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.id)is a common way of expressing the intent clearly.
You can mentally read it as:
1Does at least one matching order exist?NOT EXISTS
Now suppose we want the opposite:
Customers who have no orders.
Use:
1NOT EXISTSRun:
1234567891011SELECT c.id, c.full_nameFROM customers cWHERE c.id IN (1, 10001) AND NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.id )ORDER BY c.id;You should see:
123 id | full_name-------+------------------------- 10001 | Customer Without OrdersFor customer 1, the subquery finds orders:
12EXISTS → TRUENOT EXISTS → FALSEFor customer 10001, it finds none:
12EXISTS → FALSENOT EXISTS → TRUEConceptually:
12345678910111213Customer 10001 │ ▼search for matching order │ ▼none found │ ▼NOT EXISTS = TRUE │ ▼keep customerA common interview pattern
Finding rows with no related records is one of the most useful NOT EXISTS patterns.
Every seeded product already appears in an order item, so add one that does not:
12345678INSERT INTO products (id, sku, name, category, price)VALUES ( 10001, 'SKU-010001', 'Product Never Ordered', 'audio', 19.99);Now find products that have never appeared in an order item:
123456789SELECT p.id, p.nameFROM products pWHERE NOT EXISTS ( SELECT 1 FROM order_items oi WHERE oi.product_id = p.id);You should see:
123 id | name-------+----------------------- 10001 | Product Never OrderedThe question is:
12For this product,does any order_items row reference it?If not, keep the product.
The same pattern works for:
123456789customers with no ordersproducts never orderedusers with no subscriptionsposts with no commentsstudents who haven't submitted an assignmentEXISTS versus IN
Some queries can be expressed using either IN or EXISTS.
For example:
12345678SELECT c.id, c.full_nameFROM customers cWHERE c.id IN ( SELECT o.customer_id FROM orders o);asks:
1Is this customer ID in the set of customer IDs returned by orders?The EXISTS version is:
123456789SELECT c.id, c.full_nameFROM customers cWHERE EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.id);which asks:
1Does at least one order exist for this customer?Both can express the same requirement here.
The difference worth remembering is semantic:
12345IN→ Is this value present in this set?EXISTS→ Does at least one matching row exist?For a question such as:
1Has this customer placed an order?EXISTS often expresses the intent particularly clearly.
Do not assume one is automatically faster.
PostgreSQL's query planner may produce similar or different execution strategies depending on the query and data.
If performance matters, inspect the execution plan.
EXISTS only needs existence
Logically, EXISTS doesn't need all matching rows.
As soon as the condition:
1at least one row existshas been established, additional matching rows cannot change the result from TRUE to anything else.
This is why EXISTS is a natural expression for existence checks.
However, you should still let PostgreSQL's planner decide how to execute the complete query rather than assuming a particular physical loop or lookup strategy.
The NOT IN and NULL problem
There is an important difference between:
1NOT EXISTSand some uses of:
1NOT INwhen NULL values are involved.
Consider this small set:
121NULLNow run:
123456789SELECT 2 AS idWHERE 2 NOT IN ( SELECT value FROM ( VALUES (1), (NULL) ) AS values_table(value));You might expect:
12because 2 is not equal to 1.
But the set also contains NULL.
SQL cannot determine whether the unknown value represented by NULL might equal 2.
The condition therefore does not evaluate to TRUE.
As a result, the query returns no row.
Conceptually:
123456782 <> 1→ TRUE2 <> NULL→ UNKNOWNTRUE combined with UNKNOWN→ not enough to make NOT IN TRUEThis is one of the classic SQL NULL traps, and it follows directly from the three-valued logic you saw in the NULL values chapters.
The NOT EXISTS version
Now express the requirement directly:
12345678910SELECT 2 AS idWHERE NOT EXISTS ( SELECT 1 FROM ( VALUES (1), (NULL) ) AS values_table(value) WHERE value = 2);There is no row where:
1value = 2so NOT EXISTS is TRUE.
The query returns:
123 id---- 2For "find rows for which no matching related row exists" problems, NOT EXISTS often avoids the surprising NULL semantics associated with NOT IN.
NOT IN is not always wrong
This doesn't mean:
1Never use NOT IN.If you know the values returned by the subquery cannot contain NULL, NOT IN can be perfectly valid.
For example, a subquery returning a column defined as:
1NOT NULLdoes not introduce that particular NULL problem.
The important point is to understand the semantics rather than memorize a blanket rule.
EXISTS and joins
We could solve some existence problems using a join.
For example:
123456SELECT DISTINCT c.id, c.full_nameFROM customers cINNER JOIN orders o ON o.customer_id = c.id;can also identify customers with orders.
But notice why DISTINCT may be necessary.
One customer can have many orders:
123Customer 1 + Order ACustomer 1 + Order BCustomer 1 + Order CA join naturally produces multiple rows.
EXISTS asks a different question:
1Is there at least one matching row?If that is the actual requirement, EXISTS can communicate the intent directly without producing the matching order rows.
This is a semantic reason to choose EXISTS, not a blanket performance rule.
Use EXPLAIN ANALYZE when performance matters
Avoid rules such as:
12345EXISTS is always faster than INJOIN is always faster than a correlated subquerysubqueries are slowPostgreSQL has a cost-based query planner and can transform SQL into different execution plans.
If a query matters for application performance, inspect it:
12345678910EXPLAIN ANALYZESELECT c.id, c.full_nameFROM customers cWHERE EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.id);You should see a plan like this:
123456789101112131415161718192021 QUERY PLAN------------------------------------------------------------------------------------------------------------------------------------- Hash Join (cost=2495.78..2837.16 rows=9990 width=17) (actual time=33.792..37.449 rows=9981.00 loops=1) Hash Cond: (c.id = o.customer_id) Buffers: shared hit=1125 -> Seq Scan on customers c (cost=0.00..204.00 rows=10000 width=17) (actual time=0.013..1.062 rows=10000.00 loops=1) Buffers: shared hit=104 -> Hash (cost=2370.90..2370.90 rows=9990 width=4) (actual time=33.774..33.775 rows=9981.00 loops=1) Buckets: 16384 Batches: 1 Memory Usage: 337kB Buffers: shared hit=1021 -> HashAggregate (cost=2271.00..2370.90 rows=9990 width=4) (actual time=31.005..32.338 rows=9981.00 loops=1) Group Key: o.customer_id Batches: 1 Memory Usage: 473kB Buffers: shared hit=1021 -> Seq Scan on orders o (cost=0.00..2021.00 rows=100000 width=4) (actual time=0.054..12.979 rows=100000.00 loops=1) Buffers: shared hit=1021 Planning: Buffers: shared hit=70 Planning Time: 0.268 ms Execution Time: 38.239 ms(18 rows)Your timing and buffer values will probably differ from the ones shown here.
This plan is worth reading closely. The SQL describes a correlated subquery running once per customer, but the plan contains a Hash Join over a HashAggregate of distinct customer_id values. PostgreSQL scanned orders once, not ten thousand times.
That is the concrete evidence for the earlier claim: correlation describes the logical dependency, not the execution strategy.
Then reason about the plan PostgreSQL actually selected.
Clean up
Remove the temporary rows created in this chapter:
12345DELETE FROM customersWHERE id = 10001;
DELETE FROM productsWHERE id = 10001;The core mental model
Use:
12EXISTS→ Keep the outer row if at least one matching inner row exists.Use:
12NOT EXISTS→ Keep the outer row if no matching inner row exists.And remember:
12correlated subquery→ the inner query uses values from the current outer rowThe important point is:
`EXISTS` and `NOT EXISTS` express whether a relationship exists without requiring the matching rows themselves to appear in the result.