What does LEFT JOIN do in SQL?
LEFT JOIN returns every row from the left table expression and every right-side row satisfying the join condition. If a left row has no match, the database still returns it and supplies NULL for columns from the right side. If it has several matches, the left row appears once for each match.
SELECT
c.customer_id,
c.customer_name,
o.order_id,
o.order_date
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id;This answers “show all customers and any orders they have.” It does not promise one row per customer. A customer with five orders returns five rows; a customer with none returns one null-extended row. State the intended grain before choosing columns or aggregates.
Think in preserved rows and matching pairs
A reliable mental model has two phases. First, evaluate the join condition and return every matching pair, just like an inner join. Second, add one null-extended row for each left row that found no match. Conditions in ON participate in the first phase; conditions in WHERE filter the joined result afterward.
Choose the table or stage representing every entity that must remain in the answer.
Include identity, tenant, effective-date, and validity conditions required for one right row to be related.
Know whether each left row expects zero-or-one or zero-to-many right rows.
Separate “no related row” from “a related row exists but this attribute is missing.”
Place right-side filters in ON or WHERE intentionally
Filter placement is the most common LEFT JOIN bug. If a right-side eligibility rule belongs to what counts as a match, place it in ON. If the final answer should contain only joined rows satisfying a condition, put it in WHERE and recognize that unmatched rows will usually disappear.
-- All customers, plus completed orders when available
SELECT c.customer_id, o.order_id
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
AND o.status = 'completed';
-- Only customers that have a completed order
SELECT c.customer_id, o.order_id
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.status = 'completed';The second query rejects null-extended rows because NULL = 'completed' is not true. It may be better written as an inner join to make intent obvious. Adding OR o.status IS NULL is not always equivalent to moving the condition: a customer with only pending orders has matched rows, so no null-extended row exists to preserve.
The official PostgreSQL joined-table documentation demonstrates how a restriction in ON versus WHERE changes an outer join's result.
Distinguish unmatched rows from real NULL values
After a left join, a nullable right-side attribute cannot prove whether a match exists. If orders.discount_code is naturally nullable, discount_code IS NULL includes both unmatched customers and matched orders without a discount. Test a right-side primary key or another column guaranteed non-null for real rows.
| Observation | Possible meaning | Safer evidence |
|---|---|---|
right.attribute IS NULL | No match or matched missing attribute | Check non-nullable right primary key |
right.id IS NULL | No matched right row if id is guaranteed non-null | Verify schema constraint |
COUNT(right.id)=0 | No non-null matched ids in the group | Audit duplicated left rows and grouping grain |
COALESCE is useful for display but can erase the distinction between missing and zero. A customer with no invoice is not necessarily a customer with a zero-value invoice. Preserve a match indicator when downstream decisions need that distinction.
Prevent one-to-many LEFT JOIN multiplication
LEFT JOIN preserves left rows, but it does not preserve left row count. One customer with four addresses produces four result rows. Joining another one-to-many table can multiply combinations. This is correct relational behavior and a common source of inflated counts and sums.
WITH ranked_addresses AS (
SELECT
a.*,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY effective_from DESC, address_id DESC
) AS rn
FROM customer_addresses a
)
SELECT c.customer_id, a.city
FROM customers c
LEFT JOIN ranked_addresses a
ON a.customer_id = c.customer_id
AND a.rn = 1;This restores zero-or-one address per customer only if “latest” is the correct rule. Other needs may require pre-aggregation, returning a collection, or retaining every child row at child grain. Do not use DISTINCT as a reflex; it can hide duplicated projections without repairing semantics.
Use LEFT JOIN as an anti-join carefully
To find customers with no orders, left join orders and keep rows where a non-nullable order key is null. NOT EXISTS often communicates the existence question more directly and cannot multiply output rows.
SELECT c.customer_id
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;
SELECT c.customer_id
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);When the question is “no completed orders,” the status condition belongs inside the join or EXISTS subquery. Putting it in the outer WHERE and also testing null creates contradictory logic. Test customers with no orders, only pending orders, and at least one completed order.
Keep preservation clear across several LEFT JOINs
A chain of left joins can create a false sense that the first table is always fully preserved. A later inner join to a nullable child can remove rows. A WHERE predicate on any later table can reject null-extended records. Two one-to-many branches can multiply each other. Draw the relationship graph and label expected cardinality for every edge.
- Choose the preserved population. State why every row from the first stage belongs.
- Add one relationship. Measure row count, distinct preserved keys, and unmatched rate.
- Validate right-side uniqueness. Enforce or profile the key expected to be zero-or-one.
- Reduce child detail. Aggregate or rank before joining when final output stays at parent grain.
- Inspect downstream filters. Mark predicates that can reject null-extended rows.
- Reconcile metrics. Compare totals before and after each relationship.
Review LEFT JOIN performance without changing meaning
Optimizers can choose hash, merge, or nested-loop algorithms and may reorder operations where semantics allow. Outer-join preservation restricts some transformations. Pushing a predicate into the right input may be safe when it defines eligible matches; pushing a null-rejecting predicate can effectively change the join. Validate results before celebrating a faster plan.
| Plan signal | Possible issue | Check |
|---|---|---|
| Actual rows exceed estimate | Unexpected right-side duplicates or skew | Key frequency and statistics |
| Large hash or spill | Wide or excessive right input | Early safe filter and projection |
| Unmatched rate near zero | LEFT may be unnecessary or a data-quality assumption | Business requirement and enforced foreign key |
| Repeated child scans | Correlated or nested access path | Actual loops, indexes, and set-based rewrite |
Validate LEFT JOIN results before production
- Assert every required left key appears at least once.
- Test no match, one match, several matches, null keys, and malformed keys.
- Compare ON and WHERE placement against a written business question.
- Measure total rows, distinct left keys, and match counts after each join.
- Use a guaranteed non-null right key when detecting unmatched rows.
- Reconcile sums and counts before and after one-to-many relationships.
- Inspect actual plans with representative skew and unmatched rates.
Review LEFT JOIN behavior with adversarial data
A production-safe review starts by naming the preserved population in plain language. “Every active customer at month end” is testable; “customers joined to orders” is not. Capture a stable control count for that population before the join. After each relationship, compare distinct preserved keys, total rows, matched keys, unmatched keys, and maximum right-side matches. These measurements separate legitimate child detail from accidental loss or multiplication.
Create fixtures that force the edge cases: a left row with no partner, one partner, and several partners; a right row whose descriptive attribute is genuinely null; duplicate right keys; null join keys; status changes at the reporting boundary; and a child row outside the eligible date range. Run the intended query and an independently written control query. If both are derived from the same flawed join, agreement proves little, so use direct counts or source-system invariants where possible.
| Observed symptom | Likely cause | Decisive test |
|---|---|---|
| Left count falls | Null-rejecting WHERE or later INNER JOIN | List missing left keys at each stage |
| Measures increase | One-to-many or many-to-many expansion | Frequency profile on both join inputs |
| “Missing” category looks too large | Real nulls confused with unmatched rows | Test a guaranteed non-null right key |
Performance review comes after semantic proof. Inspect whether the right input is wider or larger than expected, whether estimates understand key skew, and whether a safe eligibility filter can reduce it before hashing or sorting. Preserve a reconciliation query and the edge-case fixture in automated tests. A faster left join is not an improvement if it changes who remains, what null means, or how many times a measure is counted.
Record the unmatched rate as a monitored contract rather than a one-time observation. A sudden rise can indicate delayed ingestion, changed identifiers, a broken effective-date condition, or a new source population. Alert on both loss and unexpected improvement: a match rate that becomes exactly 100 percent may mean a downstream filter has silently removed the very records the outer join was intended to preserve.
Inspect the complete LEFT JOIN query
Paste a sanitized complete statement into the InfiniSynapse SQL Complexity Checker so every join, filter, CTE, subquery, window, and aggregate layer can be reviewed together. Then validate unmatched cases, cardinality, and execution evidence in your database.
Open SQL Complexity CheckerRemove credentials, secrets, personal data, and sensitive literals.LEFT JOIN SQL frequently asked questions
It returns every left row and every matching right row, using NULL for right columns when no match exists.
INNER JOIN keeps only matching pairs; LEFT JOIN also preserves unmatched left rows.
A right-side predicate rejects the NULL-extended row after joining unless it explicitly accepts NULL.
One left row is returned once per right-side match. Non-unique keys can create one-to-many or many-to-many expansion.
Test a non-nullable right key with IS NULL after a left join, or use NOT EXISTS.
