Outer joins: LEFT, RIGHT and FULL

SQL · Practical · 12 min

Reports must include customers with no orders and products never sold; inner joins silently drop them.

An inner join drops rows that have no partner. Dev has no orders, so he disappears from any customers-orders inner join. An outer join keeps them:

SELECT c.name, o.id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id;

LEFT JOIN keeps every row of the left table. Where there is no match, the right table's columns are filled with NULL. RIGHT JOIN is the mirror image (rarely used; swap the tables and write a LEFT JOIN instead), and FULL JOIN keeps unmatched rows from both sides. MySQL has no FULL JOIN.

Those NULLs make outer joins useful for finding what is missing, a pattern called an anti-join:

SELECT c.name FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;          -- Dev

Two classic mistakes:

  • Filtering the right table in WHERE. LEFT JOIN orders o ... WHERE o.status = 'shipped' removes the NULL-extended rows (NULL is not 'shipped'), quietly turning the query back into an inner join. A condition about the optional side belongs in ON: LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'shipped'.
  • **Counting with COUNT(*).** For Dev, the left join produces one row with NULL order columns, so COUNT(*) says 1. Count a column from the right table, COUNT(o.id), which skips NULLs and gives 0.

When the result reads oddly, ask of each table: must a match exist (inner) or is it optional (outer)?

Example

SELECT c.name,
       COUNT(o.id)                  AS orders,   -- not COUNT(*)
       COALESCE(SUM(o.total), 0)    AS spent
FROM customers c
LEFT JOIN orders o
       ON o.customer_id = c.id
      AND o.status <> 'cancelled'               -- optional side: filter in ON
GROUP BY c.id, c.name
ORDER BY spent DESC;
--  name  | orders | spent
--  Ana   |      2 | 257.50
--  Ben   |      2 | 247.99
--  Chloe |      0 |   0
--  Dev   |      0 |   0

-- Products never ordered (anti-join)
SELECT p.name
FROM products p
LEFT JOIN order_items oi ON oi.product_id = p.id
WHERE oi.product_id IS NULL;
--  (no rows: every product has been ordered)

Remember

  • LEFT JOIN keeps all left rows, NULL-filling missing matches
  • WHERE right_col IS NULL after a LEFT JOIN finds unmatched rows
  • Conditions on the optional table go in ON, not WHERE
  • Count a right-side column, not *, to get zero for no matches

Watch out for

  • A WHERE condition on the right table that turns a LEFT JOIN into an inner join.

Check yourself

  1. customers LEFT JOIN orders ... WHERE o.status = 'shipped'. Does Dev (no orders) appear?
  2. For a customer with no orders, what does COUNT(*) return in a customers LEFT JOIN orders GROUP BY customer query?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.postgresql.org/docs/current/tutorial-join.html

Builds on

Unlocks

Nothing builds on this yet.