JOIN — Learn Practical Join Patterns and Row-Count Changes in Practice
AdvancedAdvanced JOINJoin patternsWeb developmentPostgreSQL-ready5 questions
QUESTION 1
HAVING — After GROUP BY, Keep Only Users with at Least Two Orders
INNER JOINGROUP BYHAVINGAggregate filtering
Background

HAVING is the next step after the GROUP BY + aggregate functions learned in Basic Q5. HAVING applies conditions to aggregate results after GROUP BY, so aggregate functions such as COUNT and SUM can be used in its condition.

SELECT column_name, COUNT(...), SUM(...)
FROM users AS u
INNER JOIN orders AS o ON u.user_id = o.user_id
GROUP BY u.user_id, u.name
HAVING COUNT(o.order_id) >= 2;
The fundamental difference between WHERE and HAVING:
WHERE evaluates individual rows after JOIN and before GROUP BY. Aggregate functions cannot be used there.
HAVING evaluates each group's aggregate results after GROUP BY. Aggregate functions can be used there.
SQL execution order: FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
Problem

Join users and orders, and retrieve the name, order count, and total amount for users with at least two orders. Output the results in descending order of total amount.

Tables used
▸ users
user_idname
1Tanaka
2Sato
3Yamada
▸ orders
order_iduser_idamount
113000
211500
325000
432000
53800

※ Sato (1 order) is excluded by HAVING.

Expected Output
nameorder_counttotal_amount
Tanaka24500
Yamada22800
Model Answer
SELECT
  u.name,
  COUNT(o.order_id)  AS order_count,  -- count non-NULL rows
  SUM(o.amount)      AS total_amount  -- add the order amounts
FROM users AS u
INNER JOIN orders AS o
  ON u.user_id = o.user_id
GROUP BY u.user_id, u.name     -- list every non-aggregate column in SELECT
HAVING  COUNT(o.order_id) >= 2  -- filter by the aggregate after GROUP BY
ORDER BY total_amount DESC;

/*
  Execution order:
  1. FROM users AS u             → read users
  2. INNER JOIN orders AS o      → join on ON
  3. GROUP BY u.user_id          → group by user
  4. COUNT / SUM                 → aggregate each group
  5. HAVING COUNT(...)           → keep groups with at least 2 orders
  6. SELECT u.name, ...          → project the columns
  7. ORDER BY total_amount DESC  → sort by total in descending order
  */
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 HAVING COUNT(o.order_id) >= 2 ORDER BY total_amount DESC;
LEGEND
Rows read / loaded
① FROM
FROM users AS uRead the users table (3 rows) as the left table. It will then be joined with orders, aggregated, and filtered by HAVING.
1 / 5
user_idname
1Tanaka
2Sato
3Yamada
All 3 rows read
LEARNING POINTS
JOIN PATTERN
INNER JOIN → GROUP BY → HAVING
Aggregate after JOIN, then filter groups by their aggregate values
u = users (left)  o = orders (right)
WHERE vs HAVING — different application timing: SQL evaluation order is FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. WHERE evaluates individual rows before GROUP BY, so aggregate functions cannot be used there. HAVING evaluates each group's aggregate values after GROUP BY, so functions such as COUNT and SUM can be written directly in its condition. Writing WHERE COUNT(...) >= 2 produces an error.
Combining HAVING conditions with WHERE: A HAVING clause can combine multiple aggregate conditions with AND or OR. For example, “at least 2 orders and a total of at least 5,000” is written as HAVING COUNT(...) >= 2 AND SUM(...) >= 5000. It is also common to filter before aggregation with WHERE and after aggregation with HAVING—a two-stage filtering pattern.
ANTI-PATTERNS
Putting an aggregate function in WHERE: Writing WHERE COUNT(o.order_id) >= 2 produces an error. Always use HAVING when filtering by aggregate values. Conversely, filtering for a specific user may work in HAVING, but putting it in WHERE is more efficient because it filters before GROUP BY.
Using a SELECT alias in HAVING (an error in PostgreSQL): SELECT COUNT(o.order_id) AS cnt ... HAVING cnt >= 2 produces an error in PostgreSQL (MySQL permits it). HAVING is evaluated before SELECT, so the alias does not exist yet.
FIELD NOTE
Conditional aggregation is one of the most frequent patterns in reporting APIs, such as returning a royal-customer list through an API or aggregating monthly active users. If you keep the three stages in mind—filter before aggregation with WHERE → aggregate with GROUP BY → filter after aggregation with HAVING—you can naturally decide where each condition belongs.
QUESTION 2
LEFT JOIN + COALESCE — Include Users with Zero Orders and Return the Full Aggregate
LEFT JOINCOALESCECOUNT(column)NULL conversion
Background

