skip to content

Join Semantics and Pitfalls

The traps that separate people who write joins from people who understand them: predicate placement, NULL behavior, and semi/anti-join rewrites. Most 'why is this query wrong?' interview questions live here.

part ofSQLoverview, primer and where to startread it →
on this pageshow

questions

14

In an INNER JOIN on a.dept_id = b.dept_id, what happens to rows where dept_id is NULL?

level: juniorimportance: must knowfreq 72%

answer

  1. equality has three possible outcomes
  2. unknown is not the same as equal
  3. the predicate must come out TRUE
  4. two NULLs are two different unknowns
  5. UNKNOWN is discarded just like FALSE

basics

~20 s

They disappear from the result. NULL = NULL evaluates to UNKNOWN rather than TRUE, and a join keeps only pairs whose predicate is TRUE, so a NULL key matches nothing — not even a NULL key on the other side.

solid answer

~50 s

A join predicate is evaluated for each candidate row pair, and only pairs where it evaluates to `TRUE` survive. NULL is a marker for *unknown*, so `a.dept_id = b.dept_id` with a NULL on either side yields `UNKNOWN`, which is not `TRUE`. An employee row with a NULL `dept_id` therefore joins to no department, a department row with a NULL `dept_id` joins to no employee, and the two never find each other. With an INNER JOIN both rows simply vanish — no error, no warning, just a smaller result. With a `LEFT JOIN` the left row survives, but it survives as an *unmatched* row: every right-hand column comes back NULL-extended. If you actually want NULL to match NULL you have to ask for it explicitly, with `IS NOT DISTINCT FROM` or a sentinel value; plain equality will never do it.

code

sql · 7 lines
sql
INSERT INTO employees VALUES (1,'Ada',10),(2,'Bo',20),(3,'Cy',NULL);
INSERT INTO departments VALUES (10,'Eng'),(20,'Ops'),(NULL,'Unassigned');

SELECT e.name, d.dept_name
FROM employees e
JOIN departments d ON e.dept_id = d.dept_id;
-- 2 rows: Ada/Eng and Bo/Ops. Cy never meets 'Unassigned'.

go deeper

for a junior

Remember the fact and say it plainly: a row with a NULL join key matches nothing under =, so an INNER JOIN silently drops it. Be able to point out that no error is raised.

for a middle

Explain the mechanism: comparisons return TRUE, FALSE or UNKNOWN, the ON clause admits only TRUE, and a conjunction of a matching column with an UNKNOWN one is still UNKNOWN. Walk through what each join type then does with the unmatched row.

for a senior

Show how you catch this in production: compare inner and outer join counts, count NULL keys before trusting a total, and treat a nullable foreign key as a known reconciliation hazard rather than waiting for a user to report a missing record.

for a principal

Own the modelling call. Decide whether a nullable foreign key should exist at all, or whether an explicit unknown-member row plus a NOT NULL column is the cheaper contract for every query author who will ever touch the table.

