SQL query guide

SQL JOINs: The Practical Guide

JOINs are one of the most important SQL concepts because they let you combine related data across multiple tables. The JOIN type controls which rows are included when matches exist, and which rows survive when matches are missing.

Start with the relationship

Use a JOIN when the answer to a question requires data that lives in more than one table. A join key is the column, or columns, used to match rows. A join predicate is the condition after ON.

SELECT columns
FROM table_a a
JOIN table_b b ON a.key = b.key;
Sample datatable_a, table_b · 4 rows
keycolumns
1left row A
2left row B
keycolumns
1right row A
3right row C

Result data

a columnsb columnsrow meaning
values from table_avalues from table_bone related result row

A JOIN does not merge tables permanently. It builds a result set for this query.

Primary keyUniquely identifies a row in its own table.
Foreign keyPoints to a row in another table.
Preserved tableThe table whose unmatched rows still return in an outer join.

The sample data

These examples use Users, Orders, Products, and Employees. The main join keys are Users.id = Orders.user_id and Employees.manager_id = Employees.id.

idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480
  • Ana has two matching orders.
  • Ben and Cara have no orders.
  • Order 103 points to a missing user.
  • One-to-many joins can return more rows than the left table contains.

Relationship patterns

Before choosing a JOIN, identify the cardinality: how many rows on one side can match rows on the other side. Cardinality explains why some joins return one row per entity, while others multiply rows.

One-to-oneOne row matches at most one row in the other table.

Design note: Put the foreign key on the table that extends the main record, then add a unique constraint so one user cannot accidentally get multiple profiles.

SELECT u.id, u.name, p.bio
FROM Users u
JOIN UserProfiles p ON p.user_id = u.id;

Example: Users to UserProfiles. Usually keeps one result row per user when the profile exists.

One-to-zero-or-oneOne row may have no related row or exactly one related row.

Query note: This is common for optional settings, preferences, profile details, and approval records. Use LEFT JOIN when missing optional data should return NULL.

SELECT e.id, e.name, p.pass_number
FROM Employees e
LEFT JOIN ParkingPasses p ON p.employee_id = e.id;

Example: Employees to ParkingPasses. Use LEFT JOIN when the optional row should not remove the main row.

One-to-manyOne row can match many rows in the other table.

Row-count note: This is the most common reason a JOIN returns more rows than expected. Aggregate child rows first when you need one output row per parent.

SELECT u.id, u.name, o.id AS order_id
FROM Users u
JOIN Orders o ON o.user_id = u.id;

Example: Users to Orders. The left row repeats once for every matching child row.

Many-to-oneMany rows point back to one parent row.

Model note: The foreign key usually lives on the many side. Here, many orders can share the same user_id.

SELECT o.id AS order_id, o.total, u.name
FROM Orders o
JOIN Users u ON u.id = o.user_id;

Example: Orders to Users. Each order usually joins to one user. This is the inverse view of one-to-many.

Many-to-manyMany rows on both sides relate through a bridge table.

Bridge note: The bridge table should usually store two foreign keys, and often a composite unique constraint to prevent duplicate relationships.

SELECT s.name AS student_name, c.name AS class_name
FROM Students s
JOIN Enrollments e ON e.student_id = s.id
JOIN Classes c ON c.id = e.class_id;

Example: Students to Classes through Enrollments. Join through the bridge table; do not store comma-separated IDs.

Self-referencingA row relates to another row in the same table.

Alias note: Give each copy of the table a role-based alias, such as e for employee and m for manager.

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

Example: Employees.manager_id to Employees.id. Use a self join with separate aliases for each role.

Composite relationshipThe relationship depends on more than one column.

Correctness note: Missing one column from a composite join can create false matches, duplicate rows, or totals that look valid but are wrong.

SELECT oi.order_id, oi.product_id, p.name
FROM OrderItems oi
JOIN Products p
  ON p.id = oi.product_id
  AND p.vendor_id = oi.vendor_id;

