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.
Chains, and the one link that breaks everything above it
Three tables is not two tables with more typing. It adds one genuinely new
idea — joins chain, and each one can see everything joined before it — and
one genuinely new way to be wrong, where a single missing word silently
cancels the LEFT JOIN you wrote on the line above.
Ready?
1
Each Join Attaches to Everything Before It
An order in this database knows two things about the world outside
itself: a user_id and a product_id. The
buyer's name is one table away in one direction, the product's name is
one table away in another. Getting both means joining both.
There is no new syntax for this. Joins chain. Each
JOIN attaches to the result assembled so far, and you write
as many as the question needs.
SELECT o.order_id, u.name AS buyer, p.name AS product, o.amount
FROM orders o
JOIN users u ON u.user_id = o.user_id
JOIN products p ON p.product_id = o.product_id;
The important consequence: because each join sees everything named
earlier, its ON can reference any table above it,
not only the one immediately preceding. That is what lets you walk a
chain of relationships to reach a column that the table you started
from has never heard of.
FROM events e
JOIN users u ON u.user_id = e.user_id -- u.plan is now available,
-- though events has no plan column
What a join cannot do is reference a table named later. The
FROM clause is read top to bottom, and a table that has not
been joined yet does not exist.
Aliases stop being optional here
Two tables in this database have a column called name.
Three have a user_id. At three tables, ambiguity is the
default rather than the exception — so give every table a short alias
and qualify every column with it, including the ones that are not
ambiguous yet. The next join someone adds is what changes
which ones those are.
Quick check
You write FROM events e JOIN products p ON p.product_id = o.product_id JOIN orders o ON o.user_id = e.user_id. What happens?
2
Two Dimensions at Once
It is worth being explicit about what the extra table actually earns,
because it is not just extra columns on the output.
1
orders alone
You can group by status, or by month. The dimensions available are the ones stored on the order.
2
+ users
Now also by country, or plan. Revenue by country becomes a question you can ask.
3
+ products
Now by category and country together — a question neither table can answer alone.
That is the everyday shape of analyst SQL, and it is worth recognising
as a shape: joins to reach the columns, GROUP BY to
choose the dimensions, HAVING to keep the output
readable.
SELECT p.category, u.country, SUM(o.amount) AS revenue
FROM orders o
JOIN users u ON u.user_id = o.user_id
JOIN products p ON p.product_id = o.product_id
WHERE o.status = 'paid'
GROUP BY p.category, u.country
HAVING SUM(o.amount) >= 100
ORDER BY revenue DESC;
The WHERE and the HAVING are doing different
jobs, exactly as in module 2-03: WHERE discards individual
orders before grouping, HAVING discards whole
category-and-country combinations after their totals exist.
Counting across a chain needs one extra piece of care.
COUNT(DISTINCT ...) is how you count values from one table
without the join's repetition inflating them — a product sold four times
into two countries has four rows, and a plain count of countries would
confidently say four.
Quick check
You join products to orders to users and write COUNT(u.country) to measure how many countries each product sells into. What do you get?
3
An Inner Join Downstream Cancels an Outer Join Upstream
This is the module's real content, and the failure is completely
invisible — no error, no warning, just a row count two smaller than the
one you were expecting.
In the last module, users LEFT JOIN orders gave 26 rows,
keeping the two users who never ordered. Chain a plain
JOIN onto that and watch it come apart:
FROM users u
LEFT JOIN orders o ON o.user_id = u.user_id -- 26 rows at this point
JOIN products p ON p.product_id = o.product_id -- 24 rows now
The padded rows carry o.product_id = NULL. NULL matches no
product. The inner join keeps only matches — so it
deletes them, and with them everything the LEFT JOIN was written to
preserve.
26 rows, then 24
LEFT, then plain JOIN
The second join re-filters the first join's output. The two users who never ordered are gone, and the query looks like it keeps them.
26 rows, and stays 26
LEFT, then LEFT
The padded rows pass through, gaining a NULL product name. The report can now show a user who has bought nothing.
Once a chain goes LEFT, every join after it has to be
LEFT. That is the rule, and it is worth applying mechanically
rather than reasoning about each time.
The quieter version: a table you only meant to read
The same mistake wearing a disguise. You want revenue by country, and
someone also wants the user's plan, which lives in
subscriptions. Joining that table in looks free.
Two users have no subscription row, so an inner join to
subscriptions deletes their orders too. Two
countries vanish from the report. Revenue falls by 467.00. Every number
still on the page is real, correctly computed, and understated — which
is precisely why this survives review.
A table you read no column from should not be in the query
The fix here is deletion, not a cleverer condition. If nothing in the
SELECT, WHERE, GROUP BY or
ORDER BY comes from a joined table, that join is doing
nothing but changing your row count. Take it out.
Quick check
A report joins customers LEFT JOIN orders LEFT JOIN shipments and returns 5,000 rows. A colleague "tidies up" the middle join to a plain JOIN. What is the likely effect?
4
Say the Number Before You Run It
Every failure in this module and the last one is silent. None raises an
error; all of them return a plausible number. There is exactly one cheap
habit that catches the whole family, and almost nobody does it:
predict the row count before running the query, then check
it.
=
Count unchanged
Every join was many-to-one. Each row found exactly one partner. This is the usual, healthy case.
>
Count went up
Fan-out: some row matched several on the other side. Any SUM over the result is now too big.
<
Count went down
An inner join found rows with no match and deleted them. Something is missing from the answer.
Work it forward through the chain. Start from 24 orders. Join users —
many-to-one, still 24. Join products — many-to-one, still 24. Join
subscriptions — and land on 20, because two users have
none. The arithmetic takes five seconds and the query text alone would
never have told you.
Reading the shape out of the question
Three questions, asked in order, produce the query:
1
What is one row of the answer?
One product? One user? One order? That table goes first, in FROM.
2
Which columns live elsewhere?
Join exactly those tables, and no others.
3
Must unmatched rows survive?
If yes, every join in the chain is LEFT. If no, inner throughout.
"Every product, its revenue, and how many countries bought it" answers
those as: products first; orders and users joined; and yes — "every
product" means the ones that never sold have to survive, so the chain is
LEFT the whole way down. That is the assignment.
Quick check
You join orders (24 rows) to users and get 31 rows back. What has happened?
0 of 9 completed
Loading the tables…
01
To do
An order knows a user_id and a product_id. The
buyer's name is one table away; the product's name is one table away in
a different direction. Getting both means joining both.
There is no new syntax. Joins chain — each
JOIN attaches to the result built so far, and you write as
many as the question needs.
FROM orders o JOIN users u ON u.user_id = o.user_id JOIN products p ON p.product_id = o.product_id
Both of these are many-to-one — every order has exactly one buyer and
exactly one product — so neither join adds or removes a row. 24 orders
in, 24 rows out, now carrying names from two other tables.
Your task: return order_id, the buyer's
name as buyer, the product's name
as product, and amount, for every order,
ordered by order_id.
query.sql
PostgreSQLCtrl↵ to run
Hint
p.name AS product, and the second ON is p.product_id = o.product_id. Both names are called "name", so the aliases are doing real work here.
Output
02
To do
A join does not only attach to the table immediately above it. It
attaches to everything joined so far, so its
ON can reference any table named earlier in the
FROM clause.
That is what lets you walk a chain of relationships. Here
events knows a user, and the user knows a plan — so
grouping events by plan means joining through users even
though no column of events mentions a plan.
FROM events e JOIN users u ON u.user_id = e.user_id -- now u.plan is available
What it cannot do is reference a table named later. The
FROM clause is read top to bottom, and a table that has not
been joined yet does not exist.
Your task: return each plan,
device, and the number of events as n, ordered
by plan then device.
query.sql
PostgreSQLCtrl↵ to run
Hint
u.plan comes from users, so join users first, then GROUP BY u.plan, e.device. COUNT(*) is fine here — an inner join leaves no padded rows to miscount.
Output
03
To do
Here is what the third table actually buys you. With
orders alone you can group by status or by month. Join
users and you can group by country. Join
products as well and you can group by
category and country at once — a question neither table
can answer on its own.
This is the everyday shape of analyst SQL: joins to reach the columns,
GROUP BY to pick the dimensions, and a
HAVING to keep the result readable.
Your task: for paid orders only,
return category, country and total
revenue, keeping only combinations totalling
100 or more. Order by revenue descending, then
category, then country.
query.sql
PostgreSQLCtrl↵ to run
Hint
WHERE o.status = 'paid' filters rows before grouping; HAVING SUM(o.amount) >= 100 filters the groups after. That order was module 2-03.
Output
04
To do
This is the most important station in the module, and the failure is
entirely invisible.
You learned in 3-02 that users LEFT JOIN orders gives 26
rows, keeping the two users who never ordered. Now chain a plain
JOIN to products onto it:
FROM users u LEFT JOIN orders o ON o.user_id = u.user_id -- 26 rows here JOIN products p ON p.product_id = o.product_id -- 24 again
The padded rows carry o.product_id = NULL. NULL matches no
product, the inner join keeps only matches, and the two
users disappear — the LEFT JOIN upstream has been cancelled by
the join downstream. No error. No warning. Just 24 where you
expected 26.
The rule is worth memorising: once you go LEFT, stay LEFT.
An inner join anywhere later in the chain re-filters everything before it.
Your task: prove it to yourself. Return
u.name and p.name as product for
every user, keeping the two who never ordered, ordered by
u.user_id then o.order_id. Both joins have to
be LEFT.
query.sql
PostgreSQLCtrl↵ to run
Hint
The starter has the bug in it. Change the second JOIN to a LEFT JOIN and the count goes from 24 back to 26.
Output
05
To do
A quieter version of the same mistake, and one that reaches production
regularly because the result is not empty — it is merely wrong.
Say you want revenue by country, and you also want the user's plan from
subscriptions. Joining that table in looks harmless. It is
not: two users have no subscription row, so an inner
join to subscriptions deletes their orders as well.
orders JOIN users -- 24 orders, 9 countries orders JOIN users JOIN subscriptions -- 20 orders, 7 countries
Paid revenue drops from 1891.00 to 1424.00.
Two countries vanish from the report entirely. Every number that remains
is real, which is exactly why nobody catches it.
Your task: the correct version. Return
country and paid revenue for every country
that has any, ordered by revenue descending then country — without
letting subscriptions near it.
query.sql
PostgreSQLCtrl↵ to run
Hint
Nothing in the question needs a column from subscriptions. Delete that join line entirely — the fix is removing a table, not adding a condition.
Output
06
To do
Once three tables are joined, the interesting counts are the ones that
cross them: not "how many orders" but how many different
countries has this product sold into.
COUNT(DISTINCT ...) is what makes that work. A product sold
four times in two countries has four rows after the join, and a plain
count of countries would say four. DISTINCT collapses them
to the two real values.
Keep the chain LEFT so the unsold product still appears. Its padded row
has a NULL country, and COUNT(DISTINCT u.country) skips
NULLs, so it correctly reports 0 — no COALESCE
needed, because COUNT already returns 0 rather than NULL.
Your task: return every product's name,
its number of orders as orders, and the number of distinct
buyer countries as countries. Order by countries
descending, then name.
query.sql
PostgreSQLCtrl↵ to run
Hint
COUNT(DISTINCT u.country) AS countries, and the second ON is u.user_id = o.user_id — joining users through orders, not directly to products.
Output
07
To do
Nothing changes at four except that there is more to be wrong about. The
discipline is the same one from the beginning of Part 3:
predict the row count before you run it, then check.
Here that prediction is the lesson. Start from 24 orders, join users
(many-to-one, still 24), join products (many-to-one, still 24), then
join subscriptions — and land on 20, because of the
two users with no subscription. The number tells you something happened
that the query text does not.
Your task: return o.order_id,
u.name, p.name as product and
s.plan for every order whose buyer has a subscription,
ordered by order_id.
query.sql
PostgreSQLCtrl↵ to run
Hint
JOIN subscriptions s ON s.user_id = u.user_id — the subscription belongs to the user, not to the order.
Output
08
To do
Every join question decomposes the same way, and doing it explicitly is
faster than guessing:
1
What is one row of the answer?
One product? One user? One order? That table goes first, in FROM.
2
Which columns are elsewhere?
Join exactly those tables. A table you never read a column from should not be in the query.
3
Must rows with no match survive?
If yes, every join in the chain is LEFT. If no, inner throughout.
Your task: "every user, their country, and the name of
each product they have bought — including users who have bought
nothing." Work through the three questions, then write it. Order by
u.user_id, then o.order_id.
Columns: name, country, product.
query.sql
PostgreSQLCtrl↵ to run
Hint
SELECT u.name, u.country, p.name AS product, then LEFT JOIN orders o ON o.user_id = u.user_id, then LEFT JOIN products p ON p.product_id = o.product_id.
Output
09
To do
Two tables in this database have a column called name.
Three have a user_id. Two have a plan. At
three tables, ambiguity stops being a rare annoyance and becomes the
default.
Two habits handle it completely. Give every table a short
alias, and qualify every column with it —
including the ones that are not currently ambiguous, because the next
person to add a join changes which ones those are.
Where two columns would land in the result under the same name, rename
them with AS. A result with two columns called
name is legal in Postgres and miserable to consume.
Your task: return the buyer as buyer,
their country as country, the product as
product, its category as category and the
amount as amount, for paid orders only, ordered by
order_id. Every column in the SELECT list qualified.
query.sql
PostgreSQLCtrl↵ to run
Hint
u.name AS buyer, u.country, p.name AS product, p.category, o.amount — the two "name" columns are exactly why the aliases are needed.
Output
The product reach report
To do
Sales want to know which products are travelling. Not just what earns the
most — how widely each one sells, and which are not selling at
all.
Your task: one row for every product,
including any that have never sold, with:
name — the product name
category — its category
revenue — total amount of its paid orders, 0 rather than NULL if none
countries — how many distinct buyer countries it has paid orders from
Order by revenue descending, then name.
This is a three-table chain that has to stay open the whole way: products
to orders to users. Every trap in this module and the last one is
available to you here — an inner join late in the chain, a paid filter in
the WHERE, a missing DISTINCT, a NULL where a zero belongs.
query.sql
PostgreSQLCtrl↵ to run
Hint
The paid condition goes in the first ON: LEFT JOIN orders o ON o.product_id = p.product_id AND o.status = 'paid'. Then LEFT JOIN users u ON u.user_id = o.user_id, COALESCE(SUM(o.amount), 0) AS revenue, COUNT(DISTINCT u.country) AS countries, grouped by p.product_id, p.name, p.category.