INNER JOIN, LEFT JOIN, RIGHT JOIN and FULL OUTER JOIN
corebeginnerINNER JOIN keeps only rows that match on both sides. LEFT JOIN keeps every row from the left table, filling in NULLs when there is no match on the right. RIGHT JOIN is the mirror image. FULL OUTER JOIN keeps every row from both sides, matched or not.
Think of it as
Think of the join type as answering one question: "what happens to a row that has no match?" INNER JOIN drops it. LEFT JOIN keeps the left row and pads the right side with NULL. RIGHT JOIN keeps the right row and pads the left side with NULL. FULL OUTER JOIN keeps both sides' unmatched rows, padding whichever side is missing.
What we're doing: Compare INNER JOIN and LEFT JOIN against the same two tables to see exactly which rows each one drops.
- 1–2
- orders.customer_id references customers — every order optionally points at a customer.
- 4–5
- Ada has two orders, Grace has none inserted here either, Linus has none.
- 7–8
- INNER JOIN: only Ada's two rows survive — Grace and Linus have no matching order row.
- 10–11
- LEFT JOIN: Ada's two rows plus one row each for Grace and Linus, with total as NULL.
name | total
-----+------
Ada | 50
Ada | 20
(2 rows)
name | total
-------+------
Ada | 50
Ada | 20
Grace |
Linus |
(4 rows)Why this works: PostgreSQL evaluates the join predicate for every combination of rows, then INNER JOIN filters to only the matches, while LEFT JOIN additionally injects one padded row for every left row that matched nothing. This is exactly why LEFT JOIN can return more rows than INNER JOIN on the same predicate but never fewer, and why aggregate counts like "orders per customer" are wrong under INNER JOIN for customers with zero orders — they disappear entirely instead of showing zero.
Using INNER JOIN to count zero-order customers
Wrong
Better
What you see: A "count per customer" report silently omits every customer who has zero of the thing being counted, instead of showing them with a count of 0.
Why: INNER JOIN removes a customer row entirely the moment it has no matching order row, so there is nothing left for GROUP BY to aggregate into a zero — the customer just never enters the result set. LEFT JOIN keeps the customer row with orders.id as NULL, and COUNT(o.id) correctly counts zero NULLs, producing the 0 the report actually needs.
- INNER JOIN
- customers × orders, matched only
- a customer with no orders vanishes from the result
- result size can only shrink or stay equal
- LEFT JOIN
- every customer row survives
- a customer with no orders gets one row, order columns NULL
- result size is at least the left table's row count
What happens to an unmatched row, by join type
Together
Remember: INNER drops unmatched rows on both sides; LEFT keeps every left row; RIGHT keeps every right row; FULL OUTER keeps both — the join type is really just "what happens to a row with no match."
See also: cross and self joins · cardinality and relationships

