SQL Practice Logo

SQLPractice Online

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 typeMatched rowsUnmatched A rowsUnmatched B rowsTypical use
INNER JOINkeptdroppeddroppedStrict intersection — when both sides must exist.
LEFT JOINkeptkept (B = NULL)droppedPreserve all A rows; B is optional.
RIGHT JOINkeptdroppedkept (A = NULL)Mirror of LEFT — rare in modern code.
FULL OUTER JOINkeptkept (B = NULL)kept (A = NULL)Symmetric union — reconcile two sources.
CROSS JOINcartesiann/an/aGenerate combinations — calendars, test grids.
SELF JOINkeptdepends on typedepends on typeHierarchies, comparisons within a single table.
How each join treats matched and unmatched rows from the left (A) and right (B) tables.

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;
Employees who currently hold a job title (employees with no job_id are dropped).

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;
All departments, including any that have no employees yet (employee_id will be NULL).

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;
Departments with zero employees — classic LEFT JOIN ... IS NULL pattern.

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;
Equivalent — the LEFT JOIN form is preferred for readability.

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;
Reconcile a CRM dump with a billing dump — see who is in one but not the other.
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;
FULL OUTER JOIN emulation for MySQL / SQLite.

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;
Generate one row per (region, month) combination for a sales report.

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;
Each employee with their direct manager (NULL for the CEO).

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;
Anti join — customers who have never placed an order.

Choosing the right join

IntentUse
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
Pick a join by intent, not by habit.

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:

  1. 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.
  2. 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).
  3. 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

FeaturePostgreSQLMySQL 8+SQL ServerOracle 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
Join feature support across the five engines you are most likely to interview on.

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

  1. Filtering the OUTER side in WHERE — turns LEFT JOIN into INNER JOIN. Move the predicate into ON.
  2. Joining on an implicit type cast — disables the index. ALWAYS match types.
  3. Forgetting DISTINCT or aggregation when the right side is one-to-many — a single A row gets duplicated by every B match.
  4. NOT IN with NULLable sub-query — silently returns zero rows. Use NOT EXISTS.
  5. Implicit comma cross-joins from missing the join keyword. Always write JOIN explicitly.
  6. Self-joining without LEFT for hierarchy traversal — drops the root.
  7. 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.