The requirement “include users with zero orders and show 0” is solved by combining LEFT JOIN and COALESCE. This applies the NULL padding from Basic Q2 in a more practical way with aggregate functions and COALESCE.

SELECT
  u.name,
  COUNT(o.order_id)          AS order_count,    -- skip NULLs and return 0
  COALESCE(SUM(o.amount), 0) AS total_amount    -- convert SUM(NULL) → NULL to 0
FROM users AS u
LEFT JOIN orders AS o ON u.user_id = o.user_id
GROUP BY u.user_id, u.name;
The difference between COUNT(column) and COUNT(*):
COUNT(o.order_id) counts while ignoring NULLs, so a user with zero orders automatically gets 0.
COUNT(*) counts rows, so it counts the NULL-padded row created by LEFT JOIN as 1.
After a LEFT JOIN, always use COUNT(a column from the right table).
Problem

For every user in users, retrieve the order count (order_count) and total amount (total_amount). Display users with zero orders as 0. Output the results in descending order of total amount.

Tables used
▸ users
user_idname
1Tanaka
2Sato
3Yamada
▸ orders
order_iduser_idamount
113000
211500
325000

※ Yamada (user_id=3) has no order in orders.

Expected Output
nameorder_counttotal_amount
Sato15000
Tanaka24500
Yamada00
Model Answer
SELECT
  u.name,
  COUNT(o.order_id)          AS order_count,  -- COUNT(column) skips NULL → a zero-order user automatically gets 0
  COALESCE(SUM(o.amount), 0) AS total_amount  -- SUM(NULL) = NULL, so COALESCE converts it to 0
FROM users AS u
LEFT JOIN orders AS o                         -- keep every user (Yamada is NULL-padded)
  ON u.user_id = o.user_id
GROUP BY u.user_id, u.name                    -- group together the NULL-padded row too
ORDER BY total_amount DESC;

/*
  Execution order:
  1. FROM users AS u             → read users
  2. LEFT JOIN orders AS o       → match and NULL-pad
  3. GROUP BY u.user_id, u.name  → group by user
  4. COUNT / COALESCE(SUM,0)     → count and total (NULL corrected to 0)
  5. SELECT / ORDER BY           → output in total_amount DESC order
  */
