SQL
Beginner 10 min read

Joins

In one line

A join combines rows from two tables by matching them on a condition; the join type decides what happens to rows that have no match.

What it is

Relational databases split data across tables to avoid duplication: customers in one table, orders in another, linked by orders.customer_id. A join puts them back together for a query by pairing rows whose join condition (usually in ON) is TRUE.

INNER JOIN keeps only pairs that match. LEFT JOIN keeps every row of the left table and fills the right side with NULLs when there is no match; RIGHT JOIN is the mirror image. FULL OUTER JOIN keeps unmatched rows from both sides. CROSS JOIN pairs every row with every row, giving m x n rows.

A self join joins a table to itself under two aliases, for example employees e JOIN employees m ON e.manager_id = m.id to show each employee next to their manager.

Why interviewers ask

Joins are the most tested SQL skill because real questions span several tables. Interviewers check that you pick the right join type, predict how many rows come out, avoid accidental row multiplication, and know the classic traps: a WHERE filter that silently turns a LEFT JOIN into an INNER JOIN, and NOT IN with NULLs.

How it works

  1. 1Conceptually, a join starts from every combination of left and right rows (the cross product) and keeps the pairs where the ON condition is TRUE. Real databases never build the full cross product; they use a smarter algorithm.
  2. 2For an outer join, any left row (LEFT JOIN) or right row (RIGHT JOIN) or either (FULL JOIN) that found no partner is added back once, with NULLs for the other table's columns.
  3. 3ON decides which rows match. WHERE runs after the join and filters the joined result. For inner joins the two are equivalent; for outer joins they are not, because WHERE also sees the NULL-filled rows.
  4. 4Each left row appears once per matching right row. Joining one customer to 3 orders produces 3 rows, so joining to two different one-to-many tables at once multiplies rows (3 orders x 4 tickets = 12 rows per customer).
  5. 5The engine chooses a physical algorithm: nested loop (good when one side is small or the inner side has an index), hash join (build a hash table on the smaller input, probe with the other; good for large equality joins) or merge join (walk two sorted inputs together). PostgreSQL has all three; MySQL uses nested loops and, since 8.0.18, hash joins.

Worked example · INNER vs LEFT vs FULL on two tiny tables

  1. customers(id, name): (1, Ana), (2, Raj), (3, Mei). orders(id, customer_id, amount): (101, 1, 50), (102, 1, 30), (103, 2, 20), (104, 9, 15). Customer 9 does not exist.
  2. INNER JOIN ON c.id = o.customer_id: Ana matches 101 and 102, Raj matches 103, Mei matches nothing, order 104 matches nothing. Result: (Ana, 101), (Ana, 102), (Raj, 103). 3 rows.
  3. customers LEFT JOIN orders: the same 3 rows plus Mei once with NULL order columns: (Mei, NULL). 4 rows.
  4. FULL OUTER JOIN: the 4 rows above plus order 104 with NULL customer columns: (NULL, 104). 5 rows.
  5. Customers with no orders (anti-join): customers LEFT JOIN orders ... WHERE o.id IS NULL keeps only (Mei, NULL), so the answer is Mei.
  6. Trap: customers LEFT JOIN orders ON ... WHERE o.amount > 25 returns only Ana's two orders (50 and 30). Raj's order fails the filter, and Mei's row has amount NULL, which is UNKNOWN, so the LEFT JOIN has quietly become an INNER JOIN. Move the filter into ON (ON c.id = o.customer_id AND o.amount > 25) to keep Raj and Mei with NULLs.

Picture it

Think of a school dance list. INNER JOIN lists only the students who came with a partner. LEFT JOIN lists every student from your class, writing 'no partner' next to those who came alone. FULL OUTER JOIN lists everyone from both classes, partnered or not. CROSS JOIN is the teacher writing down every possible pair.

Reference code

-- customers(id, name), orders(id, customer_id, amount)

-- Every customer with their orders (customers without orders kept)
SELECT c.name, o.id AS order_id, o.amount
FROM customers c
LEFT JOIN orders o
  ON o.customer_id = c.id
 AND o.amount > 25          -- filter on the right table goes in ON
ORDER BY c.name, o.id;

-- Customers who never ordered (anti-join)
SELECT c.name
FROM customers c
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.customer_id = c.id
);

-- Each employee with their manager (self join)
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id;

On the example tables, the first query returns (Ana, 101, 50), (Ana, 102, 30), (Mei, NULL, NULL), (Raj, NULL, NULL): Raj stays because the amount filter is in ON. The second returns Mei. The self join keeps employees with no manager because it is a LEFT JOIN.

Key patterns

Anti-join (rows with no match)

When: Customers with no orders, users who never logged in

How: Use NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id) or LEFT JOIN ... WHERE o.id IS NULL. Avoid NOT IN when the subquery column can be NULL.

Semi-join (rows with at least one match)

When: Customers who placed any order, without duplicating them

How: Use EXISTS or IN instead of JOIN plus DISTINCT; it returns each customer once no matter how many orders they have.

Self join

