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.

SyntaxSELECT ... FROM a LEFT [OUTER] JOIN b ON a.col = b.col

An 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

Example · sql
-- 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.

Example · sql
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.

Example · sql
-- 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.

Example · sql
-- 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

  • Be the first to comment on this lesson.