Explanation (table transitions & key points)
SELECT u.name, COUNT(o.order_id) AS order_count, COALESCE(SUM(o.amount), 0) AS total_amount FROM users AS u LEFT 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. Because this is a LEFT JOIN, every row must remain in the result.
1 / 5
user_idname
1Tanaka
2Sato
3Yamada
All 3 rows read (all users are retained)
LEARNING POINTS
JOIN PATTERN
LEFT JOIN + COALESCE — Complete aggregation (NULL → 0)
Keep every row from the left table and convert zero-order users from NULL to 0
u = users (left)  o = orders (right)
COUNT(column) skips NULLs: COUNT(o.order_id) does not count NULL, so Yamada's NULL-padded group automatically gets 0. In contrast, COUNT(*) counts the existence of the row, so it counts the NULL row as 1. After a LEFT JOIN, the correct pattern is to pass a NOT NULL column from the right table (such as its primary key) to COUNT.
How COALESCE(SUM(o.amount), 0) works: SUM returns NULL when all values being aggregated are NULL. COALESCE(value, default) returns the first non-NULL value, so COALESCE(NULL, 0) = 0. Reversing the order as SUM(COALESCE(o.amount, 0)) has a different meaning: it treats a NULL amount as 0 and includes it in the sum. The order that wraps the entire SUM in COALESCE is therefore important.
ANTI-PATTERNS
Using COUNT(*) makes a zero-order user look like one order: Yamada's LEFT JOIN padding row exists as one row even though all right-table values are NULL. Using COUNT(*) makes Yamada's order_count 1, which is incorrect. With an aggregate after LEFT JOIN, always use COUNT(a column from the right table).
Filtering the right table in WHERE after LEFT JOIN makes it equivalent to INNER JOIN: Writing LEFT JOIN ... WHERE o.amount > 0 causes the NULL row (Yamada) to be excluded because NULL > 0 is FALSE. To apply a condition to the right table while preserving the LEFT JOIN behavior, put the condition in the ON clause.
FIELD NOTE
A dashboard API that lists “purchase count and cumulative amount for every user, with 0 for users who have never purchased” is a standard use case for LEFT JOIN + COUNT(column) + COALESCE(SUM, 0). INNER JOIN would remove users with no purchases, causing frontend bugs where the total user count no longer matches the number of aggregate rows.
QUESTION 3
SELF JOIN — Join One Table Under Two Aliases to Retrieve Employee and Manager Names
INNER JOINSelf-joinHierarchyAdjacency list
Background

A SELF JOIN is an operation that JOINs the same table twice. Giving the two references different aliases lets you represent relationships between rows in one table, such as managers and employees or parent and child categories.

FROM   employees AS e            -- e = employee role
INNER JOIN employees AS m      -- m = manager role (the same table)
  ON e.manager_id = m.emp_id;     -- the employee's manager_id matches the manager's emp_id
Adjacency-list model: By giving a table a self-referencing foreign key such as manager_id, you can represent a hierarchy in one table. SELF JOIN is the standard technique for retrieving parent-child relationships from this structure.
Problem

From the employees table, retrieve each employee's name (employee) and their direct manager's name (manager). Exclude employees without a manager (the president, Tanaka).

Table used (employees — one table only)
▸ employees
emp_idnamemanager_id
1TanakaNULL
2Sato1
3Yamada1
4Suzuki2
5Takahashi2

※ manager_id=NULL (Tanaka, the president) is automatically excluded by INNER JOIN.

Expected Output
employeemanager
SatoTanaka
YamadaTanaka
SuzukiSato
TakahashiSato
Model Answer
SELECT
  e.name AS employee,             -- name from the employee-side alias e
  m.name AS manager               -- name from the manager-side alias m
FROM employees AS e
INNER JOIN employees AS m         -- join the same table as separate roles: e.manager_id = m.emp_id identifies the manager
  ON e.manager_id = m.emp_id      -- manager_id=NULL (Tanaka) does not satisfy the ON condition → excluded
ORDER BY e.manager_id, e.emp_id;

