skip to content

Joins

How rows from several tables are combined: INNER, the OUTER family, CROSS, LATERAL and self joins, ON versus USING, ON versus WHERE placement, NULLs in join keys, and semi and anti-join patterns. Interviewers use joins as the main SQL screen because a misplaced predicate or a duplicated key silently changes the whole result set.

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

explore

questions

page 1 of 2

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

How many rows does a CROSS JOIN of a 100-row table and a 20-row table return, and why?

level: juniorimportance: must knowfreq 78%

basics

~20 s

2,000 rows. A CROSS JOIN returns the Cartesian product: every row of the left table paired with every row of the right, with no join predicate. The output size is N times M, never N plus M.

open as a page

What rows does a FULL OUTER JOIN return that an INNER JOIN of the same tables does not?

level: juniorimportance: must knowfreq 70%

basics

~20 s

FULL OUTER JOIN returns every matched pair plus every unmatched row from both tables. An unmatched left row gets NULLs in all right-side columns, and an unmatched right row gets NULLs in all left-side columns.

open as a page

What does an INNER JOIN ... ON return, and which rows does it silently drop?

level: juniorimportance: must knowfreq 88%

basics

~20 s

INNER JOIN emits one output row for every pair of left and right rows whose ON predicate evaluates to TRUE. Any row on either side with no qualifying partner is dropped from the result, with no warning and no NULL placeholder.

open as a page

What does LEFT OUTER JOIN return for left rows that have no match on the right?

level: juniorimportance: must knowfreq 88%

basics

~10 s

LEFT OUTER JOIN keeps every row of the left table. When the ON predicate matches no right row, the left row is still returned once, with every right-side column filled in as NULL.

open as a page

Why must a self-join give the table two aliases, as in employees e JOIN employees m?

level: juniorimportance: must knowfreq 78%

basics

~20 s

A join combines two row sources, not two files. Aliasing the same table twice creates two independently named sources, so e.manager_id and m.employee_id refer to different rows. Without aliases every column reference is ambiguous and the statement is rejected.

open as a page

How does JOIN ... USING (customer_id) differ from an equivalent JOIN ... ON predicate?

level: juniorimportance: must knowfreq 55%

basics

~20 s

USING applies the same equality test as the matching ON predicate, but merges the two same-named columns into a single output column: SELECT * returns customer_id once, and the rest of the query references it unqualified.

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

Why does SELECT * FROM orders o, customers c WHERE o.total > 100 return millions of rows?

level: middleimportance: must knowfreq 68%

basics

~20 s

The comma in the FROM list relates nothing, so the query is an accidental Cartesian product. The WHERE clause filters only orders, then every surviving order is paired with every customer: the result is (matching orders) times (customers) rows.

open as a page

Why can an INNER JOIN return more rows than either of the joined tables contains?

level: middleimportance: must knowfreq 72%

basics

~20 s

A join emits one row per qualifying pair, not per input row. If a key value appears twice on the left and three times on the right, those rows alone produce six output rows, so the result can far exceed both inputs.

open as a page

What does the LATERAL keyword let a derived table in FROM do?

level: middleimportance: must knowfreq 45%

basics

~20 s

LATERAL lets a derived table in the FROM clause reference columns of the FROM items written before it. Semantically the subquery is evaluated once per left-hand row, so a correlated subquery becomes a join that can return many rows and many columns.

open as a page

With a self-join, how do you find every row in users that shares an email with another row?

level: middleimportance: must knowfreq 65%

basics

~20 s

Join users to itself on equal email and unequal key: ON a.email = b.email AND a.user_id <> b.user_id. Every row that has a twin is returned, once per twin, so use DISTINCT when three or more rows can share an address.

open as a page

How do you list customers with no orders using a LEFT JOIN and IS NULL?

level: juniorimportance: should knowfreq 80%

basics

~20 s

LEFT JOIN orders onto customers, then keep only the rows the join failed to match by testing a never-NULL right-side column in the WHERE clause, for example WHERE o.customer_id IS NULL. Those rows are the customers with no order.

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