Example: OrderItems(order_id, product_id). Join on every key column required to uniquely identify the relationship.

A foreign key usually points from the many side to the one side. For example, Orders.user_id points to Users.id, so users to orders is one-to-many, while orders to users is many-to-one.

INNER JOIN

UsersOrders

INNER JOIN returns records that exist in both tables. Rows without a matching key are excluded.

Use it when you only want users who have matching orders. With the sample data, Ana appears twice. Ben, Cara, and order 103 are excluded.

SELECT *
FROM Users u
INNER JOIN Orders o ON u.id = o.user_id;
Sample dataUsers, Orders · 6 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480

Result data

user_idnameorder_idtotal
1Ana10150
1Ana10225

Only rows with a match on both sides survive. One user can still produce multiple rows when multiple orders match.

LEFT JOIN

UsersOrders

LEFT JOIN returns every row from the left table and matching rows from the right table. If no match exists, right-side columns return NULL.

Use it when you want all users, even users who have no orders. Ana appears twice, Ben and Cara appear once with NULL order columns, and order 103 is excluded.

SELECT *
FROM Users u
LEFT JOIN Orders o ON u.id = o.user_id;
Sample dataUsers, Orders · 6 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480

Result data

user_idnameorder_idtotal
1Ana10150
1Ana10225
2BenNULLNULL
3CaraNULLNULL

Every user remains. Missing right-side values become NULL.

Common pattern: users with no orders

SELECT u.id, u.name
FROM Users u
LEFT JOIN Orders o ON u.id = o.user_id
WHERE o.id IS NULL;
Sample dataUsers, Orders · 6 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480

Result data

idname
2Ben
3Cara

WHERE o.id IS NULL keeps only the rows where the right table did not match.

RIGHT JOIN

UsersOrders

RIGHT JOIN returns every row from the right table and matching rows from the left table. Missing matches on the left side return NULL.

Use it when you want all orders, even if a matching user row is missing. Many teams prefer rewriting it as a LEFT JOIN by switching table order.

SELECT *
FROM Users u
RIGHT JOIN Orders o ON u.id = o.user_id;
Sample dataUsers, Orders · 6 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480

Result data

user_idnameorder_idtotal
1Ana10150
1Ana10225
NULLNULL10380

Every order remains. Order 103 has no matching user, so user columns become NULL.

Equivalent LEFT JOIN rewrite

SELECT *
FROM Orders o
LEFT JOIN Users u ON u.id = o.user_id;
Sample dataUsers, Orders · 6 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480

Result data

order_idtotaluser_idname
101501Ana
102251Ana
10380NULLNULL

This result preserves orders just like the RIGHT JOIN, but reads left-to-right.

FULL OUTER JOIN

UsersOrders

FULL OUTER JOIN returns all rows from both tables. Matching rows are merged, while non-matching rows contain NULL values for the missing side.

Use it when you need unmatched users, unmatched orders, and matched rows in the same result.

SELECT *
FROM Users u
FULL OUTER JOIN Orders o ON u.id = o.user_id;
Sample dataUsers, Orders · 6 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480

Result data

user_idnameorder_idtotal
1Ana10150
1Ana10225
2BenNULLNULL
3CaraNULLNULL
NULLNULL10380

A full outer join includes matched rows, left-only rows, and right-only rows.

PostgreSQL and SQL Server support FULL OUTER JOIN. MySQL does not support it directly; it is commonly simulated with LEFT JOIN, RIGHT JOIN, and UNION.

SELECT *
FROM Users u
LEFT JOIN Orders o ON u.id = o.user_id

UNION

SELECT *
FROM Users u
RIGHT JOIN Orders o ON u.id = o.user_id;
Sample dataUsers, Orders · 6 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480

Result data

user_idnameorder_idtotal
1Ana10150
1Ana10225
2BenNULLNULL
3CaraNULLNULL
NULLNULL10380

The workaround builds the same conceptual result when a database does not support FULL OUTER JOIN.

