SQL Joins Deep Dive Cheat Sheet
Breaks down INNER, LEFT/RIGHT/FULL OUTER, CROSS, and SELF joins with execution semantics and query examples for combining relational tables.
Basic Join Syntax
INNER, LEFT, RIGHT, and FULL OUTER joins.
-- INNER JOIN: only matching rows from both tablesSELECT o.id, c.nameFROM orders oINNER JOIN customers c ON o.customer_id = c.id;-- LEFT (OUTER) JOIN: all rows from left, NULLs if no match on rightSELECT c.name, o.idFROM customers cLEFT JOIN orders o ON o.customer_id = c.id;-- RIGHT (OUTER) JOIN: all rows from right, NULLs if no match on leftSELECT c.name, o.idFROM customers cRIGHT JOIN orders o ON o.customer_id = c.id;-- FULL OUTER JOIN: all rows from both, NULLs where no match (not in MySQL)SELECT c.name, o.idFROM customers cFULL OUTER JOIN orders o ON o.customer_id = c.id;
Self Join & Cross Join
Joining a table to itself and producing a Cartesian product.
-- SELF JOIN: relate rows within the same table (e.g., employee -> manager)SELECT e.name AS employee, m.name AS managerFROM employees eJOIN employees m ON e.manager_id = m.id;-- CROSS JOIN: Cartesian product, every row of A with every row of BSELECT s.size, c.colorFROM sizes sCROSS JOIN colors c;
Join Types at a Glance
Quick reference for what each join returns.
- INNER JOIN- Returns only rows where the join condition matches in both tables
- LEFT JOIN- Returns all left-table rows, with NULLs for unmatched right-table columns
- RIGHT JOIN- Returns all right-table rows, with NULLs for unmatched left-table columns; equivalent to swapping tables and using LEFT JOIN
- FULL OUTER JOIN- Returns all rows from both tables, NULLs where no match exists; MySQL lacks native support, emulate with UNION of LEFT and RIGHT joins
- CROSS JOIN- Produces the Cartesian product of both tables (rows_A * rows_B); use deliberately, it has no ON clause
- SELF JOIN- A table joined to itself via aliases, used for hierarchical or comparative relationships
- NATURAL JOIN- Joins automatically on columns with identical names in both tables; avoid in production, it's fragile to schema changes
Semi-Joins & Anti-Joins
Filtering on existence of related rows without duplicating output.
-- Semi-join: customers who have at least one order (EXISTS is typically faster than IN)SELECT c.*FROM customers cWHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);-- Anti-join: customers with NO ordersSELECT c.*FROM customers cWHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);-- Anti-join via LEFT JOIN / IS NULL patternSELECT c.*FROM customers cLEFT JOIN orders o ON o.customer_id = c.idWHERE o.id IS NULL;
LATERAL Joins (Correlated Subqueries in FROM)
Run a per-row subquery that can reference columns from earlier tables in the FROM list.
-- Top 3 most recent orders per customer, without a window functionSELECT c.name, recent.order_id, recent.order_dateFROM customers cCROSS JOIN LATERAL ( SELECT o.id AS order_id, o.order_date FROM orders o WHERE o.customer_id = c.id ORDER BY o.order_date DESC LIMIT 3) recent;-- LEFT JOIN LATERAL keeps customers with zero orders (LATERAL result can be empty)SELECT c.name, recent.order_idFROM customers cLEFT JOIN LATERAL ( SELECT o.id AS order_id FROM orders o WHERE o.customer_id = c.id ORDER BY o.order_date DESC LIMIT 1) recent ON true;-- SQL Server / T-SQL equivalent keyword: CROSS APPLY / OUTER APPLY
Reading Join Algorithms in EXPLAIN
The planner picks nested loop, hash, or merge join based on cardinality and indexes.
EXPLAIN ANALYZESELECT o.id, c.nameFROM orders oJOIN customers c ON o.customer_id = c.idWHERE o.status = 'shipped';-- Nested Loop: cheap when the outer side is small and an index exists-- on the inner side's join column; scales as O(outer_rows * index lookup)-- Hash Join: builds a hash table on the smaller input, probes with the-- larger; good for equi-joins with no useful index, needs work_mem-- Merge Join: both inputs pre-sorted (or sorted for the join) then-- merged in one pass; efficient when data is already ordered-- (e.g., joining on a clustered/primary key range scan)-- Force a plan shape to compare costs (Postgres, for diagnosis only):SET enable_nestloop = off;EXPLAIN ANALYZE SELECT ...;RESET enable_nestloop;
Common Join Pitfalls
Subtle correctness bugs that surface once joins get more complex.
- Fan-out / row multiplication- Joining a one-to-many relation before aggregating inflates SUM/COUNT results; aggregate each side separately or use a subquery before joining
- ON vs WHERE with OUTER JOIN- A filter on the right table in WHERE silently turns a LEFT JOIN into an INNER JOIN by discarding NULL-padded rows; put it in the ON clause to keep unmatched rows
- Implicit comma-join- 'FROM a, b WHERE a.id = b.a_id' is a legal but outdated CROSS JOIN + filter; omitting the WHERE by accident produces a silent Cartesian product
- USING vs ON- USING(col) requires identical column names on both sides and collapses them into one output column; ON allows differently-named keys and keeps both columns
- SELECT * with joins- Ambiguous or duplicate column names across joined tables break downstream consumers; always alias and explicitly list columns in production queries
- Join order myths- The FROM clause order doesn't dictate execution order for INNER joins (the optimizer reorders freely); it DOES matter for chained OUTER joins, which are evaluated left to right
- NULL keys never match- A join condition 'a.x = b.x' never matches when either side is NULL, even for two NULLs; use IS NOT DISTINCT FROM (Postgres) if NULL-matching is intended
Emulating FULL OUTER JOIN in MySQL
MySQL has no native FULL OUTER JOIN; combine LEFT and RIGHT results with UNION.
-- Emulated FULL OUTER JOIN: union of LEFT JOIN and the unmatched RIGHT rowsSELECT c.id AS customer_id, c.name, o.id AS order_idFROM customers cLEFT JOIN orders o ON o.customer_id = c.idUNIONSELECT c.id AS customer_id, c.name, o.id AS order_idFROM customers cRIGHT JOIN orders o ON o.customer_id = c.id;-- UNION (not UNION ALL) dedupes the rows that both queries return-- (the matched rows), leaving each unmatched row from either side exactly once.
Composite-Key & Non-Equi Joins
Joins aren't limited to a single equality predicate.
-- Composite key join (both columns must match)SELECT *FROM shipments sJOIN inventory i ON s.warehouse_id = i.warehouse_id AND s.sku = i.sku;-- Non-equi (range) join: attach the pricing tier active on the order dateSELECT o.id, o.order_date, p.tier_nameFROM orders oJOIN price_tiers p ON o.order_date >= p.valid_from AND (o.order_date < p.valid_to OR p.valid_to IS NULL);-- Non-equi joins can't use a plain b-tree equality index efficiently;-- ensure valid_from/valid_to are indexed and the range is selective,-- or the planner falls back to a nested loop scanning every tier.
Prefer NOT EXISTS over NOT IN for anti-joins when the subquery column can be NULL — NOT IN returns an empty result set entirely if any row in the subquery is NULL, a common silent bug.