LEFT JOIN
LEFT JOIN keeps every row of the left table and fills the right-hand columns with NULL wherever the ON condition finds no match.
SELECT ... FROM a LEFT [OUTER] JOIN b ON a.col = b.colAn INNER JOIN answers "which rows pair up". A LEFT JOIN answers "everything on the left, plus its partner if it has one". Every row of the left table survives; where the ON condition finds no matching right-hand row, MySQL generates one anyway with every right-hand column set to NULL. That generated row is the whole point — it is how a customer with no orders still appears in a customer report.
Shorthand and longhand
LEFT JOIN and LEFT OUTER JOIN are the same statement; OUTER is optional and carries no meaning. USING (customer_id) again abbreviates ON a.customer_id = b.customer_id, but note that with an outer join USING gives you the coalesced column — COALESCE(a.customer_id, b.customer_id) — so the join column is never NULL even when the right side is missing.
Reading the NULLs
NULL in a right-hand column means one of two things: the row did not match, or it matched and that column was genuinely NULL. Test a column declared NOT NULL — usually the right table's primary or foreign key — when you want to distinguish them. Wrap display values in COALESCE(o.total_cents, 0) so the report shows a zero rather than a blank.
What trips people up
Aggregates count the generated rows. COUNT(*) returns 1 for a customer with no orders because there is one NULL-filled row; COUNT(o.id) returns 0, because COUNT over an expression skips NULLs.
Example
-- Every customer, with their orders where they have any.
-- A customer who has never ordered still gets one row, with NULL
-- in every o.* column.
SELECT c.id,
c.email,
o.id AS order_id,
o.placed_at,
o.total_cents
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.id
ORDER BY c.id, o.placed_at;
-- LEFT OUTER JOIN is the identical statement; OUTER is optional.
-- SELECT ... FROM customers AS c LEFT OUTER JOIN orders AS o ON ...;When to use it
- List every customer with their order count, including the ones who have ordered nothing.
- Produce a dense report where categories with no activity show a zero rather than disappearing.
- Attach an optional record — a profile, a shipment, a discount — without dropping rows that lack one.
- Find rows with no counterpart by adding IS NULL on the right-hand key.
More examples
Counting over a LEFT JOIN without inventing orders
The single most common LEFT JOIN reporting bug, side by side with its fix. COUNT(*) counts rows including the NULL-extended one, so it floors at 1; COUNT(o.id) counts non-NULL values of a NOT NULL column and correctly reports 0. SUM over an empty group returns NULL rather than 0, so COALESCE it before the number reaches a chart or an invoice.
SELECT c.id,
c.email,
COUNT(*) AS wrong_count, -- never 0
COUNT(o.id) AS order_count, -- 0 when unmatched
COALESCE(SUM(o.total_cents),0) AS revenue_cents -- SUM of nothing is NULL
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.id, c.email
ORDER BY order_count;Filtering the right side without losing the left
Use this whenever the report must list all of the left table but only some of the right. Conditions on the right-hand table go in ON; conditions on the left-hand table can go in WHERE, because filtering the preserved side does not destroy the outer join.
-- Wanted: every customer, plus their PAID orders only.
-- The status test belongs in ON. In WHERE it would delete every
-- customer who has no paid order (see the ON versus WHERE lesson).
SELECT c.email,
COUNT(o.id) AS paid_orders
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.id
AND o.status = 'paid' -- part of the match, not a post-filter
GROUP BY c.id, c.email;Chaining LEFT JOINs down an optional path
Once a join in a chain is outer, every join hanging off its right-hand table must be outer too — an INNER JOIN to carriers here would delete the unshipped orders again, because s.carrier_id is NULL for them and NULL matches nothing.
-- orders -> shipments -> carriers, where an order may not be shipped
-- and a shipment may not yet have a carrier assigned.
SELECT o.id AS order_id,
s.tracking_no,
COALESCE(ca.name, 'not dispatched') AS carrier
FROM orders AS o
LEFT JOIN shipments AS s ON s.order_id = o.id
LEFT JOIN carriers AS ca ON ca.id = s.carrier_id
ORDER BY o.id;
Discussion