CROSS JOIN

CROSS JOIN produces the Cartesian product of two tables. Every row from the first table is paired with every row from the second table.

3 Users × 4 Products=12 Rows
SELECT *
FROM Users
CROSS JOIN Products;
Sample dataUsers, Products · 7 rows
idname
1Ana
2Ben
3Cara
idname
10Basic
11Pro
12Team
13Enterprise

Result data

userproduct
AnaBasic
AnaPro
AnaTeam
AnaEnterprise
BenBasic ...
CaraEnterprise

The full result has 3 × 4 = 12 rows. The table below shows a shortened sample so it stays readable.

Use it intentionally for combinations such as users by products, dates by employees, or sizes by colors.

Result size grows quickly. If one table has 1,000 rows and the other has 1,000 rows, the result has 1,000,000 rows.

SELF JOIN

A self join is useful when rows in a table relate to other rows in the same table: managers and employees, categories and parent categories, comments and replies, or organization structures.

SELECT
  e.name,
  m.name AS manager
FROM Employees e
LEFT JOIN Employees m ON e.manager_id = m.id;
Sample dataEmployees · 4 rows
idnamemanager_id
1RileyNULL
2Morgan1
3Sam1
4Lee2
idname
1Riley
2Morgan
3Sam
4Lee

Result data

employeemanager
RileyNULL
MorganRiley
SamRiley
LeeMorgan

The same table is read twice: once as employees and once as managers.

The table appears twice with different aliases. Here, e means employee and m means manager.

Anti join

An anti join returns rows from one table that do not have a match in another table. SQL does not usually have an ANTI JOIN keyword, so this is commonly written with LEFT JOIN ... WHERE right_table.id IS NULL or NOT EXISTS.

SELECT u.id, u.name
FROM Users u
LEFT JOIN Orders o ON u.id = o.user_id
WHERE o.id IS NULL;
Sample dataUsers, Orders · 6 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480

Result data

idname
2Ben
3Cara

The query keeps users whose joined order columns are NULL.

SELECT u.id, u.name
FROM Users u
WHERE NOT EXISTS (
  SELECT 1
  FROM Orders o
  WHERE o.user_id = u.id
);
Sample dataUsers, Orders · 6 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480

Result data

idname
2Ben
3Cara

NOT EXISTS returns the same missing-match answer without producing joined order columns.

Use it for records missing a relationship, such as users with no orders, products never sold, or employees without assigned equipment.

Semi join

A semi join returns rows from one table when at least one match exists in another table, without duplicating the left-side row for every match. Use EXISTS.

SELECT u.id, u.name
FROM Users u
WHERE EXISTS (
  SELECT 1
  FROM Orders o
  WHERE o.user_id = u.id
);
Sample dataUsers, Orders · 6 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480

Result data

idname
1Ana

Ana appears once, even though two orders match, because EXISTS only asks whether at least one match exists.

Use this when you only need to know whether a match exists, not return columns from the joined table. An INNER JOIN can duplicate users when they have multiple orders. EXISTS keeps each user row once.

Non-equi join

Most examples use equality conditions, but joins can also use ranges or other comparisons.

SELECT
  o.id,
  o.total,
  d.discount_rate
FROM Orders o
JOIN DiscountBands d
  ON o.total BETWEEN d.min_total AND d.max_total;
Sample dataOrders, DiscountBands · 6 rows
iduser_idtotal
101150
102125
103480
min_totalmax_totaldiscount_rate
0490%
509910%
10099915%

Result data

idtotaldiscount_rate
1015010%
102250%
1038010%

The match is based on a range, not equality. Each order total lands inside one discount band.

Use non-equi joins for date windows, pricing tiers, geographic bounds, score bands, and other non-exact matches.

Joining multiple tables

Real queries often join more than two tables. Each join should have a clear reason and a clear predicate. Sketch the path before writing the query.