Is FROM a CROSS JOIN b WHERE a.id = b.a_id equivalent to FROM a INNER JOIN b ON a.id = b.a_id?

level: middleimportance: should knowfreq 46%

basics

~20 s

Yes for inner joins: the standard defines an inner join as the Cartesian product filtered by the join predicate, so both forms return the same rows. CROSS JOIN accepts no ON clause of its own, and the explicit INNER JOIN form reads better.

open as a page

Why does a FULL OUTER JOIN result need COALESCE(l.customer_id, r.customer_id) for its key column?

level: middleimportance: should knowfreq 52%

basics

~20 s

A full outer join NULL-extends whichever side is unmatched, so each key column is NULL for rows that came only from the other table. COALESCE(l.customer_id, r.customer_id) merges the two into one column that is always populated.

open as a page

How do you emulate a FULL OUTER JOIN on an engine that supports only LEFT and RIGHT joins?

level: middleimportance: should knowfreq 58%

basics

~20 s

Run a LEFT JOIN for the matched and left-only rows, then UNION ALL a second branch that keeps only the right-side rows with no left match. Use UNION ALL, not UNION, so legitimate duplicate rows survive.

open as a page

Is the comma join FROM orders o, customers c WHERE o.customer_id = c.id the same as INNER JOIN ... ON?

level: middleimportance: should knowfreq 58%

basics

~20 s

For an inner join the two forms return exactly the same rows: a comma in FROM is a cross join, and the WHERE predicate then filters it. Explicit JOIN ... ON is preferred because it separates join conditions from filters and can express outer joins.

open as a page

When would you write an INNER JOIN whose ON predicate is a range instead of an equality?

level: middleimportance: should knowfreq 45%

basics

~20 s

When the match is defined by containment rather than equal keys: assigning an amount to a price tier, a timestamp to a validity window, or a value to a bucket. The ON clause accepts any boolean predicate, not only column equality.

open as a page

What does LEFT JOIN LATERAL ... ON TRUE give you that JOIN LATERAL does not?

level: middleimportance: should knowfreq 35%

basics

~20 s

It keeps left-hand rows whose LATERAL subquery returned no rows, NULL-extending the subquery's columns instead of dropping the row. The inner forms, CROSS JOIN LATERAL and JOIN LATERAL ... ON TRUE, discard those rows entirely.

open as a page

Why can every RIGHT OUTER JOIN be rewritten as a LEFT OUTER JOIN?

level: middleimportance: should knowfreq 52%

basics

~20 s

RIGHT OUTER JOIN preserves the table written after the keyword, and LEFT preserves the one written before it. Swapping the two table references and changing RIGHT to LEFT therefore yields the same rows; only the default column order changes.

open as a page

Why does pairing a table with itself using a.id <> b.id return every pair twice?

level: middleimportance: should knowfreq 48%

basics

~20 s

The predicate a.id <> b.id keeps both orderings of every pair, so (1,2) and (2,1) both survive. Replacing it with a.id < b.id keeps exactly one ordering, cutting n(n-1) rows to n(n-1)/2 while still excluding self-pairs.

open as a page

What does COUNT(DISTINCT h.salary) + 1 compute in a self-join with ON h.salary > e.salary?

level: middleimportance: should knowfreq 52%

basics

~20 s

It computes each employee's salary rank: the number of distinct salaries above theirs, plus one for their own. Tied salaries share a rank and the numbering has no gaps, matching dense-rank behaviour, provided the join is a LEFT JOIN so the top earner survives.

open as a page

Which columns does NATURAL JOIN match on, and what does it return if the tables share none?

level: middleimportance: should knowfreq 45%

basics

~20 s

NATURAL JOIN implicitly equates every column name the two tables share and merges each matched pair into one output column. If they share no column names the join condition is empty, so the result is a Cartesian product.

open as a page

showing 1–30 of 47