Overview
A join combines rows from two relations based on a condition. The join type determines what happens when no match exists. Choosing the wrong type produces silently wrong result counts; understanding NULL propagation and Cartesian-product risks prevents the most common mistakes. Examples use Postgres syntax throughout; behavior is identical in MySQL, SQLite, and most other ANSI SQL engines except where noted. For index strategy behind join performance, see postgres-indexes.
Join types
Pick the join that reflects which rows are required from each side.
| Join type | Rows returned | Typical use |
|---|---|---|
INNER JOIN | Only rows with a match on both sides. | Require both entities to exist. |
LEFT JOIN | All rows from left; NULL for unmatched right. | Optional right-side relationship. |
RIGHT JOIN | All rows from right; NULL for unmatched left. | Rarely preferred; rewrite as LEFT JOIN with tables swapped. |
FULL OUTER JOIN | All rows from both sides; NULL for missing side. | Reconcile two data sources with partial overlap. |
CROSS JOIN | Cartesian product; every left row paired with every right row. | Combination generation; no ON clause. |
SELF JOIN | Table joined to itself via aliases. | Hierarchy traversal, adjacency lists, finding duplicates. |
LATERAL JOIN | Right side is a subquery that references each left row. | Per-row subqueries, LIMIT per group. |
-- INNER JOIN
SELECT o.id, u.name
FROM orders o
JOIN users u ON u.id = o.user_id;
-- LEFT JOIN: users with or without orders
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id;
-- FULL OUTER JOIN: find rows missing on either side
SELECT a.key, b.key
FROM source_a a
FULL OUTER JOIN source_b b ON a.key = b.key
WHERE a.key IS NULL OR b.key IS NULL;
-- CROSS JOIN: all size/color combinations
SELECT s.name AS size, c.name AS color
FROM sizes s
CROSS JOIN colors c;Set diagram reference
Think in terms of which rows each join keeps.
| Diagram | Pattern | What to write |
|---|---|---|
| Intersection only | Both sides match | INNER JOIN ... ON ... |
| Left circle only | Left side, no match on right | LEFT JOIN ... WHERE right.id IS NULL |
| Right circle only | Right side, no match on left | RIGHT JOIN ... WHERE left.id IS NULL |
| Left circle full | All left, matched right is optional | LEFT JOIN |
| Right circle full | All right, matched left is optional | RIGHT JOIN (or swap to LEFT JOIN) |
| Full union | All rows from both sides | FULL OUTER JOIN |
| Outer ring only | Rows not in the intersection | FULL OUTER JOIN ... WHERE left.id IS NULL OR right.id IS NULL |
Self join and lateral
These patterns do not fit neatly in the diagram.
-- Self join: employee and their manager
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id;
-- LATERAL: top 3 orders per customer (Postgres-specific)
SELECT u.name, recent.created_at
FROM users u
CROSS JOIN LATERAL (
SELECT created_at
FROM orders o
WHERE o.user_id = u.id
ORDER BY created_at DESC
LIMIT 3
) recent;LATERAL allows the subquery to reference columns from the outer FROM clause row. Without LATERAL, the subquery is independent.
Join conditions and performance
The ON clause drives both correctness and index usage.
| Practice | Reason |
|---|---|
| Put the foreign key on the smaller table side. | The planner uses the index on the join key of the larger table. |
Avoid functions on join columns: ON f(a.id) = b.id. | Functions prevent index use; move the transformation to the query setup. |
Use USING (col) when both sides share the same column name. | Cleaner syntax; the column appears once in SELECT *. |
Prefer JOIN over comma-separated tables. | Comma syntax is a CROSS JOIN; missing WHERE conditions produce Cartesian products silently. |
Common bugs
The mistakes that produce wrong row counts without errors.
| Bug | Symptom | Fix |
|---|---|---|
NULL comparison with = | WHERE a.id = NULL never matches. | Use IS NULL / IS NOT NULL. |
| Filtering a LEFT JOIN in WHERE | WHERE right.col = 'x' turns the LEFT JOIN into an INNER JOIN. | Move the filter into the ON clause. |
| One-to-many join without aggregation | Result has more rows than expected; aggregates are inflated. | Add GROUP BY or use a subquery to aggregate first. |
CROSS JOIN without ON | Row count = left rows × right rows; memory blows up on large tables. | Always add an ON clause; use CROSS JOIN only when Cartesian product is intended. |
Duplicate column names with SELECT * | Column names collide when both tables share a name. | Alias tables and list columns explicitly. |
| Missing index on join key | Seq scan on large tables; slow queries. | Add a B-tree index on the foreign key column. |
-- Bug: filtering NULL-side of LEFT JOIN in WHERE converts to INNER JOIN
-- Wrong
SELECT u.name FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid'; -- drops users with no orders
-- Correct: filter in ON clause
SELECT u.name FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';Common gotchas
RIGHT JOINis logically equivalent toLEFT JOINwith the table order swapped. PreferLEFT JOINfor consistency; mixing both styles in one query is confusing.FULL OUTER JOINis not available in MySQL 5.x. Emulate withUNIONof aLEFT JOINand aRIGHT JOIN.USING (col)removes the duplicate column fromSELECT *, which is usually desirable but surprises people relying on positional column access in ORM results.- A one-to-many join inflates aggregate functions unless you aggregate the many-side first in a subquery or CTE.
COUNT(*)on an inflated join counts join rows, not entity rows. - Lateral joins are Postgres-specific syntax. The equivalent in MySQL is a correlated subquery in the
FROMclause (allowed from MySQL 8.0.14).