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.
The last module of Part 3, and it does two jobs. Most of it is
presentational: WITH is the derived table of 3-05 written top
to bottom with a name on each step. Then there is the recursive form, which
is the only construct in this course that can produce rows nobody stored.
Ready?
1
The Same Query, Read in the Order It Happens
A nested query is assembled inside out. The step that runs
first is written deepest, and a reader has to find the
innermost bracket before anything makes sense. Two levels of that is
awkward; three is genuinely hard to follow.
A common table expression is the same computation with
a name on it, lifted to the top.
Inside out
Derived table
SELECT t.name, t.spent FROM (SELECT u.name, SUM(o.amount) AS spent FROM users u JOIN orders o … GROUP BY u.user_id, u.name) t WHERE t.spent > 200;
Top to bottom
CTE
WITH spend AS ( SELECT u.name, SUM(o.amount) AS spent FROM users u JOIN orders o … GROUP BY u.user_id, u.name ) SELECT name, spent FROM spend WHERE spent > 200;
Identical results, identical work. What changed is that the first thing
that happens is the first thing you read, and the step has a name you
can say out loud.
Several CTEs go in one WITH, separated by commas, and
each may use the ones before it:
WITH spend AS ( … ),
benchmark AS (SELECT AVG(spent) AS avg_spent FROM spend)
SELECT s.name, s.spent
FROM spend s CROSS JOIN benchmark b
WHERE s.spent > b.avg_spent;
There is only oneWITH, at the very top.
The comma introduces each further name; writing WITH again
is a syntax error. The names last for that statement only — nothing is
stored and nothing survives the query.
Name the step, not the mechanism
The real argument for WITH is the next person to read the
query, who is often you in six months. Compare "the subquery in the
FROM clause of the left join" with active_buyers. One of
those can be discussed in a meeting; the other has to be traced with a
finger.
Quick check
You write WITH a AS (…) WITH b AS (…) SELECT …. What happens?
2
One Real Capability, and One Honest Caveat
Almost everything about WITH is presentation. There is
exactly one thing it can do that a derived table cannot:
a CTE can be referenced more than once.
WITH spend AS ( … )
SELECT (SELECT COUNT(*) FROM spend) AS n,
(SELECT ROUND(AVG(spent), 2) FROM spend) AS avg_spent;
One definition, two uses. As a derived table that same query would need
the whole subquery pasted in twice — and then kept in step forever,
which is the kind of duplication that goes wrong the first time somebody
edits one copy.
Any query that compares a set against a summary of itself has
this shape, and it is common: rows against their own average, this
month against the trailing twelve, each region against the total.
The caveat
A CTE is not free. Historically Postgres always materialised one — ran
it fully, stored the result, then used it — which meant a filter in the
outer query could not be pushed down into it. Since Postgres 12 a CTE
used exactly once is inlined by default and optimised as though you had
nested it, so the two forms usually perform identically now.
Where it still matters you can say so explicitly, with
AS MATERIALIZED or AS NOT MATERIALIZED. That
is a tuning concern rather than a correctness one, and worth knowing
exists rather than reaching for.
Quick check
Which of these genuinely cannot be rewritten as a derived table without repeating yourself?
3
A Query That Walks a Tree
Now the part that is genuinely new. A self join reaches
one level of a hierarchy: a user and their referrer.
Two self joins reach two levels. But "everyone in the referral chain,
however deep it goes" cannot be written that way at all — you would need
to know the depth when writing the query, and you do not.
WITH RECURSIVE has exactly two branches, joined by
UNION ALL:
WITH RECURSIVE tree AS (
SELECT user_id, name, 1 AS depth -- ANCHOR: runs once
FROM users
WHERE referred_by IS NULL
UNION ALL
SELECT u.user_id, u.name, t.depth + 1 -- RECURSIVE TERM: re-runs
FROM users u
JOIN tree t ON u.referred_by = t.user_id -- ...against the CTE itself
)
SELECT name, depth FROM tree ORDER BY depth, name;
1
The anchor runs once
It seeds the result — here, the four users who arrived on their own. It is the only branch that could run on its own.
2
The recursive term re-runs
Against only what the previous pass produced, not the whole accumulated result. Pass two finds the people the four roots referred; pass three finds the people those referred.
3
It stops when a pass returns nothing
Eventually nobody has anyone below them, the recursive term matches no rows, and the CTE is complete.
The word RECURSIVE goes once, immediately after
WITH, even when only one of several CTEs actually recurses.
Generating rows that are in no table
A recursive CTE does not need a table at all. This is the standard way
to build a complete list of months so a report can show a zero for a
month in which nothing happened — a row that exists in no table, because
nothing occurred to create it.
WITH RECURSIVE m AS (
SELECT DATE '2023-01-01' AS mon
UNION ALL
SELECT (mon + INTERVAL '1 month')::date FROM m
WHERE mon < DATE '2023-06-01' -- the termination condition
)
SELECT mon FROM m;
The WHERE is not optional
Walking a hierarchy terminates for free — you run out of children.
Generating a series does not. Leave that
WHERE out and the query never finishes: Postgres will not
detect the loop for you. In this dojo the worker is stopped after ten
seconds and you press Run again. On a real database it is a query
somebody has to go and cancel.
The ::date cast matters as well. Adding an
INTERVAL to a DATE yields a
TIMESTAMP, and both branches of a
UNION ALL must agree on type — the cast is what keeps the
column a DATE.
On Postgres specifically there is a much shorter way to do exactly this:
generate_series(DATE '2023-01-01', DATE '2023-12-01', INTERVAL '1
month'), a set-returning function that goes in
FROM. It is clearer, faster, and has no termination
condition to get wrong — but it does not exist in most other engines,
which is why the recursive form is worth meeting first.
Quick check
Your recursive tree query returns only the four root users. What is wrong?
4
Part 3, In One Table
Six modules, all answering the same underlying question — how do rows
from different places end up in one answer. A rough guide to which tool
the question is asking for:
JOIN
Columns from both sides, on one row
The answer needs fields from two tables together. Inner if unmatched rows should vanish, LEFT if they must survive — and once a chain goes LEFT it stays LEFT.
IN / EXISTS
A yes-or-no about the other table
No columns wanted from it. Cannot duplicate rows, which a join can. Negate with NOT EXISTS, never NOT IN on a nullable column.
UNION ALL
One list, from two sources
Stacking rather than widening. UNION only when a duplicate is genuinely noise.
WITH
A step worth naming — or reusing
Several stages, or one stage used twice. Reuse is the only hard capability a derived table lacks.
RECURSIVE
Depth unknown in advance
Hierarchies, and rows that exist in no table.
The habit that catches more errors than any of these choices is still
the one from 3-03: say what the row count should be before you
run it. Every failure in this part — the fan-out, the inner
join that deleted rows, the mirrored pairs, the NOT IN that
returned nothing — announces itself in a row count and in nothing else.
Next: Part 4
Analyst SQL. Ranking rows within their group, comparing a row to its
neighbours, running totals, funnels and retention cohorts — and
changing data rather than only reading it. Several things that needed
a correlated subquery here get a shorter and faster spelling there.
Quick check
"For every category, the revenue it earned and the share that is of total revenue." Which shape?
0 of 9 completed
Loading the tables…
01
To do
A common table expression is a named subquery written
before the statement that uses it. The keyword is WITH.
WITH spend AS ( SELECT u.user_id, 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 ) SELECT name, spent FROM spend WHERE spent > 200;
That is the derived table from the last module, unchanged in what it
computes. What changed is the reading order: the step that happens first
is now written first, with a name, instead of being buried in the middle
of a FROM clause.
The name behaves exactly like a table for the rest of the statement. It
exists only for this statement — nothing is stored, and nothing survives
the query.
Your task: using WITH, return
name and spent for every user whose total
paid spend is over 200, ordered by spent descending.
query.sql
PostgreSQLCtrl↵ to run
Hint
Inside the brackets goes the aggregate: 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. Then select from spend and filter on spent > 200.
Output
02
To do
Several CTEs go in one WITH, separated by commas — and
each may use the ones before it. That is what turns a
pile of nesting into a sequence of steps.
WITH spend AS ( … ), benchmark AS (SELECT AVG(spent) AS avg_spent FROM spend) SELECT …
Note there is only one WITH, at the very top. The comma
introduces the second name; writing WITH again is a syntax
error.
This is the two-level aggregation from 3-05 — the average of per-user
totals, 210.11 — with the two levels finally readable as two named
steps rather than one query inside another.
Your task: return name and
spent for every user whose paid spend beats the
average paid spend per user, ordered by spent
descending. Use two CTEs.
query.sql
PostgreSQLCtrl↵ to run
Hint
benchmark is SELECT AVG(spent) AS avg_spent FROM spend. Then SELECT s.name, s.spent FROM spend s, benchmark b WHERE s.spent > b.avg_spent — a one-row table can be joined to freely.
Output
03
To do
Here is the one hard capability difference, rather than a matter of
taste. A CTE can be referenced more than once. A
derived table cannot — you would have to paste the entire subquery in
again, and then keep the two copies in step forever.
WITH spend AS ( … ) SELECT (SELECT COUNT(*) FROM spend) AS n, (SELECT ROUND(AVG(spent), 2) FROM spend) AS avg_spent;
One definition, two uses. Any query that needs to compare a set against
a summary of itself has this shape, and it is where WITH
stops being cosmetic.
Your task: define a CTE of per-user paid spend, then
return two columns from it in a single row: how many users have any paid
spend as n, and their average spend rounded to two decimals
as avg_spent.
query.sql
PostgreSQLCtrl↵ to run
Hint
(SELECT COUNT(*) FROM spend) AS n and (SELECT ROUND(AVG(spent), 2) FROM spend) AS avg_spent — the same CTE named twice.
Output
04
To do
A CTE is a table for the rest of the statement, so everything from Part
3 applies to it unchanged — it can be joined, left-joined, grouped and
filtered.
This is the usual way to attach an aggregate back onto the rows it came
from without a fan-out. Aggregate in the CTE, join the result, and every
row keeps its own identity.
And the outer join still matters: the two users who never ordered are
absent from a spend CTE built on orders, so keeping them
needs LEFT JOIN and COALESCE exactly as in
3-02.
Your task: return every user's name,
country and paid spent — 0 where
they have none — by joining a spend CTE to users. Order by
spent descending, then name.
query.sql
PostgreSQLCtrl↵ to run
Hint
LEFT JOIN spend s ON s.user_id = u.user_id, and COALESCE(s.spent, 0) AS spent. A plain JOIN loses the three users with no paid orders.
Output
05
To do
Now the genuinely new capability. A self join reaches
one level of a hierarchy — a user and their referrer.
Two self joins reach two. But "everyone in the referral tree, however
deep" cannot be written that way at all, because the depth is not known
when the query is written.
WITH RECURSIVE is the answer, and it has exactly two parts
joined by UNION ALL:
WITH RECURSIVE tree AS ( SELECT user_id, name, 1 AS depth -- anchor: runs once FROM users WHERE referred_by IS NULL UNION ALL SELECT u.user_id, u.name, t.depth + 1 -- recursive: re-runs FROM users u JOIN tree t ON u.referred_by = t.user_id ) SELECT * FROM tree;
The anchor runs once and seeds the result — here, the
four users who arrived on their own. The recursive term
then runs against only what the previous pass produced, adding the
people they referred, and repeats until a pass returns
nothing. That empty pass is what stops it.
The word RECURSIVE goes after WITH, once, even
if only one of several CTEs is recursive.
Your task: return name and
depth for every user in the referral tree, ordered by depth
then name.
query.sql
PostgreSQLCtrl↵ to run
Hint
The recursive branch is SELECT u.user_id, u.name, t.depth + 1 FROM users u JOIN tree t ON u.referred_by = t.user_id — joining the table back to the CTE being defined.
Output
06
To do
A recursive CTE does not need a table at all. It can generate rows —
which makes it the standard way to build a complete list of months, so a
report can show a zero for a month in which nothing happened.
WITH RECURSIVE m AS ( SELECT DATE '2023-01-01' AS mon UNION ALL SELECT (mon + INTERVAL '1 month')::date FROM m WHERE mon < DATE '2023-06-01' ) SELECT mon FROM m;
The WHERE in the recursive branch is
the termination condition, and it is not optional. It
is what makes a pass eventually return no rows.
Leave it out and the query never finishes. In this dojo that means the
worker is killed and you press Run again; on a real database it means a
query someone has to go and cancel. Postgres will not detect it for you.
The ::date cast matters too — adding an
INTERVAL to a DATE gives a
TIMESTAMP, and without the cast the column type changes
between the two branches.
Your task: generate the first six months of 2023, one
row each, as mon, ordered by mon.
query.sql
PostgreSQLCtrl↵ to run
Hint
The starter has no stopping condition. Add WHERE mon < DATE '2023-06-01' to the recursive branch — the last month it generates is June.
Output
07
To do
Recursion is the portable way to build a series. On Postgres there is a
far shorter one, and it is what a Postgres analyst would actually
write.
SELECT g::date AS mon FROM generate_series(DATE '2023-01-01', DATE '2023-12-01', INTERVAL '1 month') g;
generate_series is a set-returning function
— it goes in FROM and produces rows. Start, stop, step;
the stop value is included. It works on integers and timestamps alike.
This is Postgres-specific. It has no equivalent in most other engines,
which is exactly why the recursive form was worth learning first — but
when the target is Postgres, this is clearer and faster, and there is no
termination condition to get wrong.
Your task: return all twelve months of 2023 as
mon, using generate_series, ordered by mon.
query.sql
PostgreSQLCtrl↵ to run
Hint
generate_series(DATE '2023-01-01', DATE '2023-12-01', INTERVAL '1 month') g, and select g::date AS mon so the column is a DATE rather than a timestamp.
Output
08
To do
The argument for WITH is mostly about the next person to
read the query, which includes you in six months. A well-named CTE says
what a step is for; a nested subquery only says what it does.
Compare "the subquery in the FROM clause of the left join" with
active_buyers. The second one can be discussed in a meeting.
Your task: a re-engagement list. Define a CTE named
buyers holding the user_id of everyone with at
least one paid order, then return user_id and
name for every user not in it, ordered by
user_id.
Three users qualify. Watch the negation — this is the module where
NOT IN bit, and a CTE does nothing to protect you from it.
query.sql
PostgreSQLCtrl↵ to run
Hint
buyers is SELECT user_id FROM orders WHERE status = 'paid'. Then WHERE NOT EXISTS (SELECT 1 FROM buyers b WHERE b.user_id = u.user_id) — or NOT IN, which is safe here only because user_id is never NULL.
Output
09
To do
Everything in Part 3 has been a different way of bringing rows together.
A rough guide to which:
JOIN
You need columns from both sides
The answer has fields from two tables on one row.
IN / EXISTS
You only need a yes or no
Membership or existence, with no columns wanted from the other table.
WITH
A step is worth naming, or reusing
Several stages, or one stage used twice. Reusing is the part a derived table genuinely cannot do.
RECURSIVE
The depth is not known in advance
Hierarchies, and generating rows that are in no table.
Your task: put the whole part together. Using CTEs,
return each country, the number of users in it as
users, and its total paid revenue — including
countries whose users have never bought anything, which must show
0. Order by revenue descending, then country.
query.sql
PostgreSQLCtrl↵ to run
Hint
revenue is SELECT user_id, SUM(amount) AS paid FROM orders WHERE status = 'paid' GROUP BY user_id. Join it per user, then COALESCE(SUM(r.paid), 0) AS revenue grouped by country.
Output
The referral tree
To do
Growth wants to see the referral tree with money attached: how deep each
user sits in the chain that brought them in, and what they have actually
spent. Two independent computations, combined — which is exactly what
WITH is for.
Your task: one row for every user, with:
name — the user's name
depth — their level in the referral tree, counting from 1 for anyone who arrived on their own
spent — their total paid spend, 0 if they have none
Order by depth, then name.
Two CTEs: a recursive one walking the tree from its roots, and an ordinary
one totalling paid orders per user. Then join them — and remember that
three users have no paid orders at all, so that join is an outer one.
The tree is four levels deep and every user is on it, so a correct answer
has 12 rows.
query.sql
PostgreSQLCtrl↵ to run
Hint
The anchor is SELECT user_id, name, 1 AS depth FROM users WHERE referred_by IS NULL. The recursive branch is SELECT u.user_id, u.name, t.depth + 1 FROM users u JOIN tree t ON u.referred_by = t.user_id. spend is SELECT user_id, SUM(amount) AS spent FROM orders WHERE status = 'paid' GROUP BY user_id. Then COALESCE(s.spent, 0) AS spent.