All 22 modules are open from the start — nothing here is
locked, and nothing costs anything. Sign in so your progress, titles and
credentials stay with you, on every device you use.
Free forever, with your Google account. No password, no payment.
A query inside a query, and the negation that returns nothing
A subquery is a SELECT written inside another statement. There
are only four places one can go, and where it sits decides what it is
allowed to return. One of those forms has a failure mode so quiet it is
worth the whole module on its own: a NOT IN that hands back an
empty result and no error.
Ready?
1
Where It Sits Decides What It May Return
Some questions cannot be answered in one pass. "Orders above the
average" needs the average before anything can be compared to
it — and WHERE amount > AVG(amount) is rejected, because
WHERE runs before rows are aggregated. There is no value to
compare against yet.
A subquery is the answer: a complete SELECT, in brackets,
inside another statement. There are four places it can go, and the
position is what sets the rules.
1
In WHERE, next to = or >
Must return one row, one column. A scalar subquery — it stands exactly where a plain value would.
2
In WHERE, with IN or EXISTS
May return many rows — or none. A membership or existence test rather than a value.
3
In the SELECT list
One row, one column, evaluated per output row. It becomes a column.
4
In FROM
A whole result set, used as a table. A derived table.
SELECT order_id, amount
FROM orders
WHERE status = 'paid'
AND amount > (SELECT AVG(amount) FROM orders WHERE status = 'paid');
That inner query never mentions the outer one, so it runs
once and its single number is reused for every row. A
subquery like that is uncorrelated, and you can always test it
by running it on its own.
The filter belongs in both places
Notice status = 'paid' appears twice — once outside,
once inside. Drop the inner one and you are comparing paid orders
against an average that includes refunds and pending: 81.29 rather
than 90.05. In this dataset that happens to select the same eight
rows, which is exactly the kind of luck that turns a bug into a habit.
Quick check
You write SELECT name, (SELECT country FROM users) AS c FROM products. What happens?
2
Membership, Existence, and the Bug That Returns Nothing
IN took a typed list back in module 1-02. It will just as
happily take a computed one — the subquery must return exactly one
column, and may return any number of rows.
WHERE user_id IN (SELECT user_id FROM orders)
This is a membership test, not a join: it cannot widen the result or
duplicate a row, which is its advantage when you want no columns from
the other table. A join to orders would return one row per
order; this returns one row per user.
EXISTS asks a plainer question — did the subquery produce
any row at all? What is in those rows is never examined, which is why
the convention is to select the literal 1.
WHERE EXISTS (SELECT 1 FROM orders o
WHERE o.user_id = u.user_id AND o.status = 'paid')
And now the trap
"Which users have referred nobody" looks like a job for
NOT IN. Run it against this database and you get
zero rows. The honest answer is six.
WHERE user_id NOT IN (SELECT referred_by FROM users) -- 0 rows. Not one.
referred_by is NULL for the four users who arrived on their
own, so that list contains a NULL. And NOT IN expands into
a chain of inequalities:
x NOT IN (1, 2, NULL)
→ x <> 1 AND x <> 2 AND x <> NULL
└── UNKNOWN, forever
A comparison with NULL is never true and never false — it is
unknown. An AND chain ending in unknown can
never evaluate to true, for any row, so WHERE keeps
nothing. This is the three-valued logic of module 1-05, arriving
somewhere it does real damage.
Silently empty
NOT IN with a nullable column
One NULL anywhere in the list empties the entire result. No error, no warning — just a report that says nothing qualified.
Immune
NOT EXISTS
Never compares values, so nothing can come back unknown. It only asks whether rows appeared. Reach for this when negating.
The other fix is WHERE referred_by IS NOT NULL inside the
subquery. Note that plain IN is unaffected — a NULL in the
list simply never matches, which is what you would expect. Only the
negation breaks.
Quick check
A nightly job uses WHERE id NOT IN (SELECT parent_id FROM items) to find orphans. It worked for months, then started returning nothing. What most likely changed?
3
When the Inner Query Can See the Outer Row
Every subquery so far could have been run on its own. A
correlated subquery cannot: it references the outer
query, so it is re-evaluated once for each outer row, with that row's
values in scope.
That is what makes a genuinely awkward question expressible — comparing
each row not to a global aggregate, but to an aggregate of its
own group.
SELECT o.order_id, u.country, o.amount
FROM orders o
JOIN users u ON u.user_id = o.user_id
WHERE o.amount > (SELECT AVG(o2.amount)
FROM orders o2
JOIN users u2 ON u2.user_id = o2.user_id
WHERE u2.country = u.country);
The inner query has its own aliases — o2 and
u2 — and that is not decoration. It is what lets
u.country in the inner WHERE unambiguously
mean the outer row's country. Reuse u inside and
the reference resolves to the inner copy: the correlation vanishes, the
query still runs, and it silently computes a global average instead.
An easy self-test
Copy the subquery out and run it alone. If it works, it was
uncorrelated. If it fails with an unknown column, it was correlated —
and that failure is the proof it is doing what you wanted.
The same correlation in the SELECT list gives a column
computed per row, which is a real alternative to
LEFT JOIN … GROUP BY:
SELECT u.name,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.user_id) AS orders
FROM users u;
And it dodges a trap from 3-02 for free. COUNT over no rows
is 0, so a user who never ordered comes back as 0 — no
padded row to accidentally count, no COALESCE needed.
Correlated subqueries are the portable way to write "compared with its
own group". Part 4 does the same thing with window functions, which are
usually faster and often clearer.
Quick check
In the country example above, you write the inner query using u instead of u2. What happens?
4
A Subquery Used as a Table
The fourth position is the most powerful. A subquery in
FROM is a derived table — a whole result
set treated exactly as though it were a table in the database.
It solves a specific and common problem: wanting to filter, or
aggregate, on something you had to compute first. You cannot put an
aggregate in WHERE, and HAVING only tests the
groups of the current query. So aggregate in an inner query, and treat
its output as ordinary rows.
SELECT t.name, t.spent
FROM (SELECT u.name, SUM(o.amount) AS spent
FROM users u
JOIN orders o ON o.user_id = u.user_id
WHERE o.status = 'paid'
GROUP BY u.user_id, u.name) t
WHERE t.spent > 200;
Postgres requires the alias. That trailing
t is not optional — omit it and you get
subquery in FROM must have an alias. Every column the inner
query produced is then reached through it.
Two levels of aggregation
The shape that genuinely needs this is an aggregate of an
aggregate. "The average customer's total spend" is not the average
order — it is the average of the per-customer totals, and those totals
have to exist before they can be averaged.
SELECT AVG(spent)
FROM (SELECT SUM(amount) AS spent
FROM orders
WHERE status = 'paid'
GROUP BY user_id) z; -- 210.11, not 90.05
Those two numbers answer different questions and it is easy to reach for
the wrong one. 90.05 is the average order; 210.11 is the
average customer. Comparing customer totals against 90.05 would
report almost everybody as above average — a result suspicious enough to
catch, if anyone looks.
There is a nicer spelling coming
Nested derived tables get hard to read quickly — the query is assembled
inside out, and the thing computed first appears deepest in the text.
Module 3-06 introduces the WITH clause, which is the same
machinery written top to bottom with names on each step. It is the
last module of Part 3, and it exists mostly to make this readable.
Quick check
Which of these genuinely needs a derived table rather than a HAVING clause?
0 of 9 completed
Loading the tables…
01
To do
"Orders above the average" cannot be written in one pass. You need the
average before you can compare anything to it, and
WHERE amount > AVG(amount) is rejected — an aggregate
cannot appear in WHERE, because WHERE runs before the rows
are aggregated.
A scalar subquery solves it. Put a whole
SELECT in brackets where a value would go; it runs first,
returns exactly one row and one column, and its result is used as that
value.
WHERE amount > (SELECT AVG(amount) FROM orders WHERE status = 'paid')
The inner query is completely independent — it does not mention the
outer one, so it is evaluated once and the number reused for every row.
Your task: return order_id and
amount for every paid order worth more
than the average paid order, ordered by order_id.
query.sql
PostgreSQLCtrl↵ to run
Hint
The bracketed query is (SELECT AVG(amount) FROM orders WHERE status = 'paid') — the same paid filter inside, or you compare paid orders against an average that includes refunds.
Output
02
To do
The second place a subquery can sit is the SELECT list,
where it produces a column. The rule is stricter here: it must return
one row and one column, for every row of the outer
query — more than one row and Postgres raises
more than one row returned by a subquery used as an
expression.
This one is correlated: it mentions u from
the outer query, so it is re-evaluated for each user rather than once.
SELECT u.name, (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.user_id) AS orders FROM users u
It is a genuine alternative to LEFT JOIN … GROUP BY, and it
has one real advantage: COUNT over no rows is 0, so users
who never ordered come back as 0 with no
COALESCE and no risk of counting a padded row.
Your task: return every user's name and
their number of orders as orders, using a subquery in the
SELECT list, ordered by u.user_id.
query.sql
PostgreSQLCtrl↵ to run
Hint
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.user_id) AS orders — the reference to u.user_id is what makes it run once per user.
Output
03
To do
Back in module 1-02, IN took a list you typed:
WHERE country IN ('UK', 'US'). It will just as happily take
a list a query produces.
WHERE user_id IN (SELECT user_id FROM orders)
The subquery must return exactly one column; it may
return any number of rows, including none. This is a membership test —
"is this value among those?" — and it does not widen the result or
duplicate anything, which is a real advantage over a join when you want
no columns from the other table.
Your task: return user_id and
name for every user who has placed at least one order,
using IN, ordered by user_id.
query.sql
PostgreSQLCtrl↵ to run
Hint
(SELECT user_id FROM orders) — one column, however many rows. Duplicates in that list are harmless; IN only asks whether the value is present.
Output
04
To do
This is the most notorious silent bug in SQL, it is live in this
database, and it produces an empty result rather than an
error.
"Which users have never referred anybody" looks like a job for
NOT IN against referred_by. Run it and you get
zero rows. The honest answer is six.
WHERE user_id NOT IN (SELECT referred_by FROM users) -- 0 rows
referred_by is NULL for four users, so the list contains a
NULL. x NOT IN (1, 2, NULL) expands to
x <> 1 AND x <> 2 AND x <> NULL — and
that last comparison is UNKNOWN, never false. The whole
AND chain can therefore never be true, for any row. The
three-valued logic of module 1-05, arriving where it does real damage.
Two fixes: exclude the NULLs inside the subquery, or use
NOT EXISTS, which is immune. Plain IN is
unaffected — only the negation breaks.
Your task: return user_id and
name for every user who has referred nobody, using
NOT IN with the NULLs excluded, ordered by user_id.
query.sql
PostgreSQLCtrl↵ to run
Hint
Add WHERE referred_by IS NOT NULL inside the subquery. One NULL in that list is enough to empty the entire result.
Output
05
To do
EXISTS takes a subquery and returns true if it produced
any row at all. It does not care what is in those rows,
which is why the convention is to select the literal
1 — writing SELECT * is equally valid and
slightly misleading, because nothing is ever read.
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.user_id AND o.status = 'paid')
It is nearly always correlated — the inner query
references the outer row, which is what makes it a question about
this user rather than about the table in general.
Compared with IN, it handles multi-column conditions
naturally, and it can stop at the first matching row rather than
building a whole list.
Your task: return user_id and
name for every user with at least one paid
order, using EXISTS, ordered by user_id.
query.sql
PostgreSQLCtrl↵ to run
Hint
(SELECT 1 FROM orders o WHERE o.user_id = u.user_id AND o.status = 'paid') — the o.user_id = u.user_id is the correlation and the whole point.
Output
06
To do
NOT EXISTS is true when the subquery returns
nothing. It is the third way of asking the question
that 3-02 answered with a LEFT JOIN and 3-04 answered with
EXCEPT.
Its decisive advantage over NOT IN is that it is
immune to the NULL trap. It never compares values, so
there is no comparison to come back UNKNOWN — it only asks whether rows
came back. When negating, this is the default worth reaching for.
1
LEFT JOIN … IS NULL
Use when you also want columns from the other table.
2
EXCEPT
Use when both sides are the same shape and you want a plain list of keys.
3
NOT EXISTS
Use when the condition is more than one column, or NULLs are in play.
Your task: return user_id and
name for every user who has never placed an order, using
NOT EXISTS, ordered by user_id.
query.sql
PostgreSQLCtrl↵ to run
Hint
(SELECT 1 FROM orders o WHERE o.user_id = u.user_id) — no status condition this time, because "never placed an order" means none of any kind.
Output
07
To do
The fourth place, and the most powerful. A subquery in FROM
is a derived table: a whole result set, used exactly as
though it were a table.
It solves the problem of wanting to filter on something you had to
compute first. You cannot put an aggregate in WHERE, and
HAVING only tests the groups of the current query — so
aggregate in an inner query, then treat its output as rows.
FROM (SELECT u.name, SUM(o.amount) AS spent FROM users u JOIN orders o ON o.user_id = u.user_id WHERE o.status = 'paid' GROUP BY u.user_id, u.name) t WHERE t.spent > 200
Postgres requires the alias — the t at the
end. Leave it off and you get
subquery in FROM must have an alias. Everything the inner
query named is then reached through it.
Your task: return name and
spent for every user whose total paid
spend is over 200, using a derived table, ordered by spent descending.
query.sql
PostgreSQLCtrl↵ to run
Hint
Inside the brackets: SELECT u.name, SUM(o.amount) AS spent FROM users u JOIN orders o ON o.user_id = u.user_id WHERE o.status = 'paid' GROUP BY u.user_id, u.name.
Output
08
To do
A correlated subquery re-runs for every outer row, and can see that
row's values. That makes a question expressible which is genuinely awkward
otherwise: compare each row against an average computed from
only its own group.
WHERE o.amount > (SELECT AVG(o2.amount) FROM orders o2 JOIN users u2 ON u2.user_id = o2.user_id WHERE u2.country = u.country)
The inner query has its own aliases — o2,
u2 — precisely so that u.country unambiguously
means the outer row's country. Reuse the outer names inside and the
correlation silently disappears.
Correlated subqueries are the honest, portable way to write this. Part 4
does it again with window functions, which are usually faster and
occasionally clearer.
Your task: return order_id,
country and amount for every order worth more
than the average order in that order's own country,
ordered by order_id.
query.sql
PostgreSQLCtrl↵ to run
Hint
The inner query needs its own aliases o2 and u2, and its WHERE compares u2.country = u.country — inner to outer.
Output
09
To do
Two quantifiers that turn a comparison into a statement about a whole
list. They are rarely necessary — an aggregate usually says the same
thing — but they read well and they turn up in other people's queries.
ALL
True against every row
price > ALL (…) means greater than the largest. Same as > (SELECT MAX(…)).
ANY
True against at least one
price > ANY (…) means greater than the smallest. = ANY is exactly IN.
One caution, familiar by now: an empty subquery makes
ALL vacuously true and ANYfalse, and NULLs inside the list can make either
UNKNOWN. The MAX/MIN form is usually easier to
reason about.
Your task: return name and
price for every product costing more than
every product in the addon category, using
ALL, ordered by price descending.
query.sql
PostgreSQLCtrl↵ to run
Hint
(SELECT price FROM products WHERE category = 'addon') — greater than all of them means greater than the most expensive addon.
Output
The spending report
To do
Finance wants each paying customer's total, and — for each one — whether
they are above or below what a typical paying customer spends. That
benchmark is not a column anywhere. It has to be computed from the
per-customer totals, which themselves have to be computed first.
Your task: one row per user who has at least one
paid order, with:
name — the user's name
country — their country
spent — their total paid amount
vs_average — the text above if their total beats the average per-user paid total, otherwise below
Order by spent descending, then name.
Two levels of aggregation, so two subqueries. Note carefully what the
benchmark averages: not the average order (90.05) but the average
user total (210.11). Getting that wrong returns a report where
almost everybody is above average, which should itself look suspicious.
query.sql
PostgreSQLCtrl↵ to run
Hint
The derived table is SELECT u.user_id, u.name, u.country, SUM(o.amount) AS spent FROM users u JOIN orders o ON o.user_id = u.user_id WHERE o.status = 'paid' GROUP BY u.user_id, u.name, u.country. The benchmark is SELECT AVG(spent) FROM (SELECT SUM(amount) AS spent FROM orders WHERE status = 'paid' GROUP BY user_id) z — an average of totals, so it needs its own derived table.