JOIN — Learn Table Joins and Row-Count Changes from the Basics
BasicJOINTable joinsWeb developmentPostgreSQL-ready5 questions
QUESTION 1
INNER JOIN — Join Two Tables on a Common Key and Keep Only Rows Present in Both
INNER JOINON clauseBasicsIntersection
Background

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;
Table aliases (AS o / AS u): a short alias like 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.
Problem

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.

Tables used
▸ orders
order_iduser_idamount
113000
225000
341200
▸ users
user_idname
1Tanaka
2Sato
3Yamada
Expected Output
order_idnameamount
1Tanaka3000
2Sato5000
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT o.order_id, u.name, o.amount FROM orders AS o INNER JOIN users AS u ON o.user_id = u.user_id;
LEGEND
Rows read / loaded
① 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.
1 / 3
order_iduser_idamount
113000
225000
341200
All 3 rows read
LEARNING POINTS
JOIN PATTERN
INNER JOIN — the intersection
returns only rows whose key exists in both tables
o = orders   u = users
INNER JOIN is fundamentally an "intersection": only rows whose ON-clause key exists in both tables reach the result. Here, orders.user_id = 4 is absent from users, and users.user_id = 3 (Yamada) is absent from orders, so both are excluded. INNER JOIN is the right choice when your intent is "use only the rows where the data is complete on both sides".
Table aliases and dot notation: with 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.
ANTI-PATTERNS
Forgetting the ON clause turns it into a CROSS JOIN (Cartesian product): writing 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.
Don't use the old join syntax (joining in WHERE): 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.
Field Notes: Normalization and JOIN
Following the principles of normalization, a relational database stores user data, order data and product data in separate tables. JOIN is the mechanism that reassembles that split data at read time. The vast majority of real-world SQL contains a JOIN of some kind, and the skill of reading and writing JOINs is essential in both backend engineering and data analysis.
QUESTION 2
LEFT JOIN — Keep Every Row of the Left Table and Fill Unmatched Right-Side Columns with NULL
LEFT JOINNULL paddingMaster-table joinOuter join
Background

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
Choosing INNER JOIN vs LEFT JOIN: "I want only the users who placed an order" → INNER JOIN. "I want all users, showing NULL when there is no order" → LEFT JOIN. When joining a detail table (orders) onto a master table (users), LEFT JOIN is usually preferred.
Problem

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.

Tables used
▸ users
user_idname
1Tanaka
2Sato
3Yamada
▸ orders
order_iduser_idamount
113000
211800
325000
Expected Output
user_idnameorder_idamount
1Tanaka13000
1Tanaka21800
2Sato35000
3YamadaNULLNULL
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT u.user_id, u.name, o.order_id, o.amount FROM users AS u LEFT JOIN orders AS o ON u.user_id = o.user_id;
LEGEND
Rows read / loaded
① 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.
1 / 3
user_idname
1Tanaka
2Sato
3Yamada
All 3 rows read (every row is kept)
LEARNING POINTS
JOIN PATTERN
LEFT JOIN — left outer join
keeps all left rows; the right side is NULL-padded
u = users (left)   o = orders (right)
LEFT JOIN "keeps the left table": every row of the table in FROM (the left table) always appears in the result. When the right table (the JOIN side) has no matching row, all of its columns are padded with NULL. Tanaka (user_id = 1) has 2 orders and expands into 2 rows; Yamada (user_id = 3) stays as a single NULL-padded row.
NULL means the data "does not exist": 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).
ANTI-PATTERNS
Filtering right-table columns in WHERE after a LEFT JOIN turns it into an INNER JOIN: adding a condition on a right-table column, as in 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.
Field Notes: Where LEFT JOIN Shines
"Show the latest order date on the member list", "show how many units of each product sold this month, with 0 for products that did not sell" — LEFT JOIN is the textbook solution for such requirements. RIGHT JOIN (right outer join) also exists, but swapping the order of FROM and JOIN rewrites it as a LEFT JOIN, so most projects standardize on LEFT JOIN everywhere for readability.
QUESTION 3
LEFT JOIN + IS NULL — Extract Users Who Never Ordered as a "Set Difference"
LEFT JOINIS NULLSet differenceAnti-join
Background

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
Difference from NOT IN (important): 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.
Problem

From the users table, retrieve the users (user_id and name) who have no orders at all in the orders table.

