learn-sql-with-phoebe / Builder session 4 of 10
Learn SQL with Phoebe · Builder track · Session 4 of 10

Joining tables

So far every query has lived inside one table. But the real answers - who spent the most, which customers never ordered, revenue by product name - need two or three tables stitched together. That is what a JOIN does: it lines rows up on a shared id and hands you one wider row. This is the session where Daybreak's six tables stop being islands and start being a database.

🟡 Builder track Practitioners: analysts · DE · DS · PMs Runs in your browser · SQLite Builds on b1-b3
0-3 · Welcome 3-20 · The join idea + INNER/LEFT 20-42 · Build-along: join Daybreak 42-45 · Q&A
Part 1 · covers Mode "Intermediate Joins", SQLZoo "JOIN"

What a join is 7 min live

An orders row tells you customer_id = 3 placed the order - but not their name. The name lives over in customers. A JOIN follows that shared id: for each order it finds the matching customer row and glues the two together into one wider row. You pick which columns you want from either side; SQLite does the matching.

orders order_id customer_id 1001 3 1002 7 customers customer_id name 3 Ava Chen 7 Noah Park joined result: one wider row per match order_id customer_id name 1001 3 Ava Chen 1002 7 Noah Park ON orders.customer_id = customers.customer_id The shared key column is the hinge the two tables swing on. INNER JOIN keeps a row only when both sides have a match on the key.
🔍 Click to zoom - one shared id turns two tables into one answer
LiveYour first join - orders with customer names3 min

Below, orders gets an alias o and customers gets c - short nicknames so you can write o.order_id and c.name without typing the full table name each time. The ON clause names the shared key. Press ▶ Run and watch order rows arrive with a real name attached.

SELECT o.order_id, c.name, o.order_date
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
LIMIT 10;
JOIN means INNER JOIN Writing JOIN on its own is exactly the same as INNER JOIN - SQLite fills in the INNER for you. It keeps only rows where the key matches on both sides. An order with no matching customer would simply not appear.
Self-studyWhy alias tables at all?2 min read

Aliases feel optional on a two-table join and become essential the moment you have three tables or two columns with the same name (both orders and customers have a customer_id). Prefixing every column with its table - o.customer_id vs c.customer_id - removes all ambiguity and makes the query readable at a glance.

  • Disambiguate: when two tables share a column name, an unqualified name is an error. o.customer_id says exactly which one.
  • Readability: c.name reads faster than customers.name, especially across three joins.
  • Convention: a one- or two-letter alias near the table name (o for orders, oi for order_items) is standard across every SQL shop.
Part 2 · covers Mode "LEFT/RIGHT JOIN", SQLZoo "More JOIN operations"

Which join: INNER vs LEFT 6 min live

The join you pick decides which rows survive. INNER JOIN keeps only rows that match on both sides. LEFT JOIN keeps every row from the left table, and fills the right side with NULL when there is no match. That difference is exactly how you find customers who have never ordered.

INNER JOIN customers orders kept Only customers who have at least one order. LEFT JOIN customers (all) orders NULL side Every customer stays; no-order ones get NULL orders.
🔍 Click to zoom - INNER keeps the overlap, LEFT keeps all of the left table
LiveLEFT JOIN - count orders per customer, including zero3 min

Here is the payoff. A LEFT JOIN from customers keeps every customer even if they have no orders. COUNT(o.order_id) counts only real order rows - so a customer with no match counts as 0. Sort ascending and the quiet, never-ordered customers float to the top.

SELECT c.name, COUNT(o.order_id) AS orders
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id
ORDER BY orders;
★ The classic "who is missing" query A LEFT JOIN plus COUNT of the right-side key is how analysts find customers with no orders, products never sold, or accounts with no activity. Change one word - LEFT to INNER - and those rows disappear. Choosing the join is choosing the question.
Self-studyRIGHT and FULL OUTER JOIN2 min read

SQLite also supports RIGHT JOIN and FULL OUTER JOIN (added in SQLite 3.39). A RIGHT JOIN is just a LEFT JOIN with the tables flipped - it keeps every row from the right table. A FULL OUTER JOIN keeps every row from both sides, filling NULL wherever either side has no match.

  • RIGHT JOIN: rarely needed - most people just reorder the tables and use LEFT. Same result, easier to read top to bottom.
  • FULL OUTER JOIN: useful for reconciliation - "show me every customer and every order, matched where possible, gaps on both sides shown as NULL".
  • In practice: INNER and LEFT cover the overwhelming majority of real work. Know RIGHT and FULL exist; reach for them when a reconciliation actually calls for them.
Part 3 · covers Mode "multiple joins", SQLZoo self-join

Chaining joins and self-joins 6 min live

