Overview
The join type decides what happens to rows with no match, and a wrong choice silently changes row counts. Examples use Postgres syntax. Index advice for join keys is in postgres-indexes.
Join types
Choose the join by which side’s rows you must keep.
| Join | Rows returned | Use for |
|---|---|---|
INNER JOIN | Matches on both sides. | Both entities must exist. |
LEFT JOIN | All left rows; NULL for no right match. | Optional related rows. |
RIGHT JOIN | All right rows. | Rarely; swap the tables and use LEFT JOIN. |
FULL OUTER JOIN | All rows from both sides. | Reconciling two sources. |
CROSS JOIN | Every left row with every right row. | Generating combinations; no ON. |
JOIN LATERAL | Right side is a subquery that sees each left row. | Top N per group. |
-- LEFT JOIN: users with and without orders
SELECT u.name, count(o.id) AS orders
FROM users u LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id;
-- Top 3 orders per user
SELECT u.name, r.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) r;
-- Self join: employee and manager
SELECT e.name, m.name AS manager
FROM employees e LEFT JOIN employees m ON m.id = e.manager_id;Pick the row set
Prefer EXISTS and NOT EXISTS for presence checks.
| Want | Write |
|---|---|
| Rows matching on both sides | INNER JOIN ... ON ... |
| Left rows with no right match | LEFT JOIN ... WHERE r.id IS NULL, or WHERE NOT EXISTS (...) |
| Left rows that have a right match, once each | WHERE EXISTS (SELECT 1 FROM r WHERE r.l_id = l.id) |
| Rows in only one of two sources | FULL OUTER JOIN ... WHERE l.id IS NULL OR r.id IS NULL |
Bugs that return wrong counts
Check join keys and filters first when counts look wrong.
| Bug | Symptom | Fix |
|---|---|---|
WHERE a.id = NULL | Never matches. | IS NULL or IS NOT NULL. |
Filtering the right table of a LEFT JOIN in WHERE | Becomes an inner join; unmatched rows vanish. | Move the condition into ON. |
| One-to-many join before aggregating | Sums and counts inflate. | Aggregate the many side in a subquery or CTE first. |
Comma join (FROM a, b) with a missing condition | Cartesian product. | Use explicit JOIN ... ON. |
NOT IN (subquery) where the subquery returns a NULL | No rows at all. | Use NOT EXISTS. |
SELECT * over joined tables | Duplicate column names. | List columns and alias tables. |
| No index on the foreign key | Slow joins; Postgres does not index foreign keys automatically. | Add a B-tree index on the referencing column. |
-- Wrong: drops users with no paid orders
SELECT u.name FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.status = 'paid';
-- Right: keep every user, join only paid orders
SELECT u.name FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';Gotchas
Check engine differences when porting queries.
| Gotcha | Fix |
|---|---|
MySQL has no FULL OUTER JOIN. | Union a LEFT JOIN and a RIGHT JOIN. |
LATERAL is standard SQL, not Postgres-only (MySQL 8.0.14 and later accept it). | Check the engine version before porting. |
USING (col) merges the join column in SELECT *. | List columns explicitly when ORMs expect both. |