Subqueries
By the end of this lesson
Use a query inside another, and know when a join is clearer.
A subquery is a SELECT inside another statement. You use one when the value you need to filter or compare against is itself the answer to a query.
"Products priced above average" is the clearest case. You cannot write WHERE unit_price > AVG(unit_price), because the average is a property of the whole table rather than of the row being tested. A subquery computes it first.
-- 1. Scalar: returns exactly one value, used like a constant
SELECT p.product_name, p.unit_price
FROM products AS p
WHERE p.unit_price > (SELECT AVG(unit_price) FROM products);
-- 2. List: returns one column of many values, used with IN
SELECT c.company_name
FROM customers AS c
WHERE c.customer_id IN (
SELECT o.customer_id
FROM orders AS o
WHERE o.order_date >= '2026-01-01'
);
-- 3. Derived table: returns a whole result used in FROM, and must be aliased
SELECT order_totals.order_id, order_totals.goods_value
FROM (
SELECT oi.order_id, SUM(oi.quantity * oi.unit_price) AS goods_value
FROM order_items AS oi
GROUP BY oi.order_id
) AS order_totals
WHERE order_totals.goods_value > 5000;- A scalar subquery must return one row and one column. If it returns more, the statement fails at run time — which is why an unconstrained scalar subquery is risky in production code.
- The list form is readable and works well when the inner query returns a modest number of values. Watch for nulls in that list: the previous lesson on WHERE covered why NOT IN breaks when one appears.
- A derived table needs an alias in SQL Server and PostgreSQL. Without it you get a syntax error that does not obviously say "add a name".
Correlated subqueries: run once per outer row
A correlated subquery refers to a column from the outer query, so it cannot be computed once up front. Conceptually it runs again for each candidate row.
That makes it expressive and potentially expensive. Query optimisers frequently rewrite a correlated subquery into a join internally, so it is not automatically slow — but on a large outer table with an unindexed correlation column, it can be dramatically slower than the equivalent join.
SELECT
c.company_name,
(SELECT MAX(o.order_date)
FROM orders AS o
WHERE o.customer_id = c.customer_id) AS last_order_date
FROM customers AS c
ORDER BY last_order_date DESC;- The inner query references c.customer_id, which belongs to the outer query. That reference is what makes it correlated.
- A customer with no orders gets NULL rather than being dropped, which is the same behaviour a LEFT JOIN would give and is usually what you want here.
- The equivalent LEFT JOIN with GROUP BY returns the same result. Which is faster depends on the data and the indexes, so this is a case for measuring rather than assuming.
EXISTS asks a yes-or-no question
-- Customers who have ordered at least one discontinued product
SELECT c.company_name
FROM customers AS c
WHERE EXISTS (
SELECT 1
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
JOIN products AS p ON p.product_id = oi.product_id
WHERE o.customer_id = c.customer_id
AND p.discontinued = 1
);
-- Products that have never been ordered
SELECT p.product_name, p.unit_price
FROM products AS p
WHERE NOT EXISTS (
SELECT 1
FROM order_items AS oi
WHERE oi.product_id = p.product_id
);- EXISTS returns true as soon as the inner query produces one row, so it can stop early instead of building a full result.
- SELECT 1 is conventional inside EXISTS. Nothing reads the value — only whether a row came back — so there is no reason to fetch columns.
- NOT EXISTS is the reliable way to express "has no matching row". Unlike NOT IN, a NULL in the inner data cannot make it return nothing, because EXISTS only ever answers true or false.
Subquery or join?
Both can often express the same question. Pick on readability first, then measure if performance matters.
| Prefer a subquery | Prefer a join | |
|---|---|---|
| You need | A single value to compare against, or a yes/no existence test | Columns from both tables in the result |
| Row count risk | None — the outer row count is unchanged | Real — a one-to-many join multiplies rows |
| Reads best when | The inner question stands alone: "the average price" | You are genuinely combining two sets of columns |
| Typical pitfall | A correlated subquery in the SELECT list, repeated per row | Duplicated rows inflating a SUM |
| Deduplication needed | No | Sometimes, and reaching for DISTINCT usually signals a design problem |
One genuinely useful property of the subquery form: it cannot multiply your rows. "Customers who ordered in 2026" written with IN or EXISTS returns one row per customer, guaranteed. The same question written as a join returns one row per matching order, so you need DISTINCT or a GROUP BY to get back to one row per customer.
When you only want to filter, and not to display anything from the other table, EXISTS states the intent more precisely than a join plus DISTINCT.
Summary
- A subquery supplies a value, a list or a whole table to the query around it
- A correlated subquery references the outer row, so it is evaluated per row and scales with the outer result
- EXISTS and NOT EXISTS answer existence questions and are immune to the NULL problem that breaks NOT IN
- Subqueries cannot multiply your rows, which makes them the better choice when you only need to filter
- Deeply nested subqueries are valid and hard to read — that is what CTEs are for
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Find every product that has never appeared on an order. Write it twice: once with NOT EXISTS, once with a LEFT JOIN. Then say which you would put in a report and why.
Show solution
Both return the same rows. The NOT EXISTS version states the question almost word for word: products for which no order item exists.
The LEFT JOIN version relies on an idiom — an unmatched row leaves the right side NULL, so WHERE oi.order_item_id IS NULL isolates them. It is a standard pattern, and it needs a moment's thought to read.
For a report, NOT EXISTS reads better and cannot duplicate rows. If you also wanted to show something from order_items, the join would be the right tool, because a subquery in WHERE gives you no columns to display.
Performance is data-dependent. With an index on order_items.product_id both are usually fast, and the optimiser may well produce the same plan for each.
-- Version 1: NOT EXISTS
SELECT p.product_name, p.unit_price
FROM products AS p
WHERE NOT EXISTS (
SELECT 1 FROM order_items AS oi WHERE oi.product_id = p.product_id
);
-- Version 2: LEFT JOIN with a NULL check
SELECT p.product_name, p.unit_price
FROM products AS p
LEFT JOIN order_items AS oi ON oi.product_id = p.product_id
WHERE oi.order_item_id IS NULL;Think about it
Think about it
A query lists 80,000 customers, and for each one a correlated subquery counts their orders and another sums their revenue. It takes 90 seconds. What is happening, and what would you change?
Show solution
Two correlated subqueries mean up to 160,000 inner executions against orders. Even at half a millisecond each, that is over a minute of work.
Both values come from the same table and the same set of rows, so they can be produced in one pass: group orders by customer_id once, then join that single result to customers.
That turns 160,000 small lookups into one aggregation plus one join. Reported timings vary with data and indexes, so measure rather than quote a figure — but reductions from a minute-plus to a second or two are typical for this rewrite.
The wider point: a correlated subquery in the SELECT list is fine for one value on a small result. It scales badly with both the number of outer rows and the number of subqueries.
SELECT
c.company_name,
COALESCE(agg.order_count, 0) AS order_count,
COALESCE(agg.revenue, 0) AS revenue
FROM customers AS c
LEFT JOIN (
SELECT
o.customer_id,
COUNT(DISTINCT o.order_id) AS order_count,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.status <> 'cancelled'
GROUP BY o.customer_id
) AS agg ON agg.customer_id = c.customer_id
ORDER BY revenue DESC;Saved in this browser only.