A join answers a question that needs facts from more than one table. The join type decides which unmatched rows are still meaningful enough to keep; the ON condition decides which row pairs are allowed to match.
Choose rows before you choose columns
Think of a join as lining up two lists using a shared label. Here, the label is customer_id: a customer row can line up with each order that carries the same ID. The important question is not “which keyword do I remember?” It is “which list is my report promising to include, even when no partner row exists?” Answer that first, then choose the join.
MatchConnect rows through their related keys.
PreserveDecide which unmatched rows still belong.
AuditRead NULLs as a missing match, not a zero.
VerifyCheck the result grain before you aggregate.
LEFT INPUTcustomers5 rows
Celia and Elias have no order.
MATCHcustomer_idc.id = o.customer_id
The relationship chooses valid pairs.
RIGHT INPUTorders5 rows
Order 105 has no matching customer.
INNER JOIN keeps only matching pairs
An INNER JOIN returns a row only when the ON condition is true for a row from each input. The seed has five customers and five orders, but this result has four rows: Celia and Elias have no order, while order 105 references a customer that is absent. Neither unmatched side belongs in an inner-join answer.
SQL BROWSER RUNNER
Read customers with matching orders
Return only customer-order pairs that share the same customer ID.
CREATETABLE customers (
customer_id INTEGERPRIMARYKEY,
customer_name TEXTNOTNULL,
city TEXTNOTNULL);CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGER,statusTEXTNOTNULL,
amount_cents INTEGERNOTNULL);INSERTINTO customers (customer_id, customer_name, city)VALUES(1,'Amina','Rabat'),(2,'Bilal','Casablanca'),(3,'Celia','Fes'),(4,'Dina','Rabat'),(5,'Elias','Tangier');INSERTINTO orders (order_id, customer_id,status, amount_cents)VALUES(101,1,'paid',3500),(102,1,'pending',800),(103,2,'paid',2200),(104,4,'paid',6000),(105,99,'paid',900);-- deliberately unmatched: an import problem to auditSELECT c.customer_name, o.order_id, o.status, o.amount_cents
FROM customers AS c
INNERJOIN orders AS o ON o.customer_id = c.customer_id
ORDERBY o.order_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
LEFT JOIN keeps the complete left-side population
A LEFT JOIN retains every row from the table on its left. When no order matches, the order columns are NULL. A customer can still appear more than once: Amina has two orders, so she produces two joined rows. The result grain is customer-order pair, not one row per customer.
SQL BROWSER RUNNER
Keep customers without orders
Preserve every customer while adding order details where a match exists.
CREATETABLE customers (
customer_id INTEGERPRIMARYKEY,
customer_name TEXTNOTNULL,
city TEXTNOTNULL);CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGER,statusTEXTNOTNULL,
amount_cents INTEGERNOTNULL);INSERTINTO customers (customer_id, customer_name, city)VALUES(1,'Amina','Rabat'),(2,'Bilal','Casablanca'),(3,'Celia','Fes'),(4,'Dina','Rabat'),(5,'Elias','Tangier');INSERTINTO orders (order_id, customer_id,status, amount_cents)VALUES(101,1,'paid',3500),(102,1,'pending',800),(103,2,'paid',2200),(104,4,'paid',6000),(105,99,'paid',900);-- deliberately unmatched: an import problem to auditSELECT c.customer_name, c.city, o.order_id, o.statusFROM customers AS c
LEFTJOIN orders AS o ON o.customer_id = c.customer_id
ORDERBY c.customer_id, o.order_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
A filter on the nullable side belongs in ON when unmatched rows matter
This is one of the most important outer-join decisions. Put o.status = 'paid' in ON when the question is “show every customer and their paid orders, if any.” That limits matching orders but still preserves every customer. In contrast, a WHERE o.status = 'paid' runs after the join and removes the NULL order rows—silently defeating the reason for the left join.
SQL BROWSER RUNNER
Preserve every customer with paid-order matches
Put the paid-order condition in ON so zero-paid-order customers remain visible.
CREATETABLE customers (
customer_id INTEGERPRIMARYKEY,
customer_name TEXTNOTNULL,
city TEXTNOTNULL);CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGER,statusTEXTNOTNULL,
amount_cents INTEGERNOTNULL);INSERTINTO customers (customer_id, customer_name, city)VALUES(1,'Amina','Rabat'),(2,'Bilal','Casablanca'),(3,'Celia','Fes'),(4,'Dina','Rabat'),(5,'Elias','Tangier');INSERTINTO orders (order_id, customer_id,status, amount_cents)VALUES(101,1,'paid',3500),(102,1,'pending',800),(103,2,'paid',2200),(104,4,'paid',6000),(105,99,'paid',900);-- deliberately unmatched: an import problem to auditSELECT c.customer_name, o.order_id, o.amount_cents
FROM customers AS c
LEFTJOIN orders AS o
ON o.customer_id = c.customer_id
AND o.status='paid'ORDERBY c.customer_id, o.order_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
SQL BROWSER RUNNER
See how WHERE removes unmatched customers
Run the superficially similar query with the same filter after the join.
CREATETABLE customers (
customer_id INTEGERPRIMARYKEY,
customer_name TEXTNOTNULL,
city TEXTNOTNULL);CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGER,statusTEXTNOTNULL,
amount_cents INTEGERNOTNULL);INSERTINTO customers (customer_id, customer_name, city)VALUES(1,'Amina','Rabat'),(2,'Bilal','Casablanca'),(3,'Celia','Fes'),(4,'Dina','Rabat'),(5,'Elias','Tangier');INSERTINTO orders (order_id, customer_id,status, amount_cents)VALUES(101,1,'paid',3500),(102,1,'pending',800),(103,2,'paid',2200),(104,4,'paid',6000),(105,99,'paid',900);-- deliberately unmatched: an import problem to auditSELECT c.customer_name, o.order_id, o.amount_cents
FROM customers AS c
LEFTJOIN orders AS o ON o.customer_id = c.customer_id
WHERE o.status='paid'ORDERBY c.customer_id, o.order_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
RIGHT JOIN is the mirror of LEFT JOIN
A RIGHT JOIN preserves every row from the table written on the right. This example retains all orders, including order 105 whose customer is missing. Many teams standardize on left joins because they can write the preserved table first, but the meaning is the same once you reverse the inputs. Read the query from the preserved table outward rather than memorizing keywords.
SQL BROWSER RUNNER
Keep every order, including unmatched imports
Use RIGHT JOIN to retain the orders table and reveal a missing customer match.
CREATETABLE customers (
customer_id INTEGERPRIMARYKEY,
customer_name TEXTNOTNULL,
city TEXTNOTNULL);CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGER,statusTEXTNOTNULL,
amount_cents INTEGERNOTNULL);INSERTINTO customers (customer_id, customer_name, city)VALUES(1,'Amina','Rabat'),(2,'Bilal','Casablanca'),(3,'Celia','Fes'),(4,'Dina','Rabat'),(5,'Elias','Tangier');INSERTINTO orders (order_id, customer_id,status, amount_cents)VALUES(101,1,'paid',3500),(102,1,'pending',800),(103,2,'paid',2200),(104,4,'paid',6000),(105,99,'paid',900);-- deliberately unmatched: an import problem to auditSELECT c.customer_name, o.order_id, o.customer_id AS order_customer_id
FROM customers AS c
RIGHTJOIN orders AS o ON o.customer_id = c.customer_id
ORDERBY o.order_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
FULL OUTER JOIN preserves unmatched rows from both sides
A FULL OUTER JOIN combines the two preservation rules: matched pairs appear together, customers without orders remain, and orders without customers remain. It is valuable for reconciliation and migration audits. Not every SQL engine offers this syntax, so check your target database before depending on it; the course runner supports this example. COALESCE gives the final sort a value when one side is NULL.
SQL BROWSER RUNNER
Audit both sides of a relationship
Keep matches, customer-only rows, and the orphaned order in one result.
CREATETABLE customers (
customer_id INTEGERPRIMARYKEY,
customer_name TEXTNOTNULL,
city TEXTNOTNULL);CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGER,statusTEXTNOTNULL,
amount_cents INTEGERNOTNULL);INSERTINTO customers (customer_id, customer_name, city)VALUES(1,'Amina','Rabat'),(2,'Bilal','Casablanca'),(3,'Celia','Fes'),(4,'Dina','Rabat'),(5,'Elias','Tangier');INSERTINTO orders (order_id, customer_id,status, amount_cents)VALUES(101,1,'paid',3500),(102,1,'pending',800),(103,2,'paid',2200),(104,4,'paid',6000),(105,99,'paid',900);-- deliberately unmatched: an import problem to auditSELECT c.customer_id AS customer_id, c.customer_name,
o.order_id, o.customer_id AS order_customer_id
FROM customers AS c
FULLOUTERJOIN orders AS o ON o.customer_id = c.customer_id
ORDERBYCOALESCE(c.customer_id, o.customer_id), o.order_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
How to read the NULLs: a NULL order ID beside Celia means “Celia has no matching order in this result,” not that an order exists with an ID of zero. A NULL customer name beside order 105 means the order's customer reference could not be matched. This distinction matters: report missing matches clearly, and only replace NULL with a display value such as “No order yet” after you understand what the NULL represents.
A second one-to-many join can multiply rows
Joins return one row per matching pair. When an order has several line items, joining orders to lines repeats the order's amount for each line. That is correct at line-item grain, but summing o.amount_cents after this join would overcount order 201 and 203. Before using SUM, say what one row represents and aggregate or pre-aggregate at that grain.
SQL BROWSER RUNNER
Inspect order-line result grain
Join orders to their line items and observe which order values repeat.
CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGERNOTNULL,
amount_cents INTEGERNOTNULL);CREATETABLE order_lines (
line_id INTEGERPRIMARYKEY,
order_id INTEGERNOTNULL,
product_name TEXTNOTNULL,
quantity INTEGERNOTNULL);INSERTINTO orders (order_id, customer_id, amount_cents)VALUES(201,1,3500),(202,1,2200),(203,2,6000);INSERTINTO order_lines (line_id, order_id, product_name, quantity)VALUES(1,201,'Notebook',1),(2,201,'Pen set',2),(3,202,'Backpack',1),(4,203,'Monitor stand',1),(5,203,'Cable',2);SELECT o.order_id, o.customer_id, o.amount_cents,
line.product_name, line.quantity
FROM orders AS o
JOIN order_lines AS line ON line.order_id = o.order_id
ORDERBY o.order_id, line.line_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
Common join mistakes
WHERE on nullable side
Outer rows vanish
Place a match-only filter in ON when the report must keep rows with no match.
SELECT *
Unclear output
Name columns and aliases. Joined tables often share IDs, dates, status fields, and other confusing names.
No result grain
Inflated totals
One-to-many joins repeat parent values. Inspect pairs before writing aggregates.
Missing ON condition
Cartesian product
An accidental cross join pairs every left row with every right row. Check expected row counts early.
Independent lab: customer paid-order totals
Build a report with every customer's count and total of paid orders. Amina should have one paid order worth 3,500 cents; Dina should have one worth 6,000; Celia and Elias should remain with zero. The unmatched import order must not appear because this report starts from the customer population. Use COUNT(o.order_id), not COUNT(*), and turn a missing sum into zero with COALESCE. In a real dashboard, this pattern answers “who has not purchased yet?” as reliably as it answers “who has purchased?”
SQL BROWSER RUNNER
Audit a customer paid-order report
Preserve every customer, then count and total only their paid orders.
CREATETABLE customers (
customer_id INTEGERPRIMARYKEY,
customer_name TEXTNOTNULL,
city TEXTNOTNULL);CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGER,statusTEXTNOTNULL,
amount_cents INTEGERNOTNULL);INSERTINTO customers (customer_id, customer_name, city)VALUES(1,'Amina','Rabat'),(2,'Bilal','Casablanca'),(3,'Celia','Fes'),(4,'Dina','Rabat'),(5,'Elias','Tangier');INSERTINTO orders (order_id, customer_id,status, amount_cents)VALUES(101,1,'paid',3500),(102,1,'pending',800),(103,2,'paid',2200),(104,4,'paid',6000),(105,99,'paid',900);-- deliberately unmatched: an import problem to audit-- Keep every customer, then summarize only their paid orders.SELECT c.customer_id, c.customer_name,COUNT(o.order_id)AS paid_order_count,COALESCE(SUM(o.amount_cents),0)AS paid_cents
FROM customers AS c
LEFTJOIN orders AS o
ON o.customer_id = c.customer_id
AND o.status='paid'GROUPBY c.customer_id, c.customer_name
ORDERBY paid_cents DESC, c.customer_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
Lesson review
Joins are choices about meaning, not just syntax. Start with the population the question promises to keep, write the relationship in ON, place filters at the correct query stage, then inspect the result grain before you count or sum. A NULL from an outer join is evidence of a missing match that deserves a deliberate business decision.
I can predict which rows an INNER and LEFT JOIN keep.
I can preserve unmatched rows while matching only a filtered right-side subset.
I can read RIGHT and FULL joins as preservation choices.
I can explain why one-to-many joins repeat parent values.
I can write a zero-activity report without erasing its zero-activity rows.
KNOWLEDGE CHECK
Check join reasoning
Answer all ten questions, then revisit any example whose retained rows or result grain surprised you.