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.
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 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_idsays exactly which one. - Readability:
c.namereads faster thancustomers.name, especially across three joins. - Convention: a one- or two-letter alias near the table name (
ofor orders,oifor order_items) is standard across every SQL shop.
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.
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;
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.
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: customers → orders (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;
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.
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;
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.
Try it yourself - this week ◐ 20-30 min total
- List every order with its customer's name and country by joining
orderstocustomers. - Use a
LEFT JOINto find every customer who has never placed an order (hint:COUNT(o.order_id) = 0, or the order side isNULL). - Build revenue per customer again, but this time only for orders placed on the
webchannel. - Join
order_itemstoproductsand total revenue bycategoryinstead of by product name. - Bring one three-table question about Daybreak you cannot yet answer to session b5 - odds are it needs
CASEor a reshape, which is exactly what b5 delivers.
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:
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.