/*
  Execution order:
  1. FROM employees AS e       → read all 5 rows as the employee role
  2. INNER JOIN employees AS m → match each manager_id to an emp_id as the manager
                                 manager_id=NULL (Tanaka) does not satisfy ON and is excluded
  3. SELECT e.name, m.name     → project the employee and manager names
  4. ORDER BY                  → sort by manager_id, then emp_id
*/
Explanation (table transitions & key points)
SELECT e.name AS employee, m.name AS manager FROM employees AS e INNER JOIN employees AS m ON e.manager_id = m.emp_id ORDER BY e.manager_id, e.emp_id;
LEGEND
Rows read / loaded
① FROM
FROM employees AS e (employee role)Read the employees table (5 rows) as the employee role (e). The manager_id column is the SELF JOIN key. manager_id=NULL (Tanaka) identifies the president, who has no manager.
1 / 4
emp_idnamemanager_id
1TanakaNULL
2Sato1
3Yamada1
4Suzuki2
5Takahashi2
All 5 rows read (the same table is referenced in 2 roles)
LEARNING POINTS
JOIN PATTERN
SELF JOIN — Two roles from one table
Join one table under different aliases to retrieve row relationships
e = employee role   m = manager role (both employees)
The essence of SELF JOIN—one table, two roles: Physically there is only one table, but separate aliases make the SQL engine treat the references as two different tables. Joining e (employee)'s manager_id to m (manager)'s emp_id retrieves the row-to-row relationship “who is this employee's manager?” Because this is an INNER JOIN, manager_id=NULL (the president) is automatically excluded.
Adjacency lists and recursive queries: A structure like this employees table, with a foreign key that references its own table, is called an adjacency-list model. SELF JOIN retrieves only one direct level. To follow an arbitrary number of levels such as grandchildren and great-grandchildren, use WITH RECURSIVE, available in PostgreSQL and MySQL 8.0 and later.
ANTI-PATTERNS
Use LEFT JOIN when top-level employees should also be included: This question uses INNER JOIN, so Tanaka with manager_id=NULL is excluded. To display everyone, including the president, with NULL in the manager column for the president, simply change it to LEFT JOIN employees AS m ON e.manager_id = m.emp_id.
Chaining SELF JOINs to retrieve three or more levels: Retrieving a grandparent requires another SELF JOIN, and the chain grows impractical as the hierarchy gets deeper. Use WITH RECURSIVE for a hierarchy tree with variable depth.
FIELD NOTE
Practical uses for SELF JOIN include organization-chart APIs (returning each employee with their direct manager), product categories (Electronics → Laptops → Gaming Laptops parent-child navigation), and forum threads (parent-child relationships through reply_to_comment_id). For three or more levels, develop the query into a WITH RECURSIVE query; for one direct level, SELF JOIN is the simplest solution.
QUESTION 4
JOIN + CASE Expression — Join User Names While Converting Order Statuses to Labels
INNER JOINCASE expressionLabel conversionStatus management
Background

A CASE expression is a conditional expression in SQL that returns different values depending on a column's value. Combined with JOIN, it lets you join data and transform or label values at the same time.

SELECT
  column_name,
  CASE column_name                        -- simple CASE: branch on a column's value
    WHEN 'pending'   THEN 'Processing'
    WHEN 'shipped'   THEN 'Shipped'
    ELSE             'Unknown'         -- fallback for an undefined status
  END AS status_label
Simple CASE vs searched CASE:
CASE column_name WHEN value THEN ... (simple): compares a column's value with constants using =. It is suited to mapping enumerated values as in this question.
CASE WHEN condition THEN ... (searched): accepts arbitrary conditions such as >, LIKE, and IS NULL. It is suited to range classification and compound conditions.
Problem

Join the orders and users tables and retrieve the order list. Convert the English values in status into labels (status_label) with a CASE expression.

Tables used
▸ orders
order_iduser_idamountstatus
113000shipped
211500pending
325000delivered
432000cancelled
53800pending
▸ users
user_idname
1Tanaka
2Sato
3Yamada
Expected Output
order_iduser_nameamountstatus_label
1Tanaka3000Shipped
2Tanaka1500Processing
3Sato5000Delivered
4Yamada2000Cancelled
5Yamada800Processing
Model Answer
SELECT
  o.order_id,
  u.name AS user_name,
  o.amount,
  CASE o.status                    -- simple CASE: branch on o.status and convert it to an English label
    WHEN 'pending'   THEN 'Processing'
    WHEN 'shipped'   THEN 'Shipped'
    WHEN 'delivered' THEN 'Delivered'
    WHEN 'cancelled' THEN 'Cancelled'
    ELSE             'Unknown'          -- fallback for an undefined status
  END AS status_label
