LEFT & RIGHT JOIN
An inner join drops rows that have no match. Often that's exactly the information you need: customers who've never ordered, products that never sold, departments without staff. Outer joins keep unmatched rows and fill the missing side with NULL.
LEFT JOIN
SELECT c.name, o.id AS order_id, o.order_date FROM customers AS c LEFT JOIN orders AS o ON o.customer_id = c.id WHERE c.country = 'Portugal' ORDER BY c.name, o.id;
| name | order_id | order_date |
|---|---|---|
| Ana Pereira | 1 | 2025-09-03 |
| Ana Pereira | 3 | 2025-10-01 |
| Ana Pereira | 11 | 2026-02-03 |
| Isaac Levi | 12 | 2026-02-14 |
| Julia Santos | NULL | NULL |
LEFT JOIN keeps every row from the left table (the one before the keyword). Julia has no orders, but she's still listed, with NULL for the order columns. (LEFT OUTER JOIN means the same; OUTER is optional.)
Finding rows with no match
SELECT c.name FROM customers AS c LEFT JOIN orders AS o ON o.customer_id = c.id WHERE o.id IS NULL;
| name |
|---|
| Julia Santos |
Keep all customers, then keep only those where no order was found. This "anti-join" pattern answers questions like "who hasn't…?".
SELECT p.name FROM products AS p LEFT JOIN order_items AS oi ON oi.product_id = p.id WHERE oi.product_id IS NULL;
| name |
|---|
| Smart Plug |
The Smart Plug has never been ordered.
Counting with outer joins
SELECT d.name, COUNT(e.id) AS staff FROM departments AS d LEFT JOIN employees AS e ON e.department_id = d.id GROUP BY d.name ORDER BY staff DESC;
| name | staff |
|---|---|
| Engineering | 4 |
| Sales | 3 |
| Marketing | 2 |
| Support | 2 |
| Research | 0 |
Research appears with 0 staff. Note COUNT(e.id), not COUNT(*): the unmatched row still exists, so COUNT(*) would count it as 1.
ON vs WHERE in outer joins
SELECT c.name, o.id AS pending_order FROM customers AS c LEFT JOIN orders AS o ON o.customer_id = c.id AND o.status = 'pending' WHERE c.country = 'UK';
| name | pending_order |
|---|---|
| Ben Carter | NULL |
| George Brown | 15 |
A condition in ON limits which rows are matched; all customers are still kept. Move o.status = 'pending' to WHERE and Ben disappears, because his rows have a NULL status after the join and WHERE removes them:
SELECT c.name, o.id AS pending_order FROM customers AS c LEFT JOIN orders AS o ON o.customer_id = c.id WHERE c.country = 'UK' AND o.status = 'pending';
| name | pending_order |
|---|---|
| George Brown | 15 |
LEFT JOIN, conditions on the right-hand table usually belong in ON. A WHERE condition on it quietly turns the query back into an inner join.RIGHT JOIN
SELECT e.first_name, d.name AS department FROM employees AS e RIGHT JOIN departments AS d ON d.id = e.department_id WHERE e.id IS NULL OR e.id <= 3;
| first_name | department |
|---|---|
| Rafael | Sales |
| Mei | Engineering |
| NULL | Research |
RIGHT JOIN keeps every row from the right table. It's a mirror image of LEFT JOIN — A RIGHT JOIN B equals B LEFT JOIN A — so most people swap the tables and use LEFT JOIN consistently. SQLite supports RIGHT and FULL joins since version 3.39.
Defaults for missing values
SELECT p.name, COALESCE(SUM(oi.quantity), 0) AS units_sold FROM products AS p LEFT JOIN order_items AS oi ON oi.product_id = p.id WHERE p.category = 'Electronics' GROUP BY p.name ORDER BY units_sold DESC;
| name | units_sold |
|---|---|
| Wireless Earbuds | 4 |
| USB-C Charger | 4 |
| Mechanical Keyboard | 2 |
| Smart Plug | 0 |
Try it yourself
List every employee with their department name, including anyone without a department.