Key Takeaways

  • INNER JOIN keeps only matching rows; LEFT, RIGHT, and FULL JOIN keep unmatched rows and fill the gap with NULL.
  • SQL is the third most-used language among all developers at 58.6%, behind only JavaScript and HTML/CSS (Stack Overflow Developer Survey 2025).
  • MySQL has no native FULL OUTER JOIN — you simulate it with a LEFT JOIN UNION RIGHT JOIN.
  • Unindexed join columns force a nested loop scan; indexing both sides lets the planner switch to a hash or merge join instead.
Overlapping translucent panes of glass representing how a SQL join overlays rows from two tables to produce a combined result set
A JOIN overlays two row sets on a shared key — how much of each side survives depends on the join type. (Photo: Pexels)

What Is a SQL JOIN?

A SQL JOIN combines rows from two or more tables using a related column, typically a primary key on one side and a foreign key on the other. In 2025, SQL ranked as the third most-used programming language overall at 58.6% of respondents, trailing only JavaScript and HTML/CSS (Stack Overflow Developer Survey 2025). Joins are the mechanism that makes normalized schema design practical — you split data across tables to avoid duplication, then join them back together at query time.

Without joins, every query against a normalized schema would require multiple round trips and manual merging in application code. That's slower and more error-prone than letting the database engine do the work in one pass. Why does that matter for schema design? Because a schema with too many tables and no clear join paths becomes painful to query, no matter how "correct" the normalization is on paper.

The five core join types differ only in which unmatched rows they keep. INNER JOIN drops them. LEFT, RIGHT, and FULL JOIN keep them from one or both sides and pad the missing columns with NULL. CROSS JOIN ignores matching entirely and returns every possible row combination.

Most-used programming languages among developers — Stack Overflow Developer Survey 2025
JavaScript HTML/CSS SQL 66.0% 61.9% 58.6%

Source: Stack Overflow Developer Survey 2025

SQL ranks third among all programming languages at 58.6% usage, behind JavaScript (66%) and HTML/CSS (61.9%) (Stack Overflow Developer Survey 2025). Joins are the operation developers reach for most often once they're inside SQL, since almost no production schema fits in a single table.

INNER JOIN

INNER JOIN returns only the rows where the join condition matches on both sides. Any row in either table without a corresponding match is excluded from the result entirely. This is the default join type — plain JOIN without a keyword means INNER JOIN in every major SQL dialect.

SELECT orders.id, customers.name, orders.total
FROM orders
INNER JOIN customers ON orders.customer_id = customers.id;

This query returns only orders that have a matching customer row. If customer_id is NULL or references a customer that was deleted, that order simply won't appear in the results. That's usually what you want for reporting, but it's a common source of confusion when row counts don't match expectations — the missing rows aren't a bug, they're rows with no match.

INNER JOIN is the SQL standard default: writing JOIN without a modifier is functionally identical to INNER JOIN across MySQL, PostgreSQL, SQL Server, and Oracle. It returns the intersection of two row sets on the join key — rows present in only one table are silently excluded, never padded with NULL.

LEFT and RIGHT JOIN

LEFT JOIN (also written LEFT OUTER JOIN) returns every row from the left table, whether or not it has a match, padding unmatched right-table columns with NULL. This preserves "orphan" rows that an INNER JOIN would silently drop — customers with zero orders, products never purchased, users who never logged in.

SELECT customers.name, orders.id AS order_id
FROM customers
LEFT JOIN orders ON customers.id = orders.customer_id;

Every customer appears at least once, even those with no orders — order_id is NULL for them. To find customers who have never ordered, add WHERE orders.id IS NULL after the join. That pattern, called an anti-join, is one of the most practical uses of LEFT JOIN.

RIGHT JOIN is the mirror image: it keeps every row from the right table instead. In practice, most developers use LEFT JOIN exclusively and reorder the tables rather than switch to RIGHT JOIN, since it reads more naturally left-to-right and keeps queries consistent across a codebase.