FROM orders AS o
INNER JOIN users AS u
  ON o.user_id = u.user_id
ORDER BY o.order_id;

/*
  Execution order:
  1. FROM orders AS o          → read 5 orders
  2. INNER JOIN users AS u     → match by user_id and add user_name
  3. CASE o.status ...         → evaluate each status and convert it to an English label
  4. SELECT / ORDER BY         → output 4 columns in order_id ascending order
*/
Explanation (table transitions & key points)
SELECT o.order_id, u.name AS user_name, o.amount, CASE o.status WHEN 'pending' THEN 'Processing' WHEN 'shipped' THEN 'Shipped' WHEN 'delivered' THEN 'Delivered' WHEN 'cancelled' THEN 'Cancelled' ELSE 'Unknown' END AS status_label FROM orders AS o INNER JOIN users AS u ON o.user_id = u.user_id ORDER BY o.order_id;
LEGEND
Rows read / loaded
① FROM
FROM orders AS oRead the orders table (5 rows) as the starting point. The status column will be evaluated by the CASE expression.
1 / 5
order_iduser_idamountstatus
113000shipped
211500pending
325000delivered
432000cancelled
53800pending
All 5 rows read (including the status column)
LEARNING POINTS
JOIN PATTERN
INNER JOIN + CASE — Join and label in one query
Retrieve names with JOIN while converting statuses with CASE
o = orders (left)  u = users (right)
Choosing between simple CASE and searched CASE: CASE o.status WHEN '...' THEN '...' in this question is a simple CASE; it compares the specified column's value with constants using =. A searched CASE such as CASE WHEN o.amount > 5000 THEN 'High' WHEN o.amount > 1000 THEN 'Medium' ELSE 'Low' END accepts arbitrary conditions. Use searched CASE for ranges and compound conditions.
CASE expressions are not limited to SELECT: A CASE expression can be used in ORDER BY, GROUP BY, WHERE, and as an argument to an aggregate function—for example, COUNT(CASE WHEN status='shipped' THEN 1 END) counts shipped orders only. It is one of the most general-purpose expressions in SQL.
ANTI-PATTERNS
Converting in application code can lead to an N+1 problem: Fetching English statuses from SQL and converting them in the application works, but looping after retrieving all rows gets slower as the data grows. It also scatters the status definition across application code and SQL, increasing maintenance costs. As a rule, centralize the conversion logic in a SQL CASE expression.
Omitting ELSE makes an undefined status NULL: If a CASE expression has no ELSE, a value that matches none of the WHEN clauses returns NULL. Unexpected status values can enter production data, so always write ELSE 'Unknown' to make the fallback explicit.
FIELD NOTE
It is common to store English constant values (enums) such as order statuses and payment states in the database while displaying localized labels in API responses and UIs. Combining JOIN + CASE lets one query retrieve and convert “who,” “what,” and “in which state” at once, keeping backend API code simple.
QUESTION 5
Derived-Table JOIN — Join Users to a Subquery's Aggregated Result
INNER JOINDerived tableSubqueryInline view
Background

A derived table is a technique that treats a subquery written in the FROM or JOIN clause as a temporary table. Give the subquery an alias and you can JOIN it just like an ordinary table.

FROM users AS u
INNER JOIN (                       -- the derived table (subquery) starts here
  SELECT
    user_id,
    MAX(ordered_at) AS latest,
    SUM(amount)     AS total
  FROM orders
  GROUP BY user_id
) AS s                             -- give the derived table the alias s
  ON u.user_id = s.user_id;
Execution order: The derived table is executed before the outer query and treated as a small, aggregated table. Because aggregation and transformation are completed in advance, the result can be joined to another table with a simpler outer query.
Problem

Group and aggregate the orders table by user_id (latest order date and total amount), then JOIN that result to users to retrieve it together with the user name. Sort the output by latest order date in descending order.