UsersOrdersOrderItemsProducts
SELECT
  u.name,
  o.id AS order_id,
  p.name AS product_name
FROM Users u
JOIN Orders o ON u.id = o.user_id
JOIN OrderItems oi ON o.id = oi.order_id
JOIN Products p ON oi.product_id = p.id;
Sample dataUsers, Orders, OrderItems, Products · 12 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480
order_idproduct_idquantity
1012011
1012021
1022031
idname
201Keyboard
202Mouse
203Monitor

Result data

nameorder_idproduct_name
Ana101Keyboard
Ana101Mouse
Ana102Monitor

Each join adds another relationship hop. Order 101 appears twice because it has two line items.

Filtering joins: ON vs WHERE

For INNER JOIN, putting a condition in ON or WHERE often produces the same final rows. For outer joins, the placement can change the result.

Keeps all users, only matching paid orders

SELECT *
FROM Users u
LEFT JOIN Orders o
  ON u.id = o.user_id
  AND o.status = 'paid';
Sample dataUsers, Orders · 6 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480

Result data

nameorder_idstatus
Ana101paid
BenNULLNULL
CaraNULLNULL

The left table is still preserved. The paid filter controls which order rows match, not which users survive.

Accidentally removes users with no paid orders

SELECT *
FROM Users u
LEFT JOIN Orders o ON u.id = o.user_id
WHERE o.status = 'paid';
Sample dataUsers, Orders · 6 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480

Result data

nameorder_idstatus
Ana101paid

The WHERE clause removes rows where o.status is NULL, so unmatched users disappear.

Conditions in WHERE run after the join result is formed. A WHERE condition on the right table can make a LEFT JOIN behave like an INNER JOIN.

Many-to-many joins

Many-to-many relationships need a third table that stores the relationship between two entities. A student can enroll in many classes, and a class can have many students.

StudentsEnrollmentsClasses
SELECT
  s.name AS student_name,
  c.name AS class_name
FROM Students s
JOIN Enrollments e ON s.id = e.student_id
JOIN Classes c ON e.class_id = c.id;
Sample dataStudents, Enrollments, Classes · 7 rows
idname
1Maya
2Noah
student_idclass_id
110
111
210
idname
10SQL
11APIs

Result data

student_nameclass_name
MayaSQL
MayaAPIs
NoahSQL

The bridge table stores relationships. The query turns IDs into readable student and class names.

Do not store comma-separated IDs in one column. Use a bridge table so the database can join, index, validate, and query the relationship cleanly.

ON, USING, and NATURAL JOIN

ON is the clearest and most flexible way to write join conditions.

SELECT *
FROM Users u
JOIN Orders o ON u.id = o.user_id;
Sample dataUsers, Orders · 6 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480

Result data

nameorder_idtotal
Ana10150
Ana10225

ON makes the relationship explicit even when the column names differ.

USING can be shorter when both tables have the same join column name.

SELECT *
FROM Orders
JOIN Payments USING (order_id);
Sample dataOrders, Payments · 5 rows
iduser_idtotal
101150
102125
103480
order_idpaid_at
1012026-07-01
1022026-07-02

Result data

order_idtotalpaid_at
101502026-07-01
102252026-07-02

USING (order_id) is shorthand for matching same-named columns and usually outputs one shared order_id column.

NATURAL JOIN automatically joins columns with the same names. Prefer ON for teaching and production examples. Use USING only when it improves clarity. Avoid NATURAL JOIN because schema changes can silently change query behavior.

JOIN vs UNION

JOINs combine columns from related rows.

SELECT u.name, o.total
FROM Users u
JOIN Orders o ON u.id = o.user_id;
Sample dataUsers, Orders · 6 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480

Result data

nametotal
Ana50
Ana25

A JOIN widens the result by adding columns from related rows.

UNION stacks rows from compatible result sets.

SELECT email FROM Customers
UNION
SELECT email FROM Leads;
Sample dataCustomers, Leads · 4 rows
email
ana@example.com
ben@example.com
email
lee@example.com
ana@example.com