When: Hierarchies or comparing rows in the same table

How: Alias the table twice: employees e JOIN employees m ON e.manager_id = m.id. Use LEFT JOIN to keep the CEO, who has no manager.

Pre-aggregate before joining

When: Joining one parent to two or more one-to-many children

How: Aggregate each child per parent key first, then join the one-row-per-key results, to avoid multiplying rows.

FULL OUTER JOIN in MySQL

When: Reconciling two lists in MySQL, which has no FULL JOIN

How: Combine a LEFT JOIN with a RIGHT JOIN (or LEFT JOIN with sides swapped) using UNION, or UNION ALL with an IS NULL filter on the second half.

Real-world use

E-commerce order pages

Showing an order means joining orders to order_items to products to get names and prices; Shopify-style stores run this on every order page and rely on indexes on the foreign key columns so each join is a quick lookup.

Finding gaps with anti-joins

Marketing tools find 'users who signed up but never ordered' with a LEFT JOIN ... IS NULL or NOT EXISTS, then send them a reminder email.

Data warehouses and star schemas

In Snowflake, BigQuery or Redshift a large fact table (sales) is joined to small dimension tables (date, product, store). Engines use hash joins with the small dimension tables as the build side.

ORMs and the N+1 problem

Django's select_related and Rails' includes/eager_load turn 'load 100 posts, then 100 separate author queries' into one query with a join (or one extra batched query), cutting 101 round trips to 1 or 2.

In a project

A SaaS company wants a weekly report of every customer account, its plan name, how many support tickets it opened and how much it paid, including accounts with no tickets or payments.

  1. Start from accounts and LEFT JOIN plans, so accounts without a plan still appear.
  2. Do not LEFT JOIN tickets and payments directly together: one account with 5 tickets and 3 payments would produce 15 rows and inflate both totals.
  3. Instead aggregate each child table first in its own subquery or CTE (tickets per account, payments per account), then LEFT JOIN those one-row-per-account results to accounts.
  4. Use COALESCE(ticket_count, 0) and COALESCE(paid, 0) to show zeros instead of NULLs for accounts with no activity.
  5. Check with EXPLAIN that the joins use indexes on account_id, and compare the row count to SELECT COUNT(*) FROM accounts.

Impact: The report lists every account exactly once with correct totals. Pre-aggregating before joining removed a fan-out bug that had been overstating revenue for accounts with many tickets.

Trade-offs

Use it when

  • INNER JOIN when you only want rows that exist on both sides
  • LEFT JOIN when the left table is the list you must show in full (all customers, all days)
  • EXISTS / NOT EXISTS when you only need to test for a match, not read the matching columns
  • FULL OUTER JOIN to reconcile two sources and find rows missing from either side

Avoid it when

  • Joining several one-to-many tables at once and then summing: rows multiply and totals inflate
  • Using DISTINCT to hide duplicate rows caused by a wrong join; fix the join instead
  • CROSS JOIN on large tables by accident (a missing ON condition): 10,000 x 10,000 rows is 100 million
  • NOT IN (subquery) when the subquery can return NULL

Cost: Nested loop: O(n x m) in the worst case, O(n log m) with an index on the inner table. Hash join: about O(n + m) time with memory for the smaller side. Merge join: O(n + m) on already-sorted inputs, plus O(n log n) to sort if they are not.

Common mistakes

  • Filtering the right-hand table of a LEFT JOIN in WHERE, which drops the NULL-filled rows and turns it into an inner join.
  • Not predicting fan-out: joining to a one-to-many table repeats the left row once per match, so SUM over the joined result double-counts.
  • Using NOT IN with a subquery that can contain NULL, which makes the whole condition UNKNOWN and returns no rows.
  • Forgetting the join condition (or joining on the wrong column) and producing a cross product.
  • Thinking LEFT JOIN is always slower than INNER JOIN; the planner treats them differently, but the right choice is about the result you need, not speed.

Interview questions

Quick revision

  • INNER: matches only. LEFT: all left rows, NULLs on the right if no match. FULL: all rows from both.
  • CROSS JOIN: m x n rows. A missing ON condition gives you this by accident.
  • Right-table filters in a LEFT JOIN go in ON, not WHERE.
  • Each match repeats the left row: watch fan-out before SUM or COUNT.
  • Anti-join: NOT EXISTS or LEFT JOIN ... IS NULL. Avoid NOT IN with NULLs.
  • Self join = same table, two aliases (employee and manager).
  • MySQL has no FULL OUTER JOIN: emulate with LEFT JOIN UNION RIGHT JOIN.
  • Algorithms: nested loop (small or indexed), hash (large equality), merge (sorted inputs).

Check yourself

1. customers has Ana (2 orders), Raj (1 order) and Mei (0 orders). How many rows does customers LEFT JOIN orders return?

2. What does adding WHERE o.status = 'paid' to customers c LEFT JOIN orders o usually do?

3. A CROSS JOIN between a 3-row table and a 4-row table returns how many rows?

4. orders.customer_id contains some NULLs. Which query safely finds customers with no orders?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free