SQL JOIN Cheatsheet - SQL JOIN Query Reference
For developers who join tables but still get the row counts wrong. The recurring trap is that a JOIN on a one-to-many parent duplicates parent rows, inflating results unless you dedupe. By the end you can pick INNER vs LEFT by whether un-matched rows should survive, understand the duplication pitfall on one-to-many joins, express candidate-missing conditions with LEFT JOIN ... WHERE ... IS NULL, and choose EXISTS for correlated subqueries.
Typical Use Case
For engineers who write reports daily, chase numbers that do not match, or speed up an existing query. The difficulty is never the syntax. Filtering a right-hand condition in WHERE after a LEFT JOIN quietly turns the outer join into an inner one, and joining many tables with the wrong driving table multiplies rows and confuses the optimiser. Covers the practical forms of INNER, LEFT, RIGHT, FULL and CROSS, self-joins, joins against aggregates, EXPLAIN, indexes, FORCE INDEX, and EXISTS.
INNER JOIN 5
SELECT * FROM users INNER JOIN orders ON users.id = orders.user_idSELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_idSELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id AND o.status = 'paid'SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id WHERE o.total > 100SELECT u.name, o.total, p.amount FROM users u JOIN orders o ON u.id = o.user_id JOIN payments p ON o.id = p.order_idLEFT / RIGHT JOIN 6
SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_idSELECT * FROM users RIGHT JOIN orders ON users.id = orders.user_idSELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id WHERE orders.id IS NULLSELECT * FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid'SELECT * FROM orders o RIGHT JOIN users u ON u.id = o.user_id WHERE o.id IS NULLSELECT u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.idFULL / CROSS JOIN 5
SELECT * FROM users FULL OUTER JOIN orders ON users.id = orders.user_idSELECT * FROM users u FULL JOIN orders o ON u.id = o.user_id WHERE u.id IS NULL OR o.user_id IS NULLSELECT * FROM table1 CROSS JOIN table2SELECT * FROM table1, table2SELECT * FROM sizes CROSS JOIN colorsSelf & Multi-table 5
SELECT e.name, m.name AS manager FROM employees e LEFT JOIN employees m ON e.manager_id = m.idSELECT a.name, b.name AS parent FROM categories a JOIN categories b ON a.parent_id = b.idSELECT * FROM a JOIN b ON a.id = b.a_id JOIN c ON b.id = c.b_idSELECT t1.name AS grandchild, t3.name AS grandparent FROM tree t1 JOIN tree t2 ON t1.parent_id = t2.id JOIN tree t3 ON t2.parent_id = t3.idSELECT u.name, o.total, p.amount FROM users u JOIN orders o ON u.id = o.user_id JOIN payments p ON o.id = p.order_idJOIN & Aggregation 5
SELECT u.name, COUNT(o.id) AS order_count FROM users u JOIN orders o ON u.id = o.user_id GROUP BY u.idSELECT u.name, SUM(o.total) AS total_spent FROM users u JOIN orders o ON u.id = o.user_id GROUP BY u.id HAVING SUM(o.total) > 1000SELECT c.name, AVG(o.total) AS avg_order FROM customers c JOIN orders o ON c.id = o.customer_id GROUP BY c.idSELECT u.name, COUNT(o.id) FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id HAVING COUNT(o.id) = 0SELECT DATE(o.created_at) AS d, COUNT(*) FROM orders o JOIN users u ON u.id = o.user_id GROUP BY DATE(o.created_at)JOIN Performance 6
EXPLAIN SELECT * FROM users u JOIN orders o ON u.id = o.user_idEXPLAIN ANALYZE SELECT * FROM users u JOIN orders o ON u.id = o.user_idCREATE INDEX idx_orders_user_id ON orders(user_id)SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_idSELECT * FROM users u STRAIGHT_JOIN orders o ON u.id = o.user_idSELECT * FROM users u JOIN orders o FORCE INDEX(idx_user_id) ON u.id = o.user_idUSING & Natural Join 4
SELECT * FROM users JOIN orders USING (user_id)SELECT * FROM users u JOIN orders o USING (user_id, tenant_id)SELECT * FROM users NATURAL JOIN ordersSELECT * FROM users u JOIN orders o ON u.id = o.user_id AND u.tenant_id = o.tenant_idSubquery & EXISTS 5
SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)SELECT * FROM orders o WHERE o.user_id IN (SELECT id FROM users WHERE status = 'active')SELECT u.name, (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS cnt FROM users uSELECT * FROM orders WHERE user_id = (SELECT MAX(user_id) FROM orders)Parameter matrix
| Parameter | Effect | Example |
|---|---|---|
INNER JOIN ... ON | Keeps only rows that match on both sides; unmatched rows disappear entirely. | SELECT * FROM users INNER JOIN orders ON users.id = orders.user_id |
JOIN <alias> ON ... | JOIN is shorthand for INNER JOIN, and aliases keep column references readable once several tables are in play. | SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id |
LEFT JOIN ... ON | Keeps every row of the left table and fills NULL on the right when nothing matches, making it the default choice whenever the left table drives the query. | SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id |
RIGHT JOIN ... ON | Keeps every row of the right table. In practice most teams rewrite it as a LEFT JOIN for readability. | SELECT * FROM users RIGHT JOIN orders ON users.id = orders.user_id |
... WHERE o.id IS NULL | The anti-join idiom: it finds left-table rows with no counterpart on the right, such as users without orders or products without a category. | SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id WHERE orders.id IS NULL |
LEFT JOIN ... ON u.id = o.user_id AND o.status = 'paid' | Moving the extra condition into ON is what stops the LEFT JOIN from silently degrading into an inner join. | SELECT u.name, o.total FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid' |
FULL OUTER JOIN | Keeps rows from both sides. MySQL has no support for it, so the equivalent is a LEFT JOIN and RIGHT JOIN combined with UNION. | SELECT * FROM users FULL OUTER JOIN orders ON users.id = orders.user_id |
CROSS JOIN / comma join | A cartesian product whose row count is the product of both tables. Reserve it for small tables where every combination is genuinely valid. | SELECT * FROM sizes CROSS JOIN colors |
Self join e LEFT JOIN employees m | Joins a table to itself, the standard way to express a hierarchy such as employee-to-manager or category-to-parent. | SELECT e.name, m.name AS manager FROM employees e LEFT JOIN employees m ON e.manager_id = m.id |
Chained multi-table JOIN | With three or more tables each step needs its own ON clause, and a missing one turns the middle of the query into a cartesian product. | SELECT u.name, o.total, p.amount FROM users u JOIN orders o ON u.id = o.user_id JOIN payments p ON o.id = p.order_id |
COUNT(o.id) AS order_count | Counting a specific column rather than the star is what makes a LEFT JOIN row with no match count as zero rather than one. | SELECT u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id |
GROUP BY ... HAVING SUM(o.total) > 1000 | HAVING filters aggregated results while WHERE filters rows before aggregation, and the two cannot be swapped. | SELECT u.name, SUM(o.total) AS total_spent FROM users u JOIN orders o ON u.id = o.user_id GROUP BY u.id HAVING SUM(o.total) > 1000 |
SELECT DATE(o.created_at) AS d, COUNT(*) | Grouping by the result of a function gives you time-series rollups; note that some engines require the function expression to appear in the GROUP BY as well. | SELECT DATE(o.created_at) AS d, COUNT(*) FROM orders o JOIN users u ON u.id = o.user_id GROUP BY DATE(o.created_at) |
EXPLAIN SELECT ... | Reveals the driving table, the join type and which index was hit in the plan, and it is the first move on a slow joining query. | EXPLAIN SELECT * FROM users u JOIN orders o ON u.id = o.user_id |
EXPLAIN ANALYZE SELECT ... | Actually runs the query and reports real row counts and timings per step, exposing stale statistics whenever estimated and actual rows differ by orders of magnitude. | EXPLAIN ANALYZE SELECT * FROM users u JOIN orders o ON u.id = o.user_id |
CREATE INDEX idx_orders_user_id ON orders(user_id) | Indexes the join key. A join whose key carries no index is guaranteed to fall back to a full scan on a large table. | CREATE INDEX idx_orders_user_id ON orders(user_id) |
STRAIGHT_JOIN | Forces the join to follow the written FROM order, which is a stopgap for when the optimiser picks the wrong driving table. | SELECT * FROM users u STRAIGHT_JOIN orders o ON u.id = o.user_id |
FORCE INDEX (idx_user_id) | Pins a specific index for one table. More precise than STRAIGHT_JOIN, but it bakes the index choice into the query. | SELECT * FROM users u JOIN orders o FORCE INDEX(idx_user_id) ON u.id = o.user_id |
JOIN ... USING (user_id) | Joins on identically named columns, which then appear only once in the result. A good fit for wide tables with consistent naming. | SELECT * FROM users JOIN orders USING (user_id) |
NATURAL JOIN | Joins implicitly on every shared column name, so renaming a column silently changes the join semantics. Strongly discouraged. | SELECT * FROM users NATURAL JOIN orders |
ON u.id = o.user_id AND u.tenant_id = o.tenant_id | In a multi-tenant schema the tenant column belongs in ON, otherwise the join leaks rows across tenants. | SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id AND u.tenant_id = o.tenant_id |
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) | A correlated existence check that stops at the first hit, and unlike IN it behaves correctly with nullable columns. | SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) |
WHERE NOT EXISTS (...) | Existence-based anti-join, which usually produces a cleaner plan than LEFT JOIN combined with IS NULL. | SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) |
user_id IN (SELECT id FROM users WHERE status = 'active') | A semi-join formulation. Beware that NOT IN returns an empty set entirely once the subquery yields a single NULL. | SELECT * FROM orders o WHERE o.user_id IN (SELECT id FROM users WHERE status = 'active') |
SELECT (SELECT COUNT(*) ...) AS cnt | A scalar subquery in the projection runs once per row, which slows down noticeably on large inputs; replace it with a join plus GROUP BY. | SELECT u.name, (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS cnt FROM users u |
WHERE user_id = (SELECT MAX(user_id) FROM orders) | The classic way to fetch a row holding an extreme value, more direct than sorting and taking the first row. | SELECT * FROM orders WHERE user_id = (SELECT MAX(user_id) FROM orders) |
Common pitfalls
SymptomUsers who should appear as having no orders vanish from the result entirely.
CauseA LEFT JOIN was written but the right-table condition landed in WHERE. After the NULL padding, WHERE orders.status = "paid" is false, so the join silently degrades into an inner join.
FixMove the right-table condition into the ON clause, or state INNER JOIN explicitly if only paid orders were wanted.
SymptomUsers with no orders are counted as having one.
CauseThe aggregate used COUNT(*) or COUNT(o.*), so the padded row of NULLs was counted as well.
FixCount a specific nullable column such as COUNT(o.id), which ignores NULL and returns zero when there is no match.
SymptomThe row count explodes and the multi-table result is orders of magnitude larger than expected.
CauseIn a chained three-table join one ON clause is missing or wrong, so a cartesian product appears in the middle.
FixVerify step by step: join two tables and check the row count, then add the third, and confirm the driving table and row estimates for each step in the EXPLAIN output.
SymptomA join that was fast becomes slow even though the data volume has not grown.
CauseThe join key lost its index, or stale statistics led the optimiser to the wrong driving table.
FixUse EXPLAIN to check for full scans and index the join key, and compare estimates against reality with EXPLAIN ANALYZE when in doubt.
SymptomThe same SQL behaves differently on MySQL and on PostgreSQL.
CauseSupport for FULL OUTER JOIN and USING differs, and so do string concatenation and GROUP BY rules, in particular whether every non-aggregated column must appear in the group.
FixWrite an equivalent form for the target engine, and keep cross-database reporting to joins, explicit column names and strict GROUP BY.
SymptomA NATURAL JOIN drops a column from the result and the semantics shift unexpectedly.
CauseNATURAL JOIN matches every shared column name implicitly, so adding a same-named column on either side changes the join condition without any warning.
FixSwitch to an explicit JOIN ... ON with the join columns written out, so schema changes cannot introduce a silent bug.
SymptomA query in a multi-tenant database returns rows belonging to another tenant.
CauseThe join condition only asserted equality of the primary key and forgot tenant_id, or the tenant filter was applied to one side alone.
FixPut tenant_id into the ON clause, filter on both sides, and consider a composite index that leads with the tenant column.
SymptomA query using NOT IN against a subquery comes back with no rows at all.
CauseThe subquery yields a NULL, and NOT IN evaluates to unknown for every row once a NULL is present, so nothing can be returned.
FixUse NOT EXISTS instead, or filter the NULLs out inside the subquery.
Troubleshooting
1Read the plan and the join types
EXPLAIN SELECT * FROM users u JOIN orders o ON u.id = o.user_idFocus on the type, key and rows columns for each table.
2Compare estimated rows against actual rows
EXPLAIN ANALYZE SELECT * FROM users u JOIN orders o ON u.id = o.user_idA gap of orders of magnitude means the statistics need refreshing.
3Confirm the join keys are indexed
grep -n "CREATE INDEX" schema.sqlA missing index is the most common root cause of slow joins.
4Verify tenant filters appear on both sides
grep -n "tenant_id" queries.sqlEvery query touching a multi-tenant table should filter twice.
5Check for accidental NATURAL JOIN usage
grep -n "NATURAL JOIN" queries.sqlIts presence in a production query basically always means rewriting with an explicit ON.
6Look for right-table conditions that landed in WHERE
grep -n "LEFT JOIN" -A 3 queries.sqlA WHERE right after the join that references a right-table column is the signal of a degraded inner join.
Command Examples
Anti-join: find users with no orders at all
SELECT u.name, o.total FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid'
WHERE o.id IS NULLPutting the condition in ON rather than WHERE is what makes this work: an ON clause only decides whether the right row matches, it never filters rows out of the left table. Users with zero orders therefore survive the join, and WHERE o.id IS NULL picks them out. Move o.status into WHERE and every user without a paid order gets mislabelled as having no orders.
Zero-order users: LEFT JOIN plus aggregation plus HAVING
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id
HAVING COUNT(o.id) = 0COUNT(o.id), never COUNT(*): with no matching order every o.id is NULL so COUNT(o.id) is 0, while COUNT(*) still sees the surviving left row and returns 1, so the condition never fires. Whenever you need "how many" rather than "does it exist", count a nullable column from the outer-joined side.
Self-join to rebuild a hierarchy from a flat table
SELECT t1.name AS grandchild, t3.name AS grandparent
FROM tree t1
JOIN tree t2 ON t1.parent_id = t2.id
JOIN tree t3 ON t2.parent_id = t3.idThree aliases of the same tree table joined in sequence walk parent_id up two generations. A NULL grandparent does not mean the query is wrong: the root simply has no parent. If you need to tell roots from broken links, switch to a LEFT JOIN and test t3.name IS NULL.
Compare the execution plan before and after indexing the foreign key
EXPLAIN SELECT * FROM users u JOIN orders o ON u.id = o.user_id
CREATE INDEX idx_orders_user_id ON orders(user_id)
EXPLAIN ANALYZE SELECT * FROM users u JOIN orders o ON u.id = o.user_idA Nested Loop plan with a large row estimate means orders.user_id is not indexed, so the database rescans the whole orders table once per user row. On a large orders table that is a disaster. Re-run EXPLAIN ANALYZE after creating the index and confirm the actual scanned row count dropped before calling it done.
Output
Nested Loop (cost=0.00..45231.44 rows=1500)
Use EXISTS instead of IN for a large subquery
SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)
SELECT * FROM orders o WHERE o.user_id IN (SELECT id FROM users WHERE status = 'active')The semantics differ in an important way: EXISTS and NOT EXISTS short-circuit at the first match, while IN generally has to materialize the whole subquery result. With a users table of hundreds of thousands of rows, the intermediate IN result can exceed the main query; switching to EXISTS usually turns a Materialize into a Semi Join.
Common Pitfalls
- Omitting ON (or writing ON 1=1, or mistyping a.id=b.id as a.id=a.id) yields a cartesian product: two 50k-row tables return 2.5 billion rows, filling the disk and exhausting the connection pool. In production this usually shows up as "the API got slow", not as a SQL error, which makes it very hard to trace. Before running, check that the number of ON predicates equals the number of join keys.
- NULL never compares equal in an inner join: ON a.user_id = o.user_id treats NULL as non-matching, not as matching. Any nullable join column silently drops rows. When a join column can be NULL, wrap it in NULLIF or use IS NOT DISTINCT FROM semantics, and add o.user_id IS NOT NULL to the predicate.
- SELECT DISTINCT removes duplicate rows but does nothing about their source. A LEFT JOIN against a detail table (orders, items) legitimately multiplies rows, and DISTINCT then throws away the amount and quantity along with the duplicates: a user with three 100-total orders collapses to a single row of 100. Aggregate with GROUP BY on the primary key instead of masking it with DISTINCT.
- Moving a right-table filter into the WHERE clause of a LEFT JOIN silently degrades it to an INNER JOIN: the very rows the LEFT JOIN was meant to preserve (users with no orders) are removed by WHERE o.total > 100. To keep every left row, right-table conditions belong in ON.
- SELECT * in a JOIN yields duplicate column names for identically named columns (id, created_at from both sides), and positional or name-based access can silently read the wrong table. Worse, count(o.id) stays correct so the bug survives for years. Select only the columns you need and alias them explicitly.
- Forgetting an index on the join key forces a full table scan, and the rows estimate in EXPLAIN reveals it. Before adding an index for users JOIN orders ON u.id = o.user_id, check whether orders.user_id is already indexed; a redundant index only slows writes.
Tips
- A LEFT JOIN WHERE condition in ON only affects right-table matching; in WHERE it filters the whole result set.
- CROSS JOIN produces a Cartesian product — use cautiously on large data.
- Table aliases (u, o) make multi-table queries more concise and readable.
- EXISTS subqueries can outperform JOIN in some cases, especially when only existence matters.
- Index the JOIN columns, otherwise a full table scan hurts performance.
- USING(a, b) is cleaner than ON x.a = y.a AND x.b = y.b, and merges same-named columns.
FAQ
How do I choose between INNER JOIN and LEFT JOIN?
Decide with one question: do you need to keep left-table rows that have no match on the right? If a record only counts when both sides exist, use INNER — SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id, where JOIN defaults to INNER. If every left row must survive even without orders, use LEFT JOIN and expect NULLs on the right. The classic trap is filter placement: LEFT JOIN ... WHERE o.status = 'paid' drops the orderless users because NULL never compares true, while putting the condition in ON — ON u.id = o.user_id AND o.status = 'paid' — only affects matching and preserves the left row count.
My JOIN returns far more rows than expected — why?
Almost always one-to-many fan-out. Joining users to orders emits one row per order, so a user with three orders becomes three rows; adding a payments join multiplies it again. Remedies: select only the columns you actually need instead of SELECT *; collapse to one row per group with an alias plus aggregation such as SELECT u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id; or skip the JOIN entirely when you only test existence and use WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id), which can even be faster in some cases.
How do I find rows with no match, such as users who never ordered?
Use LEFT JOIN with an IS NULL test: SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id WHERE orders.id IS NULL. Matched rows have a non-null id on the right and get filtered away, leaving exactly the orderless users. HAVING gives the same answer: SELECT u.name, COUNT(o.id) FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id HAVING COUNT(o.id) = 0. Or skip the JOIN entirely with SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id). When you need rows exclusive to either side, reach for FULL OUTER JOIN with WHERE u.id IS NULL OR o.user_id IS NULL to get the symmetric difference.
Between USING, NATURAL JOIN and ON, which should I write for a multi-table query?
ON is the general and clearest form, and it is mandatory when the columns have different names: ON u.id = o.user_id AND u.tenant_id = o.tenant_id. When the join columns share a name, USING is shorter: SELECT * FROM users JOIN orders USING (user_id), and multiple columns work too as USING (user_id, tenant_id) — the result set merges identically named columns into one field, removing the u. versus o. ambiguity. NATURAL JOIN matches on every shared column name automatically, so a schema change breaks it silently; the sheet flags it as risky and recommends reserving it for throwaway queries.
A JOIN query suddenly got slow — what do I check first?
Start with the plan: EXPLAIN SELECT * FROM users u JOIN orders o ON u.id = o.user_id shows the join method, and EXPLAIN ANALYZE (PostgreSQL / MySQL 8.0+) actually runs it and reports timings. The usual culprit is a missing index on the join column — add one with CREATE INDEX idx_orders_user_id ON orders(user_id) to avoid full scans. Next, replace SELECT * with just the columns you need to reduce table lookups. When MySQL picks the wrong driving table, force the order with STRAIGHT_JOIN or pin the index with FORCE INDEX(idx_user_id). Remember that filtering after aggregation (COUNT, SUM, AVG) belongs in HAVING, not WHERE.
Official References
Each command links to its official documentation below, so you can verify the latest usage and read deeper.
Maintained by LaoHand
Publicly updated on Jul 21, 2026, continuously proofread against official docs.
Contact Us
Wrong command or description? Send us corrections, business inquiries or product feedback by email.
Contact Us