◀ Course contents Part 3 · Module 3-03

Joining Three Tables

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.

orders JOIN users                      -- 24 orders, 9 countries, 1891.00
orders JOIN users JOIN subscriptions   -- 20 orders, 7 countries, 1424.00

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
PostgreSQL
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
    PostgreSQL
    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
      PostgreSQL
      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
        PostgreSQL
        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
          PostgreSQL
          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
            PostgreSQL
            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
              PostgreSQL
              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
                PostgreSQL
                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
                  PostgreSQL
                  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
                    PostgreSQL
                    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.

                    Output
                    
                          

                      Notification