Tables used
▸ users
user_idname
1Tanaka
2Sato
3Yamada
4Suzuki
▸ orders
order_iduser_id
11
22
31
Expected Output
user_idname
3Yamada
4Suzuki
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT u.user_id, u.name FROM users AS u LEFT JOIN orders AS o ON u.user_id = o.user_id WHERE o.order_id IS NULL;
LEGEND
Rows read / loaded
① FROM
FROM users AS uRead the users table (4 rows) as the left table.
1 / 4
user_idname
1Tanaka
2Sato
3Yamada
4Suzuki
All 4 rows read
LEARNING POINTS
JOIN PATTERN
LEFT JOIN + IS NULL — set difference (anti-join)
returns left-only rows (those absent from the right table)
u = users (left)   o = orders (right)
The "anti-join" pattern — LEFT JOIN + IS NULL: filtering the LEFT JOIN result down to the rows where the right table's columns are NULL retrieves records that exist in the left table but not the right (the set difference). It is used constantly for negative conditions — "users who never purchased", "questions left unanswered", "invoices left unpaid".
Difference from NOT IN: 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.
ANTI-PATTERNS
Testing IS NULL on a non-key column: checking a column other than the join key, as in 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.
QUESTION 4
Three-Table JOIN — Chain order_items / orders / users to Retrieve Order Line Items
INNER JOINMulti-step joinsVirtual tableE-commerce staple
Background

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 ①
Alias naming convention: using the table's initials (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.
Problem

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_itemsordersusers.

Tables used
▸ order_items
item_idorder_idproduct_nameqty
11Laptop2
21Mouse1
32Laptop3
▸ orders
order_iduser_id
11
22
▸ users
user_idname
1Tanaka
2Sato
Expected Output
user_nameproduct_nameqty
TanakaLaptop2
TanakaMouse1
SatoLaptop3
Model Answer
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
*/
Explanation (table transitions & key points)
SELECT u.name AS user_name, oi.product_name, oi.qty FROM order_items AS oi INNER JOIN orders AS o ON oi.order_id = o.order_id INNER JOIN users AS u ON o.user_id = u.user_id;
LEGEND
Rows read / loaded
① 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.
1 / 4
item_idorder_idproduct_nameqty
11Laptop2
21Mouse1
32Laptop3
All 3 rows read
LEARNING POINTS
JOIN PATTERN
Three-table chained INNER JOIN
chain JOINs to combine three tables (fully matched rows only)
oi = order_items   o = orders   u = users
Chaining JOINs — joining onto a virtual table: the second 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.
Alias naming rules: using the table's initials (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.
ANTI-PATTERNS
A forgotten ON clause explodes the data: omit one ON clause in a three-table join and those two tables become a CROSS JOIN (Cartesian product), multiplying the row count. Here it would give 3 × 2 × 2 = 12 rows. Useful habits: check the plan with EXPLAIN, and debug by adding the JOINs one at a time while verifying the row count with SELECT COUNT(*).
Field Notes: Multi-Step Joins and API Design
In an e-commerce backend, the API that "displays the customer name, product name and amount on the order list screen" invariably contains a join across several tables. A query joining four tables — order_items → orders → users → products — is a staple of real-world work. Any column of any joined table is reachable via alias.column, and the final SELECT clause chooses only the columns to output.
QUESTION 5
JOIN + GROUP BY — Build a Virtual Table with JOIN, Then Aggregate Order Count and Total per User
INNER JOINGROUP BYCOUNT / SUMAggregation API staple
Background

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
List every non-aggregated SELECT column in GROUP BY: when the SELECT clause contains an aggregate function (COUNT/SUM), every column that is not inside an aggregate must be listed in GROUP BY. Forget one and PostgreSQL — or MySQL in strict mode — raises an error.
Problem

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).

Tables used
▸ users
user_idname
1Tanaka
2Sato
3Yamada
▸ orders
order_iduser_idamount
113000
211500
325000
432000
53800
Expected Output
nameorder_counttotal_amount
Sato15000
Tanaka24500
Yamada22800
Model Answer
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
*/
Explanation (table transitions & key points)
SELECT u.name, COUNT(o.order_id) AS order_count, SUM(o.amount) AS total_amount FROM users AS u INNER JOIN orders AS o ON u.user_id = o.user_id GROUP BY u.user_id, u.name ORDER BY total_amount DESC;
LEGEND
Rows read / loaded
① FROM
FROM users AS uRead the users table (3 rows) as the left table.
1 / 4
user_idname
1Tanaka
2Sato
3Yamada
All 3 rows read
LEARNING POINTS
JOIN PATTERN
INNER JOIN → GROUP BY — intersection + aggregation
group-aggregate the virtual table produced by the JOIN
u = users (left)   o = orders (right)
The JOIN → GROUP BY execution order: SQL is evaluated as 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).
List every non-aggregated SELECT column in GROUP BY: in 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.
ANTI-PATTERNS
INNER JOIN makes users with zero orders disappear: because this question uses INNER JOIN, users with no rows in orders are excluded from the result. If you want "users with zero orders listed with a count of 0", use LEFT JOIN together with COUNT(o.order_id) (which does not count NULLs) — those users are then output with order_count = 0.
Field Notes: Aggregation APIs and JOIN + GROUP BY
Dashboard and report APIs that show "top 10 customers by purchase amount" or "sales aggregated per department" are almost always a combination of JOIN + GROUP BY + ORDER BY LIMIT. Master this pattern and a frontend request like "return the per-user aggregates as JSON" translates straight into SQL. Q6 through Q10 cover more advanced practical patterns: JOIN combined with CASE expressions, the HAVING clause, and window functions.