Nothing stops you at two tables. To get revenue per customer you need three: customers for the name, orders to reach that customer's orders, and order_items for the money. You chain them by adding a second JOIN ... ON ..., each link naming its own key. This is the exact query behind Daybreak's top-customer list.

LiveThree-table join - revenue per customer3 min

Read the joins as a path: customersorders (on customer_id) → order_items (on order_id). Line revenue is quantity * unit_price; SUM rolls it up per customer. We filter to status = 'completed' so refunds and cancels do not inflate the numbers.

SELECT c.name, ROUND(SUM(oi.quantity*oi.unit_price),2) AS spent
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
JOIN order_items oi ON o.order_id = oi.order_id
WHERE o.status='completed'
GROUP BY c.customer_id
ORDER BY spent DESC
LIMIT 5;
Real world

Your answer should read: Noah Park 232, Ava Chen 222, Ethan Ruiz 177, Jack Lee 135, Zoe Tan 119. That is the founder's "who are our best customers" question answered in one query - three tables, a filter, a sum, a sort. Most revenue and cohort reports are this same skeleton with different columns bolted on.

Self-studySelf-joins - a table joined to itself2 min read

A self-join is a table joined to a second copy of itself, using two different aliases. It answers "which rows relate to other rows in the same table" - here, pairs of customers who live in the same city. Give customers two aliases (a and b) and match them on city.

SELECT a.name AS customer_a, b.name AS customer_b, a.city
FROM customers a
JOIN customers b ON a.city = b.city AND a.customer_id < b.customer_id
ORDER BY a.city;

The a.customer_id < b.customer_id condition keeps each pair once and stops a row from matching itself. Self-joins power "friends in the same region", "duplicate detection", and org-chart "who reports to whom" style questions.

Build-along · everyone types

Revenue by product name ★ 12 min · everyone builds

The order_items table knows product_id and money, but not the product's name - that lives in products. Join the two and you can rank the catalog by revenue. Let us build it one clause at a time, then run it.

Start with the money table: SELECT * FROM order_items LIMIT 5; - see quantity and unit_price, but only a bare product_id.

Add the join: JOIN products p ON oi.product_id = p.product_id so each line gets a real product name.

Sum the money: SUM(oi.quantity*oi.unit_price) AS revenue, grouped by product.

Sort it: ORDER BY revenue DESC so the best sellers land on top.

SELECT p.name, SUM(oi.quantity*oi.unit_price) AS revenue
FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
GROUP BY p.product_id
ORDER BY revenue DESC;
Real world

This is the shape of every "top products / top X by revenue" report. A fact table with ids and amounts (order_items), joined to a dimension table with human labels (products), summed and sorted. Swap products for customers or a date table and you have built most of a BI dashboard's back end.

Homework

Try it yourself - this week ◐ 20-30 min total

Source material

Official sources covered

This session teaches the working core of the major free SQL curricula's join material, run on live Daybreak data instead of screenshots. Certificates, graded problem sets, and video lectures stay on the official sites (linked below). This page covers:

Mode SQL Tutorial - Intermediate: JoinsParts 1-3 · INNER, LEFT/RIGHT, FULL OUTER, self-joins, multiple keys, run live
SQLZoo - JOIN & More JOIN operationsParts 1-3 · multi-table joins and self-joins, editable here
Kaggle Intro to SQL - Joining DataParts 1-2 · INNER/LEFT foundations; Kaggle's notebooks stay on Kaggle
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What is the difference between INNER JOIN and LEFT JOIN?

INNER keeps only matching rows. LEFT keeps every row from the left table and puts NULL on the right side when nothing matches - that is how you find customers with no orders.

2 · What does the ON clause do in a join?

ON tells SQLite which columns to match rows on - usually a shared id like o.customer_id = c.customer_id. It is the hinge the two tables swing on.

3 · How do you get total revenue per customer?

The money lives in order_items and the name in customers, linked through orders. Chain the three joins, then SUM quantity*unit_price per customer.

Builder session 4 cheat sheet · pin this

A join isrows from two tables lined up on a shared key, glued into one wider row.
The patternFROM a JOIN b ON a.key = b.key. Alias tables (o, c, oi) to keep it short.
INNER JOINKeeps only rows that match on both sides. Plain JOIN means INNER.
LEFT JOINKeeps every left-table row, NULL on the right where no match. Finds the "missing".
RIGHT / FULLSQLite supports both. RIGHT = flipped LEFT; FULL keeps every row on both sides.
ON clauseNames the key columns to match: ON o.customer_id = c.customer_id.
Chaining joinsAdd a second JOIN ... ON ... per table. customers → orders → order_items = revenue per customer.
Self-joinJoin a table to itself with two aliases (a, b) to relate rows in the same table.