## The rule in one line A join predicate admits a pair of rows only when it evaluates to `TRUE`. The comparison `x = y` with a NULL on either side evaluates to `UNKNOWN`, so a row whose join key is NULL matches nothing on the other side — including a row whose join key is also NULL. ## Why equality cannot see NULL NULL is not a value; it is a marker meaning "no value is recorded here". Asking whether one unrecorded thing equals another unrecorded thing has no defensible answer, so SQL's comparison operators return a third truth value, `UNKNOWN`, whenever an operand is NULL. The `ON` clause behaves like `WHERE` in this respect: it is a filter that lets `TRUE` through and rejects both `FALSE` and `UNKNOWN`. That is also why `= NULL` never matches anything and why the language provides a separate `IS NULL` predicate for asking the only question that has an answer: "is this marker present?" ## What each join type does with it The comparison itself never changes. What changes between join types is only what happens to rows that failed to match: - **INNER JOIN** — a row with a NULL key on either side produces no output row at all. - **LEFT OUTER JOIN** — the left row is preserved because the left side is the preserved side, but it is preserved as an unmatched row: all right-hand columns are NULL-extended. It is *not* paired with right rows that happen to have NULL keys. A right row with a NULL key still disappears. - **RIGHT OUTER JOIN** — the mirror image. - **FULL OUTER JOIN** — a NULL-keyed row on each side appears as two separate unmatched rows, never as one matched pair. - **CROSS JOIN** — has no predicate, so NULL keys are irrelevant; every pair is produced. ## Composite keys make it worse With `ON a.region = b.region AND a.sku = b.sku`, a NULL in `sku` on one side makes that conjunct `UNKNOWN`. In three-valued logic `TRUE AND UNKNOWN` is `UNKNOWN`, so the whole predicate is `UNKNOWN` and the pair is rejected even though `region` matched perfectly. One nullable column in a multi-column key is enough to lose the row. ## Worked example ```sql CREATE TABLE employees (emp_id INT, name VARCHAR(50), dept_id INT); CREATE TABLE departments (dept_id INT, dept_name VARCHAR(50)); INSERT INTO employees VALUES (1,'Ada',10),(2,'Bo',20),(3,'Cy',NULL),(4,'Di',NULL); INSERT INTO departments VALUES (10,'Eng'),(20,'Ops'),(NULL,'Unassigned'); SELECT COUNT(*) FROM employees e JOIN departments d ON e.dept_id = d.dept_id; -- 2: Ada and Bo. Cy and Di match nothing, not even the 'Unassigned' row. ``` Even though a human reader sees an "Unassigned" department sitting right there with a NULL key, the engine will not connect it to Cy and Di. Two NULLs are two separate unknowns, not one shared value. ## How the bug actually shows up Nothing errors. The query looks correct in review and returns plausible data. The symptoms are indirect: a report total that is quietly lower than the source system's, a dashboard whose row count drops the day someone starts leaving a foreign key unpopulated, a reconciliation that always misses the same handful of records. The cheapest diagnostic is to count the offenders directly — `SELECT COUNT(*) FROM employees WHERE dept_id IS NULL` — and to compare the row count of the inner join against the same query written as a `LEFT JOIN`. A gap between the two is precisely the set of rows the equality predicate rejected. ## What to do about it Pick the fix that matches what NULL actually means in your data: - If NULL means "not applicable yet" and those rows still belong in the output, use an outer join and handle the NULL-extended columns deliberately. - If NULL genuinely should match NULL — typically when you are reconciling two extracts with the same optional attribute — say so with `ON a.k IS NOT DISTINCT FROM b.k`, which returns `TRUE` for two NULLs and `FALSE` when only one side is NULL. - Best of all, remove the ambiguity from the model: declare the column `NOT NULL` and add an explicit "unknown" row to the referenced table. Then ordinary equality does exactly the right thing and no reader has to remember any of this. ## The one sentence to keep Equality in a join predicate is a test for *known sameness*; NULL is the absence of knowledge, so it never satisfies that test on either side of any join.

  • Does a LEFT JOIN rescue the rows whose left-side key is NULL?
    It keeps them, but not by matching them. The left row is preserved because the left side is the preserved side; the predicate still evaluated to UNKNOWN, so the row comes back with every right-hand column NULL-extended. It is not paired with right rows that also have NULL keys. Rows with NULL keys on the right side still disappear entirely.
  • What happens with a two-column join key when only one column is NULL?
    The pair is still rejected. The predicate is a conjunction, and TRUE AND UNKNOWN is UNKNOWN, which the join filters out just like FALSE. So `ON a.region = b.region AND a.sku = b.sku` drops the pair when sku is NULL on one side, even though region matched exactly.
  • How would you measure how many rows a nullable join key is silently dropping?
    Count them directly with `SELECT COUNT(*) FROM employees WHERE dept_id IS NULL`, and run the same join once as INNER and once as LEFT; the difference in row counts is the set the equality predicate rejected. Doing that before trusting a total is a cheap habit on any query over a nullable foreign key.

saying these in an interview costs you the question

  • Says NULL = NULL is TRUE, so the rows match
  • Thinks two NULL keys join to each other
  • Believes LEFT JOIN makes NULL keys match right rows
  • Claims the engine raises an error on NULL join keys
  • Writes ON a.dept_id = NULL to catch those rows

context

open as a page

For an INNER JOIN, does it matter whether a filter goes in the ON clause or in WHERE?

level: juniorimportance: must knowfreq 70%

basics

~20 s

For an INNER JOIN, ON and WHERE are logically equivalent: a row must satisfy both predicates to survive either way. For outer joins they are not equivalent, because ON is evaluated before NULL-extension and WHERE after it.

open as a page

How do you list customers that have at least one order without repeating any customer?