Rows of connected data points on a dark background illustrating how a LEFT JOIN preserves unmatched rows from the primary table
LEFT JOIN keeps every row on the preserved side, filling gaps with NULL instead of dropping the row.
LEFT JOIN preserves every row from the left table regardless of a match, filling unmatched right-side columns with NULL — the standard pattern for finding rows with no related record via WHERE right_table.id IS NULL. RIGHT JOIN does the identical operation with the table roles reversed, but most style guides prefer rewriting a RIGHT JOIN as a LEFT JOIN with swapped table order for readability.

FULL OUTER JOIN

FULL OUTER JOIN returns every row from both tables, matching where possible and padding with NULL on whichever side lacks a match. It's the union of what LEFT JOIN and RIGHT JOIN would each return separately. PostgreSQL, SQL Server, and Oracle all support it natively with the FULL JOIN keyword.

-- PostgreSQL, SQL Server, Oracle
SELECT customers.name, orders.id AS order_id
FROM customers
FULL OUTER JOIN orders ON customers.id = orders.customer_id;

MySQL has no native FULL OUTER JOIN. The standard workaround unions a LEFT JOIN with a RIGHT JOIN (or a second LEFT JOIN with the tables swapped), deduplicating the overlap:

-- MySQL workaround
SELECT customers.name, orders.id AS order_id
FROM customers LEFT JOIN orders ON customers.id = orders.customer_id
UNION
SELECT customers.name, orders.id AS order_id
FROM customers RIGHT JOIN orders ON customers.id = orders.customer_id;

UNION (not UNION ALL) removes the duplicate rows that both halves of the query would otherwise produce for every matched row. This behavior — and other core syntax differences between dialects — is covered in more depth in the MySQL vs PostgreSQL comparison.

MySQL is the only major relational database among the top five most-used engines that lacks a native FULL OUTER JOIN keyword — PostgreSQL, SQL Server, and Oracle all support it directly. The standard MySQL workaround unions a LEFT JOIN and a RIGHT JOIN against the same pair of tables, using UNION rather than UNION ALL to drop the duplicated matched rows.

CROSS JOIN and SELF JOIN

CROSS JOIN returns the Cartesian product of two tables — every row from the first table paired with every row from the second, with no matching condition at all. A 100-row table crossed with a 50-row table produces 5,000 result rows. It's intentional for generating combinations (sizes × colors for a product catalog) and accidental almost everywhere else.

SELECT sizes.label, colors.name
FROM sizes
CROSS JOIN colors;

SELF JOIN isn't a separate keyword — it's any join type applied to a table joined with itself, using table aliases to distinguish the two roles. It's the standard pattern for hierarchical data, like an employees table where each row has a manager_id pointing to another row in the same table.

SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;

The most common accidental CROSS JOIN in production code isn't written on purpose — it happens when a developer lists two tables in a WHERE-style old syntax (FROM a, b) and forgets the filtering condition entirely. Modern SQL style guides ban comma-joins for exactly this reason: an explicit JOIN ... ON makes a missing condition a syntax error instead of a silent Cartesian product.

How the Database Executes a Join

PostgreSQL chooses between three join algorithms at query planning time — nested loop, hash join, and merge join — and the choice depends heavily on whether the join columns are indexed (CYBERTEC PostgreSQL, Join Strategies and Performance). Nested loop scans one table row by row, checking the other table for each match — fine for small tables, expensive as row counts grow.

Hash join builds an in-memory hash table from the smaller table, then streams the larger table through it, which is efficient as long as the hash table fits in the configured working memory. Merge join sorts both inputs on the join key first, then walks them in parallel — fast when the data is already sorted via an existing index, expensive when PostgreSQL has to sort from scratch.

You can see which algorithm the planner picked with EXPLAIN ANALYZE, which executes the query and reports both the estimated and actual cost of each plan node (PostgreSQL Documentation, Using EXPLAIN):

