A JOIN combines two tables horizontally based on a common key column (for example user_id). In a normalized database, user data lives in the users table and order data in the orders table, so a JOIN is required to retrieve related data together.
INNER JOIN is the simplest kind: it keeps only the rows whose key exists in both tables, per the ON clause. Rows whose key exists in just one table are dropped (an intersection).
SELECT columns, ... FROM left_table AS alias INNER JOIN right_table AS alias ON left_table.key = right_table.key;
AS o lets you write o.user_id, making it explicit that you mean "user_id of the orders table". When the same column name exists in several tables, alias qualification is mandatory.Join the orders table and the users table on user_id, and retrieve order_id, the user's name (name), and amount (the order amount).
Note: user_id = 4 in orders does not exist in users. And user_id = 3 (Yamada) in users has no orders.
| order_id | user_id | amount |
|---|---|---|
| 1 | 1 | 3000 |
| 2 | 2 | 5000 |
| 3 | 4 | 1200 |
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| order_id | name | amount |
|---|---|---|
| 1 | Tanaka | 3000 |
| 2 | Sato | 5000 |
SELECT o.order_id, u.name, -- the u. prefix states explicitly: the name column of the users table o.amount FROM orders AS o -- start from orders (the left table) INNER JOIN users AS u -- join with users (the right table) ON o.user_id = u.user_id; -- match key: keep only rows whose user_id matches in both tables /* Execution order: 1. FROM orders AS o → the orders table 2. INNER JOIN users AS u → for each orders row, match and join the users rows satisfying ON 3. SELECT o.order_id, ... → project from the joined virtual table */
LEGEND
① FROM
FROM orders AS oRead the orders table (3 rows). The user_id column is the join key. Each of these rows will next be matched against the users table.| order_id | user_id | amount |
|---|---|---|
| 1 | 1 | 3000 |
| 2 | 2 | 5000 |
| 3 | 4 | 1200 |
o = orders u = users
AS o you can write o.user_id. When both tables have a column named user_id, as here, an unqualified SELECT user_id raises an "ambiguous column" error. Make it a habit to always qualify with an alias when joining multiple tables.INNER JOIN users with no ON clause outputs every combination of all left rows × all right rows. Here that would be 3 × 3 = 9 rows — a serious accident. Always double-check the ON clause before running the query.FROM orders, users WHERE orders.user_id = users.user_id is the legacy SQL-89 style. It mixes join conditions and filter conditions in the same WHERE clause and hurts readability; modern SQL (SQL-92 onward) uses the INNER JOIN ... ON syntax as the standard.Unlike INNER JOIN, a LEFT JOIN (left outer join) always keeps every row of the left table (the FROM side). When no row of the right table satisfies the ON condition, all of the right table's columns are output as NULL.
SELECT columns, ... FROM left_table AS u -- every row is always output LEFT JOIN right_table AS o ON u.key = o.key; -- if nothing matches, o.* is NULL
Retrieve every user in the users table together with their order information (order_id, amount). Users with no orders at all should show NULL for order_id and amount.
Note: Yamada (user_id = 3) has no orders, and Tanaka (user_id = 1) has two.
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| order_id | user_id | amount |
|---|---|---|
| 1 | 1 | 3000 |
| 2 | 1 | 1800 |
| 3 | 2 | 5000 |
| user_id | name | order_id | amount |
|---|---|---|---|
| 1 | Tanaka | 1 | 3000 |
| 1 | Tanaka | 2 | 1800 |
| 2 | Sato | 3 | 5000 |
| 3 | Yamada | NULL | NULL |
SELECT u.user_id, u.name, o.order_id, -- Yamada (user_id = 3) matches nothing in orders, so these columns become NULL o.amount FROM users AS u -- keep every user (the left table) LEFT JOIN orders AS o -- join only the matching orders rows; NULL-pad when none ON u.user_id = o.user_id; /* Execution order: 1. FROM users AS u → read the left table 2. LEFT JOIN orders AS o → match on ON (NULL-pad the unmatched) 3. SELECT ... → project the columns and output */
LEGEND
① FROM
FROM users AS uRead the users table (3 rows) as the left table. In a LEFT JOIN, every row of this table is guaranteed to appear in the result.| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
u = users (left) o = orders (right)
NULL. Tanaka (user_id = 1) has 2 orders and expands into 2 rows; Yamada (user_id = 3) stays as a single NULL-padded row.order_id = NULL on Yamada's row does not mean "zero" or "empty string" — it means no corresponding row exists in the orders table. You can test for it with IS NULL / IS NOT NULL, which leads directly to the set-difference pattern in the next question (Q3).LEFT JOIN orders ON ... WHERE o.amount > 0, drops the NULL rows (Yamada), because NULL is FALSE in every comparison. Put conditions on the right table in the ON clause — or, if you really meant an inner join, switch to INNER JOIN.Applying the LEFT JOIN property from Q2 (unmatched right-side rows become NULL), you can retrieve "records that exist in the left table but not in the right" — in other words, a set difference.
FROM left_table AS u LEFT JOIN right_table AS o ON u.key = o.key WHERE o.key IS NULL -- keep only NULL rows = the rows that matched nothing on the right
WHERE u.user_id NOT IN (SELECT user_id FROM orders) gives the same result — but it has a bug: if the subquery's list contains even a single NULL, the whole result becomes empty (any IN comparison with NULL is UNKNOWN). LEFT JOIN + IS NULL is free of that bug and makes better use of join indexes on large tables, so it is the recommended pattern in practice.From the users table, retrieve the users (user_id and name) who have no orders at all in the orders table.
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| 4 | Suzuki |
| order_id | user_id |
|---|---|
| 1 | 1 |
| 2 | 2 |
| 3 | 1 |
| user_id | name |
|---|---|
| 3 | Yamada |
| 4 | Suzuki |
SELECT u.user_id, u.name FROM users AS u LEFT JOIN orders AS o -- first, LEFT JOIN every user against orders ON u.user_id = o.user_id -- users matching no order get NULL in every o.* column WHERE o.order_id IS NULL; -- keep only the NULL rows = users who never ordered /* Execution order: 1. FROM users AS u → read users 2. LEFT JOIN orders AS o → match on ON, NULL-pad the rest 3. WHERE o.order_id IS NULL → only the unmatched rows pass 4. SELECT u.user_id, u.name → project 2 columns */
LEGEND
① FROM
FROM users AS uRead the users table (4 rows) as the left table.| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| 4 | Suzuki |
u = users (left) o = orders (right)
WHERE u.user_id NOT IN (SELECT user_id FROM orders) returns the same rows, but has a bug: if the subquery's list contains even one NULL, the entire result goes empty (every IN comparison against NULL is UNKNOWN, so no row can pass the filter). LEFT JOIN + IS NULL avoids that bug and uses indexes well, which is why it is the pattern recommended in practice.WHERE o.amount IS NULL, also matches "rows that do exist in orders but were stored with amount = NULL". In the set-difference pattern, always test IS NULL on a join-key column (here o.order_id or o.user_id) to stay accurate.To join three or more tables, chain the JOINs. The second JOIN applies to the result of the first (a virtual table).
FROM order_items AS oi INNER JOIN orders AS o ON oi.order_id = o.order_id -- ① first join INNER JOIN users AS u ON o.user_id = u.user_id; -- ② joined onto the virtual table from ①
order_items → oi, orders → o, users → u) as the alias is a widely adopted convention. In long SQL with several JOINs, without aliases you cannot tell at a glance which column comes from which table.From the order line items (order_items) of an e-commerce site, retrieve the ordering user's name (user_name), the product name (product_name) and the quantity (qty).
Join with INNER JOIN in the order order_items → orders → users.
| item_id | order_id | product_name | qty |
|---|---|---|---|
| 1 | 1 | Laptop | 2 |
| 2 | 1 | Mouse | 1 |
| 3 | 2 | Laptop | 3 |
| order_id | user_id |
|---|---|
| 1 | 1 |
| 2 | 2 |
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| user_name | product_name | qty |
|---|---|---|
| Tanaka | Laptop | 2 |
| Tanaka | Mouse | 1 |
| Sato | Laptop | 3 |
SELECT u.name AS user_name, -- the name column of the users table oi.product_name, -- the product name from order_items oi.qty -- the quantity from order_items FROM order_items AS oi -- start from the line items INNER JOIN orders AS o -- ① join line items to orders on order_id ON oi.order_id = o.order_id INNER JOIN users AS u -- ② join users onto the virtual table from ① via user_id ON o.user_id = u.user_id; /* Execution order: 1. FROM order_items AS oi → read the 3 line-item rows 2. INNER JOIN orders AS o → match oi.order_id with o.order_id and join (3 rows kept) intermediate table: order_items + orders merge, adding the user_id column 3. INNER JOIN users AS u → match o.user_id with u.user_id and join (3 rows kept) intermediate table: u.name is added as well (all tables' data now present) 4. SELECT u.name, oi.product_name, oi.qty → project the 3 columns we need */
LEGEND
① FROM
FROM order_items AS oiRead the line-item table (3 rows) as the starting point. order_id is the key for the next join.| item_id | order_id | product_name | qty |
|---|---|---|---|
| 1 | 1 | Laptop | 2 |
| 2 | 1 | Mouse | 1 |
| 3 | 2 | Laptop | 3 |
oi = order_items o = orders u = users
INNER JOIN users runs against the virtual table formed by joining order_items and orders. Whether three tables or four, the build-up is the same: "join the next table onto the previous JOIN result". The final virtual table carries the columns of every table, and the SELECT clause picks out just the ones you need.order_items → oi, orders → o, users → u) as the alias is a widely adopted convention. In long SQL with several JOINs, without aliases it becomes impossible to see which column comes from which table at a glance. SELECT u.name is far clearer than SELECT name during code review.SELECT COUNT(*).alias.column, and the final SELECT clause chooses only the columns to output.Combining JOIN with GROUP BY lets you write aggregate queries that span multiple tables. SQL is evaluated in the order FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. Because the JOIN builds a virtual table before GROUP BY aggregates it, columns from different tables can freely serve as grouping keys or aggregation targets.
FROM users AS u INNER JOIN orders AS o ON u.user_id = o.user_id -- aggregate the joined virtual table with GROUP BY GROUP BY u.user_id, u.name
Join the users and orders tables and aggregate, per user, the number of orders (order_count) and the total amount (total_amount). Sort the output by total amount, largest first (descending).
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| order_id | user_id | amount |
|---|---|---|
| 1 | 1 | 3000 |
| 2 | 1 | 1500 |
| 3 | 2 | 5000 |
| 4 | 3 | 2000 |
| 5 | 3 | 800 |
| name | order_count | total_amount |
|---|---|---|
| Sato | 1 | 5000 |
| Tanaka | 2 | 4500 |
| Yamada | 2 | 2800 |
SELECT u.name, COUNT(o.order_id) AS order_count, -- counts non-NULL values (o.order_id always exists for a real order) SUM(o.amount) AS total_amount -- total of the order amounts FROM users AS u INNER JOIN orders AS o -- users with no orders are dropped here (because it is an INNER JOIN) ON u.user_id = o.user_id GROUP BY u.user_id, u.name -- list every non-aggregated SELECT column (u.name) in GROUP BY ORDER BY total_amount DESC; -- sort by the aggregated column's alias /* Execution order: 1. FROM users AS u → read the 3 users rows 2. INNER JOIN orders AS o → join with orders (a 5-row virtual table) 3. GROUP BY u.user_id → split into 3 groups, one per user 4. COUNT / SUM → aggregate order count and total per group 5. SELECT u.name, ... → project the aggregated columns 6. ORDER BY total_amount DESC → sort by total amount, descending */
LEGEND
① FROM
FROM users AS uRead the users table (3 rows) as the left table.| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
u = users (left) o = orders (right)
FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. Because the JOIN builds a virtual table before GROUP BY aggregates it, columns from different tables can freely be grouping keys or aggregation targets. Here we group by u.name (a users column) while aggregating o.amount (an orders column).SELECT u.name, COUNT(...), u.name is a "non-aggregated column". PostgreSQL and MySQL (strict mode) raise an error unless every non-aggregated SELECT column also appears in GROUP BY. Grouping by u.user_id alone would already determine u.name uniquely here, but writing both explicitly is the safe choice.LEFT JOIN together with COUNT(o.order_id) (which does not count NULLs) — those users are then output with order_count = 0.