level: juniorimportance: must knowfreq 80%

basics

~20 s

Filter with a semi-join instead of joining: WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id). An inner join emits one output row per matching order, so a customer with five orders appears five times.

open as a page

Why does adding WHERE o.status = 'PAID' to a LEFT JOIN drop customers with no orders?

level: middleimportance: must knowfreq 85%

basics

~20 s

Unmatched customers are NULL-extended, so o.status is NULL in those rows; NULL = 'PAID' evaluates to UNKNOWN, and WHERE keeps only rows where the predicate is TRUE. The filter therefore deletes every unmatched row, collapsing the LEFT JOIN into an INNER JOIN.

open as a page

Which of NOT EXISTS, NOT IN and LEFT JOIN ... IS NULL correctly returns customers with no orders?

level: middleimportance: must knowfreq 75%

basics

~20 s

All three can express the anti-join, but only NOT EXISTS is unconditionally safe. LEFT JOIN ... WHERE o.customer_id IS NULL is equivalent when the IS NULL test uses the join key and every orders predicate sits in ON. NOT IN breaks if the subquery yields any NULL.

open as a page

How do you write a join predicate that treats NULL on both sides as a match?

level: middleimportance: should knowfreq 44%

basics

~20 s

Use a null-safe comparison in the ON clause: IS NOT DISTINCT FROM treats two NULLs as equal and one NULL as unequal. MySQL spells the same idea <=>. A COALESCE sentinel on both sides also works but can collide with real data.

open as a page

After a LEFT JOIN, how do you tell a source NULL from an outer-join NULL?

level: middleimportance: should knowfreq 50%

basics

~20 s

Test a right-side column that cannot be NULL in the base table, usually its primary key. If that comes back NULL the row never matched; if it has a value the match happened, so any other NULL you see is real data from the matched row.

open as a page

Does LEFT JOIN orders o ON o.customer_id = c.id AND c.region = 'EU' remove non-EU customers?

level: middleimportance: should knowfreq 45%

basics

~10 s

No. The ON clause only decides which orders count as matches, so a non-EU customer is kept and NULL-extended rather than removed. To exclude those customers, put c.region = 'EU' in WHERE instead.

open as a page

Why can EXISTS filter on a related table without returning any of its columns?

level: middleimportance: should knowfreq 55%

basics

~20 s

EXISTS is a predicate, not a table reference: it yields TRUE or FALSE for the current outer row, and its subquery's range variables are out of scope in the outer SELECT list. A semi-join returns only left-side columns, at most one row per left row.

open as a page

What can go wrong when you join on COALESCE(a.code, 'N/A') = COALESCE(b.code, 'N/A')?

level: seniorimportance: should knowfreq 32%

basics

~20 s

It makes every NULL-keyed row on one side match every NULL-keyed row on the other, multiplying rows; and if the sentinel ever appears in real data, genuine rows join to missing ones. Wrapping both columns in a function also blocks ordinary index use.

open as a page

A LEFT JOIN report lost its zero-activity rows after a WHERE date filter was added — how do you diagnose and fix it?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Look for WHERE predicates naming the outer-joined alias: a date filter on the optional table discards NULL-extended rows and collapses the LEFT JOIN. Move the date range into the ON clause, then assert that every preserved-side row still appears.

open as a page

How do you find staging rows absent from a target table when the match key spans two columns?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Use NOT EXISTS with one correlated equality per key column, ANDed together. It is the portable multi-column anti-join and extends to any number of columns. A LEFT JOIN with both equalities in ON plus an IS NULL test on a target key column works too.

open as a page

When does adding DISTINCT to a JOIN fail to reproduce the EXISTS semi-join's result?

level: seniorimportance: should knowfreq 45%

basics

~20 s

DISTINCT de-duplicates the whole projected row, so it also collapses rows the left table genuinely holds more than once, while EXISTS preserves left-side multiplicity exactly. It also stops helping as soon as a right-side column is projected, since those values differ per match.

open as a page

In a LEFT JOIN, does WHERE (o.status = 'PAID' OR o.id IS NULL) equal putting the status test in ON?

level: seniorimportance: nice to knowfreq 28%

basics

~20 s

No. The two agree only for left rows with no match at all. A customer whose orders are all unpaid survives the ON version as a NULL-extended row, but the OR guard drops it, because its rows carry a non-NULL o.id.

open as a page