Result data

email
ana@example.com
ben@example.com
lee@example.com

UNION stacks rows and removes duplicates. It does not combine related columns like a JOIN.

Use a JOIN when data belongs side by side in the same row. Use UNION when separate queries produce the same kind of rows.

Aggregating after JOINs

Joins often change row counts. Aggregations should account for one-to-many relationships.

SELECT
  u.id,
  u.name,
  COUNT(o.id) AS order_count,
  SUM(o.total) AS lifetime_value
FROM Users u
LEFT JOIN Orders o ON u.id = o.user_id
GROUP BY u.id, u.name;
Sample dataUsers, Orders · 6 rows
idnamenote
1Ana2 orders
2Bennone
3Caranone
iduser_idtotal
101150
102125
103480

Result data

idnameorder_countlifetime_value
1Ana275
2Ben0NULL
3Cara0NULL

COUNT(o.id) ignores NULL order IDs. SUM(o.total) returns NULL for users with no matching totals unless you wrap it with COALESCE.

Counting rows after joining multiple one-to-many tables can inflate totals. Aggregate each relationship separately when needed.

Which JOIN should you use?

NeedUseReason
Only matching recordsINNER JOINReturns rows that match on both sides.
All records from the primary tableLEFT JOINPreserves every row from the left table.
All records from the right tableRIGHT JOINPreserves every row from the right table.
Every record from both tablesFULL OUTER JOINKeeps matched and unmatched rows from both sides.
Every possible pairingCROSS JOINCreates the Cartesian product.
Parent-child relationships in one tableSELF JOINUses aliases to join a table to itself.
Rows with no matchAnti join or NOT EXISTSFinds missing relationships.
Rows that have a match, without duplicatesEXISTSChecks existence without multiplying left rows.
Range or comparison matchingNon-equi joinUses predicates like BETWEEN, <, or >.
Two entities that both relate to many rowsBridge tableModels many-to-many relationships cleanly.

Best practices

  • Join using indexed keys when possible.
  • Select only the columns you need.
  • Avoid SELECT * in production queries.
  • Use clear table aliases.
  • Understand the query execution plan for expensive joins.
  • Filter early when it reduces the dataset before joining.
  • Confirm whether unmatched rows should be included before choosing the JOIN type.
  • Know the relationship cardinality before joining: one-to-one, one-to-many, many-to-one, many-to-many, self-referencing, optional, or composite.
  • Expect duplicate-looking rows when one left row matches multiple right rows.
  • Use EXISTS when checking for existence instead of joining only to filter.
  • Be careful with NULL values in join keys because NULL = NULL is not true in normal SQL comparisons.
  • Avoid NATURAL JOIN in production examples.
  • Use composite join conditions when a relationship depends on more than one column.
  • Check row counts before and after each join while debugging.
  • Add indexes on foreign keys and heavily used join/filter columns.

Common mistakes

  • Missing the ON condition and accidentally creating a Cartesian product.
  • Joining on the wrong column because names look similar.
  • Using INNER JOIN when unmatched rows should still be reported.
  • Putting right-table filters in WHERE after a LEFT JOIN.
  • Assuming JOIN removes duplicates. It does not.
  • Joining many-to-many tables without understanding the bridge table.
  • Using SELECT * and creating unclear duplicate column names like id, created_at, or status.
  • Aggregating after joins without checking whether the join multiplied rows.
  • Confusing JOIN with UNION.

Database compatibility notes

  • INNER JOIN, LEFT JOIN, and CROSS JOIN are broadly supported.
  • RIGHT JOIN is supported by many databases, but some teams avoid it for readability.
  • FULL OUTER JOIN is not available in MySQL as a direct keyword.
  • SQLite supports RIGHT JOIN and FULL OUTER JOIN in modern versions, but older versions did not.
  • Syntax details can vary around quoted identifiers, date functions, execution plans, and optimizer hints.