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;
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
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.
| 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 |
※ Sato (1 order) is excluded by HAVING.
| name | order_count | total_amount |
|---|---|---|
| Tanaka | 2 | 4500 |
| Yamada | 2 | 2800 |
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 */
LEGEND
① 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.| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
u = users (left) o = orders (right)
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.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.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.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.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;
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).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.
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| order_id | user_id | amount |
|---|---|---|
| 1 | 1 | 3000 |
| 2 | 1 | 1500 |
| 3 | 2 | 5000 |
※ Yamada (user_id=3) has no order in orders.
| name | order_count | total_amount |
|---|---|---|
| Sato | 1 | 5000 |
| Tanaka | 2 | 4500 |
| Yamada | 0 | 0 |
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 */
LEGEND
① 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.| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
u = users (left) o = orders (right)
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.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.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).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.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
manager_id, you can represent a hierarchy in one table. SELF JOIN is the standard technique for retrieving parent-child relationships from this structure.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).
| emp_id | name | manager_id |
|---|---|---|
| 1 | Tanaka | NULL |
| 2 | Sato | 1 |
| 3 | Yamada | 1 |
| 4 | Suzuki | 2 |
| 5 | Takahashi | 2 |
※ manager_id=NULL (Tanaka, the president) is automatically excluded by INNER JOIN.
| employee | manager |
|---|---|
| Sato | Tanaka |
| Yamada | Tanaka |
| Suzuki | Sato |
| Takahashi | Sato |
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 */
LEGEND
① 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.| emp_id | name | manager_id |
|---|---|---|
| 1 | Tanaka | NULL |
| 2 | Sato | 1 |
| 3 | Yamada | 1 |
| 4 | Suzuki | 2 |
| 5 | Takahashi | 2 |
e = employee role m = manager role (both employees)
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.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.LEFT JOIN employees AS m ON e.manager_id = m.emp_id.WITH RECURSIVE query; for one direct level, SELF JOIN is the simplest solution.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
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.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.
| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | 1 | 3000 | shipped |
| 2 | 1 | 1500 | pending |
| 3 | 2 | 5000 | delivered |
| 4 | 3 | 2000 | cancelled |
| 5 | 3 | 800 | pending |
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| order_id | user_name | amount | status_label |
|---|---|---|---|
| 1 | Tanaka | 3000 | Shipped |
| 2 | Tanaka | 1500 | Processing |
| 3 | Sato | 5000 | Delivered |
| 4 | Yamada | 2000 | Cancelled |
| 5 | Yamada | 800 | Processing |
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 */
LEGEND
① FROM
FROM orders AS oRead the orders table (5 rows) as the starting point. The status column will be evaluated by the CASE expression.| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | 1 | 3000 | shipped |
| 2 | 1 | 1500 | pending |
| 3 | 2 | 5000 | delivered |
| 4 | 3 | 2000 | cancelled |
| 5 | 3 | 800 | pending |
o = orders (left) u = users (right)
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.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.NULL. Unexpected status values can enter production data, so always write ELSE 'Unknown' to make the fallback explicit.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;
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.
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| order_id | user_id | amount | ordered_at |
|---|---|---|---|
| 1 | 1 | 3000 | 2024-01-10 |
| 2 | 1 | 1500 | 2024-02-15 |
| 3 | 2 | 5000 | 2024-01-20 |
| 4 | 3 | 2000 | 2024-03-05 |
| name | latest_order_date | total_amount |
|---|---|---|
| Yamada | 2024-03-05 | 2000 |
| Tanaka | 2024-02-15 | 4500 |
| Sato | 2024-01-20 | 5000 |
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 */
LEGEND
① Subquery: FROM orders
FROM ordersFirst, the subquery in the FROM clause runs. Read the 4 rows in the orders table that it will aggregate.| order_id | user_id | amount | ordered_at |
|---|---|---|---|
| 1 | 1 | 3000 | 2024-01-10 |
| 2 | 1 | 1500 | 2024-02-15 |
| 3 | 2 | 5000 | 2024-01-20 |
| 4 | 3 | 2000 | 2024-03-05 |
u = users s = derived table (aggregated orders)
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.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.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.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.