EXPLAIN ANALYZE
SELECT orders.id, customers.name
FROM orders
JOIN customers ON orders.customer_id = customers.id;

The single highest-leverage fix for a slow join, in practice, is almost always a missing index on the foreign key column — not a query rewrite. Once both sides of a join column are indexed, the planner has the option to switch away from nested loop, and query time on multi-million-row tables routinely drops from seconds to milliseconds.

PostgreSQL selects between nested loop, hash, and merge join at plan time based on table size, available memory, and existing sort order (CYBERTEC PostgreSQL). Indexing the join columns on both sides is the most reliable way to give the planner cheaper options than a full nested loop scan, and EXPLAIN ANALYZE is the standard tool for confirming which algorithm actually ran (PostgreSQL Documentation).

Common Join Mistakes

  • Forgetting the ON clause. Omitting the join condition turns any join into an accidental CROSS JOIN, producing a row count that looks like an explosion rather than a bug.
  • Confusing WHERE and ON with outer joins. Filtering a LEFT JOIN's right-table column in WHERE instead of ON silently converts it back into an INNER JOIN, since NULL rows fail most WHERE conditions.
  • Not indexing the join column. A missing index on either side of the join condition forces a nested loop scan, which scales badly once tables pass a few thousand rows. See the foreign key guide for how MySQL indexes FK columns automatically.
  • Unexpected duplicate rows. Joining against a table where the join column isn't unique multiplies matching rows — always confirm cardinality (one-to-one, one-to-many, many-to-many) before joining, ideally at schema design time.
  • Chaining too many joins without checking the plan. Five or more joined tables in one query can produce a plan the optimizer struggles to estimate accurately. Break the query apart or add targeted indexes once EXPLAIN ANALYZE shows a mismatch between estimated and actual rows.

Visualise Joins Before Writing SQL

Join bugs are cardinality bugs in disguise — a query that "should" return one row per customer but returns three is almost always a many-to-many relationship nobody modeled explicitly. Drawing the schema first makes that relationship visible before it becomes a production incident.

The crow's foot notation guide explains how to read the cardinality symbols that predict exactly how many rows a join will return. Once the relationships are mapped, you can design your schema visually and export the correct CREATE TABLE and foreign key DDL directly, so every join you write later has a clear, indexed path to follow.

For a broader library of schema patterns with the joins already worked out, see the database schema examples post, and the normalization guide for when splitting tables (and therefore requiring more joins) actually pays off.

Frequently Asked Questions

What is the difference between INNER JOIN and LEFT JOIN?

INNER JOIN returns only rows that have a match in both tables — non-matching rows are dropped entirely. LEFT JOIN returns every row from the left table regardless of a match, filling unmatched right-table columns with NULL. Use LEFT JOIN when you need to keep "orphan" rows, such as customers with zero orders.

How many types of SQL joins are there?

The core types are INNER, LEFT (OUTER), RIGHT (OUTER), FULL (OUTER), and CROSS JOIN. SELF JOIN is not a distinct keyword — it's any of the above join types applied to a table joined with itself, typically used for hierarchical or comparison queries.

Does MySQL support FULL OUTER JOIN?

No. MySQL has no native FULL OUTER JOIN keyword, unlike PostgreSQL, Oracle, and SQL Server. The standard workaround is a LEFT JOIN UNION a RIGHT JOIN (or UNION ALL with a WHERE NULL filter on the second half) to simulate the same result set.

Why does a JOIN return duplicate rows?

Duplicate rows almost always mean the join condition matches more than one row on the other side — a one-to-many or many-to-many relationship that wasn't accounted for. Check for a missing filter, a non-unique join column, or an accidental CROSS JOIN caused by omitting the ON clause.

Are JOINs slow without indexes?

Yes. Without an index on the join column, the database engine often falls back to a nested loop scan that checks every row pair, which scales poorly past a few thousand rows. Indexing both sides of a join column typically lets the planner switch to a hash or merge join, cutting execution time by orders of magnitude on large tables.