Tables used
▸ users
user_idname
1Tanaka
2Sato
3Yamada
▸ orders
order_iduser_idamountordered_at
1130002024-01-10
2115002024-02-15
3250002024-01-20
4320002024-03-05
Expected Output
namelatest_order_datetotal_amount
Yamada2024-03-052000
Tanaka2024-02-154500
Sato2024-01-205000
Model Answer
SELECT
  u.name,
  s.latest_order_date,
  s.total_amount
FROM users AS u
INNER JOIN (                        -- subquery in the FROM clause (derived table)
  SELECT
    user_id,
    MAX(ordered_at) AS latest_order_date,  -- latest order date
    SUM(amount)     AS total_amount        -- total amount
  FROM orders
  GROUP BY user_id
) AS s                             -- give the derived table the alias s
  ON u.user_id = s.user_id
ORDER BY s.latest_order_date DESC; -- latest order date descending

/*
  Execution order:
  1. Execute the subquery       → aggregate orders by user_id
                                 derived table s (3 rows) is generated
  2. FROM users AS u           → read the 3 users
  3. INNER JOIN ... AS s       → match u.user_id with s.user_id (3 rows retained)
  4. SELECT u.name, s.*        → project the requested 3 columns
  5. ORDER BY latest_order_date DESC → sort by latest order date descending
*/
Explanation (table transitions & key points)
SELECT u.name, s.latest_order_date, s.total_amount FROM users AS u INNER JOIN ( SELECT user_id, MAX(ordered_at) AS latest_order_date, SUM(amount) AS total_amount FROM orders GROUP BY user_id ) AS s ON u.user_id = s.user_id ORDER BY s.latest_order_date DESC;
LEGEND
Rows read / loaded
① Subquery: FROM orders
FROM ordersFirst, the subquery in the FROM clause runs. Read the 4 rows in the orders table that it will aggregate.
1 / 6
order_iduser_idamountordered_at
1130002024-01-10
2115002024-02-15
3250002024-01-20
4320002024-03-05
Subquery begins: 4 rows read
LEARNING POINTS
JOIN PATTERN
INNER JOIN derived table — Join a subquery as a virtual table
JOIN an aggregated subquery (s) to an outer table (u)
u = users   s = derived table (aggregated orders)
Execution order for a derived table: The SQL engine executes a subquery in the FROM clause before the outer query. The subquery returns an aggregated result (3 rows), which the outer query JOINs as “table s.” This enables the pattern “JOIN on values after aggregation.” Because this derived table is a non-correlated subquery that cannot reference columns from the outer query, it runs once.
Developing the query into a CTE (WITH clause): Rewriting a derived table with a WITH clause (Common Table Expression) improves readability. The shape is WITH s AS (SELECT user_id, MAX(ordered_at) ... FROM orders GROUP BY user_id) SELECT ... FROM users JOIN s .... A CTE returns the same result as a subquery, but its named definition at the top makes long queries easier to follow and allows it to be referenced in multiple places.
ANTI-PATTERNS
Replacing it with a correlated subquery can hurt performance: A query such as SELECT u.name, (SELECT MAX(ordered_at) FROM orders WHERE user_id = u.user_id) AS latest ... executes the subquery once per outer row. If users has 1 million rows, it runs 1 million times. A derived table or CTE performs the aggregation once.
Forgetting the derived-table alias causes an error: A subquery written as INNER JOIN (SELECT ...) ON ... without an alias produces an error in both PostgreSQL and MySQL. Always give a derived table an alias with AS alias_name.
FIELD NOTE
The pattern “aggregate subquery → JOIN” appears frequently in practice, for example in a dashboard API that returns each user's latest login time and cumulative billing amount. Once comfortable with it, rewrite the query with a WITH clause (CTE) to make complex queries easier to read. As further patterns, LATERAL JOIN for retrieving multiple rows from the latest record and window functions also appear frequently in production queries.