SQL Joins: The Complete Guide to INNER, LEFT, RIGHT, FULL, CROSS, Self and Anti Joins
Joins are the single most important SQL skill — every multi-table query depends on them. This guide covers all six join types, when to use each, the performance trade-offs, and the dialect-specific quirks you will hit in real PostgreSQL, MySQL, SQL Server, Oracle and SQLite code. Every example is runnable in our free in-browser editor.
Last updated · SQL Practice Online team
What is a SQL join?
A SQL JOIN combines rows from two or more tables based on a matching column between them. Most non-trivial business questions — "which customers ordered last quarter", "which employees report to which managers", "which patients have unfilled prescriptions" — require pulling data from at least two tables, and JOIN is how SQL expresses that combination.
The standard SQL specification (ANSI SQL-92) defines six join behaviours: INNER, LEFT OUTER, RIGHT OUTER, FULL OUTER, CROSS, and the implicit comma-join. Most modern databases support all of them; a handful (most notably MySQL up to version 8 and SQLite) do not implement FULL OUTER JOIN natively, which we cover below.
The six join types at a glance
| Join type | Matched rows | Unmatched A rows | Unmatched B rows | Typical use |
|---|---|---|---|---|
| INNER JOIN | kept | dropped | dropped | Strict intersection — when both sides must exist. |
| LEFT JOIN | kept | kept (B = NULL) | dropped | Preserve all A rows; B is optional. |
| RIGHT JOIN | kept | dropped | kept (A = NULL) | Mirror of LEFT — rare in modern code. |
| FULL OUTER JOIN | kept | kept (B = NULL) | kept (A = NULL) | Symmetric union — reconcile two sources. |
| CROSS JOIN | cartesian | n/a | n/a | Generate combinations — calendars, test grids. |
| SELF JOIN | kept | depends on type | depends on type | Hierarchies, comparisons within a single table. |
A "self join" is not a separate operator — it is any join (INNER, LEFT, etc.) where a table is joined to itself, usually with a different alias. Anti-join and semi-join, similarly, are patterns expressed with NOT EXISTS / EXISTS, NOT IN, or LEFT JOIN ... WHERE B.key IS NULL — they are not SQL keywords.
INNER JOIN
INNER JOIN keeps only rows where the ON predicate matches in both tables. It is the default and most common join — when in doubt, this is the one to reach for.
SELECT e.first_name, e.last_name, j.job_title
FROM employees e
INNER JOIN jobs j
ON j.job_id = e.job_id
ORDER BY e.last_name;Notes: the INNER keyword is optional in every major database — JOIN alone means INNER JOIN. Spelling it out is a readability convention, not a performance choice.
LEFT JOIN (LEFT OUTER JOIN)
LEFT JOIN keeps every row from the left table, attaching matching right-table rows where they exist and NULLs where they do not. It is the right tool whenever the left side is the canonical entity list and you want to enrich it with optional related data.
SELECT d.department_name, e.first_name, e.last_name
FROM departments d
LEFT JOIN employees e
ON e.department_id = d.department_id
ORDER BY d.department_name;You can chain LEFT JOINs but be aware of the order: LEFT JOIN is not associative once a later join introduces a NULLable side. If you need the same left-preservation across all joins, keep them all LEFT and never mix INNER in the middle.
Counting "left rows with no match"
SELECT d.department_name
FROM departments d
LEFT JOIN employees e
ON e.department_id = d.department_id
WHERE e.employee_id IS NULL;RIGHT JOIN (RIGHT OUTER JOIN)
RIGHT JOIN is the mirror of LEFT JOIN: it preserves every row from the right table. It exists for completeness but is uncommon in modern code because rewriting any RIGHT JOIN as a LEFT JOIN with the tables swapped reads more naturally.
-- Discouraged
SELECT * FROM employees e RIGHT JOIN departments d ON d.department_id = e.department_id;
-- Equivalent and clearer
SELECT * FROM departments d LEFT JOIN employees e ON e.department_id = d.department_id;FULL OUTER JOIN
FULL OUTER JOIN keeps every row from both tables — matched pairs together, unmatched left rows with NULL on the right, and unmatched right rows with NULL on the left. It is the natural choice when reconciling two sources of truth (e.g. CRM customers vs. billing customers).
SELECT COALESCE(c.customer_id, b.customer_id) AS customer_id,
c.email AS crm_email,
b.email AS billing_email
FROM crm_customers c
FULL OUTER JOIN billing_customers b
ON b.customer_id = c.customer_id
WHERE c.customer_id IS NULL OR b.customer_id IS NULL;SELECT a.id AS a_id, b.id AS b_id FROM a LEFT JOIN b ON b.id = a.id
UNION
SELECT a.id AS a_id, b.id AS b_id FROM a RIGHT JOIN b ON b.id = a.id;CROSS JOIN
CROSS JOIN produces the unfiltered cartesian product — every row of A paired with every row of B. It has no ON clause. Useful for generating dense calendar grids, parameter sweeps for tests, or all-pairs comparisons. Use with care: a CROSS JOIN of two 1,000-row tables yields 1,000,000 rows.
SELECT r.region_name, m.month_name
FROM regions r
CROSS JOIN months m
ORDER BY r.region_name, m.month_num;Self join
A self join is a join where a table appears on both sides under different aliases. The two canonical use cases are hierarchies (employee → manager) and within-table comparisons (this row vs. the previous row, when window functions are not available or are clumsy).
SELECT e.first_name || ' ' || e.last_name AS employee,
m.first_name || ' ' || m.last_name AS manager
FROM employees e
LEFT JOIN employees m
ON m.employee_id = e.manager_id
ORDER BY manager NULLS FIRST, employee;Use LEFT JOIN (not INNER) for the hierarchy pattern — otherwise the top of the tree (the row whose manager_id is NULL) is dropped. Recursive CTEs are the right tool when you need the full ancestor chain in a single query.
Anti joins and semi joins
Anti and semi joins are not SQL keywords — they are patterns expressed with EXISTS, NOT EXISTS, NOT IN, or LEFT JOIN ... IS NULL. A semi join keeps rows from A that have at least one match in B (without duplicating A). An anti join keeps rows from A that have no match in B.
-- Preferred (NOT EXISTS — handles NULLs correctly).
SELECT c.customer_id, c.email
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
-- Equivalent in most engines (LEFT JOIN ... IS NULL).
SELECT c.customer_id, c.email
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;Choosing the right join
| Intent | Use |
|---|---|
| Both sides must have a match (e.g. orders → customers, never orphan). | INNER JOIN |
| Keep all A rows, attach B if present (e.g. all departments + their employees). | LEFT JOIN |
| Reconcile two equally-authoritative sources (CRM vs. billing). | FULL OUTER JOIN |
| Generate every combination (calendar × region). | CROSS JOIN |
| Hierarchy or within-table comparison. | Self join (LEFT or INNER) |
| "A rows with no B" — anti pattern. | NOT EXISTS or LEFT JOIN ... IS NULL |
| "A rows with at least one B" — semi pattern. | EXISTS |
Performance considerations
The choice of INNER vs. OUTER rarely changes runtime by itself — modern planners are good at both. Three other factors matter much more:
- Indexes on the join columns. A join on an unindexed column on a million-row table forces a hash or merge with full-table scan; the same join with a B-tree index typically runs in milliseconds.
- Selectivity. Apply the most-restrictive WHERE filter first (the planner often does this automatically, but only if statistics are fresh — run ANALYZE / UPDATE STATISTICS regularly).
- Data types. Joining a VARCHAR to an INTEGER triggers an implicit cast that disables index use. Match the types and (in MySQL) collations on both sides.
Dialect differences
| Feature | PostgreSQL | MySQL 8+ | SQL Server | Oracle 19c+ | SQLite |
|---|---|---|---|---|---|
| INNER / LEFT / RIGHT / CROSS | ✅ | ✅ | ✅ | ✅ | ✅ |
| FULL OUTER JOIN | ✅ | ❌ (emulate with UNION) | ✅ | ✅ | ❌ (emulate with UNION) |
| LATERAL / CROSS APPLY | ✅ LATERAL | ✅ LATERAL (8.0.14+) | ✅ CROSS/OUTER APPLY | ✅ LATERAL | ❌ |
| NATURAL JOIN | ✅ | ✅ | ❌ | ✅ | ✅ |
| USING (col) | ✅ | ✅ | ❌ | ✅ | ✅ |
| Hash join | ✅ | ✅ (8+) | ✅ | ✅ | ⚠️ limited |
Practical advice: prefer ON over USING and avoid NATURAL JOIN. Both USING and NATURAL JOIN match on column name, which means a future schema change (renaming a column, adding a column with the same name) silently changes query semantics. ON is explicit and survives refactors.
Common mistakes
- Filtering the OUTER side in WHERE — turns LEFT JOIN into INNER JOIN. Move the predicate into ON.
- Joining on an implicit type cast — disables the index. ALWAYS match types.
- Forgetting DISTINCT or aggregation when the right side is one-to-many — a single A row gets duplicated by every B match.
- NOT IN with NULLable sub-query — silently returns zero rows. Use NOT EXISTS.
- Implicit comma cross-joins from missing the join keyword. Always write JOIN explicitly.
- Self-joining without LEFT for hierarchy traversal — drops the root.
- Using SELECT * across multiple tables — column ambiguity, fragile downstream code, and unpredictable plan changes when columns are added.
Practice this in the editor
Frequently asked questions
What is the difference between INNER JOIN and LEFT JOIN?
INNER JOIN returns only rows where the ON predicate matches in both tables. LEFT JOIN returns every row from the left table, plus matching right-table rows; rows on the left with no match get NULLs in the right-hand columns. Use INNER when both sides must exist; LEFT when the left side is your canonical entity list and the right is optional.
Is JOIN the same as INNER JOIN?
Yes. In every major SQL dialect (PostgreSQL, MySQL, SQL Server, Oracle, SQLite, BigQuery, Snowflake) the keyword JOIN with no qualifier means INNER JOIN. Spelling INNER explicitly is a readability convention, not a performance or semantic difference.
Why does my LEFT JOIN behave like an INNER JOIN?
You probably filtered the right-side table in WHERE. A predicate like WHERE orders.status = "shipped" eliminates rows where orders is NULL — exactly the rows LEFT JOIN was supposed to preserve. Move the predicate into the ON clause: LEFT JOIN orders o ON o.customer_id = c.customer_id AND o.status = "shipped".
Does MySQL support FULL OUTER JOIN?
No. MySQL through version 8.x does not implement FULL OUTER JOIN. Emulate it with a UNION of a LEFT JOIN and the corresponding LEFT JOIN with the tables swapped (or LEFT JOIN ... UNION ... RIGHT JOIN). Use UNION (which deduplicates) rather than UNION ALL. SQLite has the same limitation.
How many tables can I join in one query?
Most engines accept 60+ tables in a single query, but you should rarely write that. Past about 8 tables, planner statistics become unreliable, plan times spike, and the query becomes hard to reason about. Decompose with views or CTEs and consider whether the schema is over-normalised.
Are joins faster than sub-queries?
Usually identical. Modern optimisers rewrite EXISTS, IN, and correlated sub-queries into semi-joins or hash joins internally. Pick the form that reads more clearly for the intent. The exception is NOT IN with NULLable values — that is semantically broken regardless of performance, and you should always rewrite it as NOT EXISTS.
What is a self join in SQL?
A self join is any join where a table appears on both sides of the JOIN keyword under different aliases. The two canonical uses are hierarchy traversal (employees → manager via manager_id) and within-table comparisons (this row versus a sibling row). Use LEFT JOIN for the hierarchy case to preserve the root.
When should I use CROSS JOIN?
When you genuinely want every combination of rows — e.g. building a dense (region, month) reporting grid, generating a parameter sweep for a regression test, or expanding a row set with a number-table. Avoid it accidentally: a comma between tables without a WHERE predicate is also a cartesian product and is almost always a bug.
Should I use ON or USING?
Prefer ON. USING matches columns by name, so a future rename or accidentally-shared column name silently changes the join semantics. ON is explicit and survives refactors. The same caution applies to NATURAL JOIN, which matches all same-named columns across both tables.
How do I find rows in A that have no match in B?
Use NOT EXISTS or LEFT JOIN ... WHERE B.key IS NULL. Both express the anti-join pattern and most planners optimise them identically. Avoid NOT IN with a sub-query that can return NULL — three-valued logic makes the entire predicate UNKNOWN and the query returns zero rows.