In Part 1 Q2, the INNER JOIN excluded Human Resources because it had zero employees. A LEFT JOIN removes this limitation and always keeps every row from the left table. When no row on the right matches, it automatically generates a row padded with NULLs.
-- INNER JOIN: a department with zero employees is excluded FROM departments INNER JOIN employees ... -- Human Resources: does not appear in the result -- LEFT JOIN: keep departments with zero employees; the employee side is NULL FROM departments LEFT JOIN employees ... -- Human Resources: remains with emp_id=NULL
COUNT(*) counts the existence of rows. A row containing NULL columns still exists as one row, so a department with zero employees is incorrectly counted as 1.COUNT(e.emp_id) returns the number of rows where that column is not NULL. It skips the NULL row and correctly returns 0.LEFT JOIN departments and employees, and retrieve the number of employees per department (emp_count). Departments with zero employees must also be included.
| dept_id | dept_name |
|---|---|
| 1 | Sales |
| 2 | Engineering |
| 3 | Human Resources |
| emp_id | name | dept_id |
|---|---|---|
| 1 | Tanaka | 1 |
| 2 | Sato | 1 |
| 3 | Yamada | 2 |
| 4 | Suzuki | 2 |
Note: Human Resources (dept_id=3) has no employees. Using COUNT(*) would incorrectly count it as 1.
| dept_name | emp_count |
|---|---|
| Sales | 2 |
| Engineering | 2 |
| Human Resources | 0 |
-- ✗ COUNT(*): counts the NULL row as 1 → Human Resources becomes 1 (wrong) -- SELECT d.dept_name, COUNT(*) AS emp_count ... SELECT d.dept_name, COUNT(e.emp_id) AS emp_count -- skip NULL → Human Resources is 0 (correct) FROM departments AS d LEFT JOIN employees AS e ON d.dept_id = e.dept_id GROUP BY d.dept_id, d.dept_name ORDER BY d.dept_id; /* Execution order: 1. FROM departments AS d → Read departments 2. LEFT JOIN employees AS e → Join (expanded 1:N) 3. GROUP BY dept_id, dept_name → Group rows 4. COUNT(e.emp_id) → Count while skipping NULL 5. SELECT d.dept_name, emp_count → Project two columns 6. ORDER BY d.dept_id → Sort and output */
LEGEND
① Left table
FROM departments AS dRead the departments table (3 rows). Human Resources (dept_id=3) has no employees, but a LEFT JOIN keeps it in the result.| dept_id | dept_name |
|---|---|
| 1 | Sales |
| 2 | Engineering |
| 3 | Human Resources |
LEFT JOIN → generate NULL → skip it with COUNT(e.emp_id)
COUNT(*) evaluates only whether the current row exists. A row with NULL columns still exists as one row, so it counts the NULL row generated by LEFT JOIN as 1. In contrast, COUNT(e.emp_id) returns the number of rows where e.emp_id is not NULL, skips the NULL row, and correctly returns 0. This distinction is one of the most common SQL aggregation mistakes.COUNT(COALESCE(e.emp_id, 0)), COALESCE converts NULL to 0, so COUNT treats 0 as non-NULL and counts it as 1. The result is the same incorrect 1 as COUNT(*). To skip NULLs, use COUNT(e.emp_id) directly.products LEFT JOIN orders → COUNT(o.order_id)) and a survey summary that includes questions with zero answers (questions LEFT JOIN answers → COUNT(a.answer_id)). The difference between COUNT(*) and COUNT(column) does not exist for SUM in the same way (NULLs are skipped in SUM calculations), but it must always be kept in mind for COUNT.If you try to retrieve only the latest login for each user with a 1:N JOIN, the users rows expand by the number of rows in login_history.
The window function ROW_NUMBER() assigns a sequence number to every row in a group. Specify the group with PARTITION BY and the sort order with ORDER BY to select the newest row for each user, where rn=1.
-- ROW_NUMBER() syntax ROW_NUMBER() OVER ( PARTITION BY user_id -- reset for each user ORDER BY login_at DESC -- number newest first ) AS rn -- rn=1 is the latest login
rn=1.Join each user's latest login timestamp (last_login) to the users table. It is acceptable for last_login to be NULL when a user has no login history.
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| log_id | user_id | login_at |
|---|---|---|
| 1 | 1 | 2024-03-10 |
| 2 | 1 | 2024-03-15 |
| 3 | 2 | 2024-03-08 |
| 4 | 2 | 2024-03-10 |
| 5 | 3 | 2024-03-12 |
Note: a direct JOIN would expand each users row by its number of logins. Use the correct approach: narrow the N side to one row with CTE + ROW_NUMBER before joining.
| name | last_login |
|---|---|
| Tanaka | 2024-03-15 |
| Sato | 2024-03-10 |
| Yamada | 2024-03-12 |
WITH ranked_logins AS ( SELECT user_id, login_at, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY login_at DESC -- the newest row gets rn=1 ) AS rn FROM login_history ) SELECT u.name, r.login_at AS last_login FROM users AS u INNER JOIN ranked_logins AS r ON u.user_id = r.user_id AND r.rn = 1 -- join only the latest row → becomes 1:1 ORDER BY u.user_id; /* Execution order: 1. CTE ranked_logins → Read login_history 2. ROW_NUMBER() OVER (...) → Number each user's rows newest first 3. FROM users AS u → Read users 4. INNER JOIN ranked_logins (rn=1) → Join only the latest rows 5. SELECT u.name, r.login_at → Project two columns 6. ORDER BY u.user_id → Sort and output */
LEGEND
① CTE — source data
FROM login_historyRead the login_history table (5 rows). A direct JOIN would expand the users rows.| log_id | user_id | login_at |
|---|---|---|
| 1 | 1 | 2024-03-10 |
| 2 | 1 | 2024-03-15 |
| 3 | 2 | 2024-03-08 |
| 4 | 2 | 2024-03-10 |
| 5 | 3 | 2024-03-12 |
login_history (N rows) → filter to rn=1 → 1:1 join to users
AND r.rn = 1 to the JOIN condition makes only the rn=1 row (the latest login) for each user eligible to join. Rows with rn=2 or later do not join, so the post-JOIN row count stays equal to the users row count (3 rows). The reason N:1 expansion does not occur is the guarantee that rn=1 is exactly one row per user.RANK() assigns the same number to tied rows. If two rows have exactly the same login_at, RANK gives both rows rn=1 and the JOIN expands to two rows. Use ROW_NUMBER when exactly one row is required.FROM (SELECT ..., ROW_NUMBER() ... ) AS sub WHERE sub.rn = 1, but a CTE improves readability. The important point is to filter to rn=1 after calculating ROW_NUMBER. A window function cannot be used directly in a WHERE clause, so a subquery or CTE is required.orders with ROW_NUMBER PARTITION BY customer_id ORDER BY ordered_at DESC), the latest price per product (price_history), and each employee's current position (position_history). It is especially powerful when you need multiple columns from that row, which GROUP BY + MAX cannot provide.In Part 1 Q5, we avoided a fan trap with an inline subquery. Writing the same solution with a CTE (WITH clause) makes the query much easier to read. A CTE is a named subquery that is defined before the main query.
-- Basic CTE syntax: define aggregate tables before joining WITH emp_agg AS ( SELECT dept_id, SUM(salary) AS total_salary FROM employees GROUP BY dept_id -- convert 1:N to 1:1 ), sales_agg AS (...) -- chain multiple CTEs with commas SELECT ... FROM departments JOIN emp_agg ... -- safe 1:1 × 1:1 join
① Readability: define the aggregation logic first, keeping the main query simple.
② Reusability: reference the same CTE more than once.
③ Ease of debugging: SELECT from a CTE on its own to inspect an intermediate result.
Retrieve the total employee salary (total_salary) and total sales (total_sales) for each department. Use the correct approach: pre-aggregate each 1:N table with a CTE (WITH clause) before joining them.
| dept_id | dept_name |
|---|---|
| 1 | Sales |
| 2 | Engineering |
| emp_id | dept_id | name | salary |
|---|---|---|---|
| 1 | 1 | Tanaka | 400000 |
| 2 | 1 | Sato | 350000 |
| 3 | 2 | Yamada | 500000 |
| 4 | 2 | Suzuki | 450000 |
| sale_id | dept_id | amount |
|---|---|---|
| 1 | 1 | 800000 |
| 2 | 1 | 600000 |
| 3 | 2 | 1200000 |
| 4 | 2 | 900000 |
Note: a direct JOIN creates an employees×dept_sales Cartesian product and doubles every value (the fan trap).
| dept_name | total_salary | total_sales |
|---|---|---|
| Sales | 750000 | 1400000 |
| Engineering | 950000 | 2100000 |
WITH emp_agg AS ( SELECT dept_id, SUM(salary) AS total_salary FROM employees GROUP BY dept_id -- convert 1:N → 1:1 (dept_id becomes unique) ), sales_agg AS ( SELECT dept_id, SUM(amount) AS total_sales FROM dept_sales GROUP BY dept_id -- convert 1:N → 1:1 (dept_id becomes unique) ) SELECT d.dept_name, e.total_salary, s.total_sales FROM departments AS d INNER JOIN emp_agg AS e ON d.dept_id = e.dept_id -- 1:1 INNER JOIN sales_agg AS s ON d.dept_id = s.dept_id -- 1:1 ORDER BY d.dept_id; /* Execution order: 1. CTE emp_agg → Aggregate employees (unique dept_id) 2. CTE sales_agg → Aggregate dept_sales (unique dept_id) 3. FROM departments → Read departments 4. INNER JOIN emp_agg → 1:1 join on dept_id 5. INNER JOIN sales_agg → 1:1 join on dept_id 6. SELECT, ORDER BY → Project columns and sort */
LEGEND
① CTE 1 source data — employees
FROM employees (salary data)First inspect employees, a 1:N table (4 rows). It has multiple rows per department, so it must be aggregated by dept_id.| emp_id | dept_id | name | salary |
|---|---|---|---|
| 1 | 1 | Tanaka | 400000 |
| 2 | 1 | Sato | 350000 |
| 3 | 2 | Yamada | 500000 |
| 4 | 2 | Suzuki | 450000 |
emp_agg = employee aggregate sales_agg = dept_sales aggregate
SELECT DISTINCT after a fan trap occurs does not repair the inflated and incorrect SUM or AVG values. DISTINCT removes duplicate rows, not double-counted numbers. The only correct solution is aggregation before the JOIN.The granularity of a table describes what one row represents. In a monthly table, one row represents one department and one month; in a daily table, one row represents one department and one day.
When tables with different granularities are joined directly, the coarser-grained value is copied once for every finer-grained row. Joining a monthly budget (one row per month) to three daily sales rows copies the budget three times.
-- ✗ Direct JOIN → monthly_budget.budget is copied for every daily_sales row FROM monthly_budget AS b JOIN daily_sales AS d ON b.dept_id = d.dept_id AND b.month = DATE_TRUNC('month', d.sale_date) -- dept_id=1: budget=500000 copied for 3 days → SUM(budget) = 1500000 (3x!)
DATE_TRUNC('month', date_column) + GROUP BY.Using monthly_budget (monthly budgets) and daily_sales (daily sales), retrieve the budget (budget), monthly sales total (total_revenue), and budget achievement rate (achievement_rate: %, rounded to one decimal place) for each department and month.
| month | dept_id | budget |
|---|---|---|
| 2024-03-01 | 1 | 500000 |
| 2024-03-01 | 2 | 800000 |
| sale_date | dept_id | revenue |
|---|---|---|
| 2024-03-05 | 1 | 100000 |
| 2024-03-12 | 1 | 200000 |
| 2024-03-20 | 1 | 80000 |
| 2024-03-08 | 2 | 250000 |
| 2024-03-15 | 2 | 300000 |
| 2024-03-22 | 2 | 200000 |
Note: a direct JOIN inflates monthly_budget.budget by the number of daily rows (threefold). Use a CTE to aggregate daily data to monthly granularity before joining.
| month | dept_id | budget | total_revenue | achievement_rate |
|---|---|---|---|---|
| 2024-03-01 | 1 | 500000 | 380000 | 76.0 |
| 2024-03-01 | 2 | 800000 | 750000 | 93.8 |
WITH monthly_sales AS ( SELECT DATE_TRUNC('month', sale_date)::DATE AS month, -- convert daily data to monthly granularity dept_id, SUM(revenue) AS total_revenue FROM daily_sales GROUP BY DATE_TRUNC('month', sale_date)::DATE, dept_id ) SELECT b.month, b.dept_id, b.budget, ms.total_revenue, ROUND(ms.total_revenue::NUMERIC / b.budget * 100, 1) AS achievement_rate FROM monthly_budget AS b LEFT JOIN monthly_sales AS ms ON b.month = ms.month AND b.dept_id = ms.dept_id ORDER BY b.month, b.dept_id; /* Execution order: 1. CTE monthly_sales → Read daily_sales 2. DATE_TRUNC('month', ...) → Round dates down to the first of the month 3. GROUP BY month, dept_id → Group by month × department 4. SUM(revenue) → Aggregate monthly sales 5. FROM monthly_budget AS b → Read budgets 6. LEFT JOIN monthly_sales AS ms → Join on month + dept_id 7. ROUND(...) → Calculate the achievement rate (%) 8. SELECT, ORDER BY → Project columns and sort */
LEGEND
① CTE source data — daily_sales
FROM daily_sales (daily granularity)This daily-grain table records multiple sales dates for each department (one row = one department and one day). Aggregate it to match the monthly grain of the budget table.| sale_date | dept_id | revenue |
|---|---|---|
| 2024-03-05 | 1 | 100000 |
| 2024-03-12 | 1 | 200000 |
| 2024-03-20 | 1 | 80000 |
| 2024-03-08 | 2 | 250000 |
| 2024-03-15 | 2 | 300000 |
| 2024-03-22 | 2 | 200000 |
monthly_budget (monthly grain) ← CTE → monthly_sales (monthly grain)
DATE_TRUNC('month', sale_date) rounds a date down to the first day of its month. 2024-03-05, 2024-03-12, and 2024-03-20 all become 2024-03-01. This lets different dates in the same month belong to one GROUP BY group. Using it as a GROUP BY key converts daily granularity to monthly granularity.When looking for users who bought both Coffee and Cake through an N:N junction table, it is tempting to write WHERE product = 'A' AND product = 'B'. But this always returns zero rows.
-- ✗ AND conditions apply to the same row WHERE p.product_name = 'Coffee' AND p.product_name = 'Cake' -- one product_name cannot hold two values at once → always 0 rows -- ✓ narrow candidate rows with IN, then test “has both” with HAVING WHERE p.product_name IN ('Coffee', 'Cake') -- retrieve candidate rows with OR HAVING COUNT(DISTINCT p.product_name) = 2 -- keep groups containing two products
Using the users and purchases tables, retrieve users who bought both “Coffee” and “Cake”.
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| 4 | Suzuki |
| purchase_id | user_id | product_name |
|---|---|---|
| 1 | 1 | Coffee |
| 2 | 1 | Cake |
| 3 | 2 | Coffee |
| 4 | 2 | Sandwich |
| 5 | 3 | Cake |
| 6 | 3 | Sandwich |
| 7 | 4 | Coffee |
| 8 | 4 | Cake |
Note: WHERE product_name = 'Coffee' AND product_name = 'Cake' returns zero rows. Use IN + GROUP BY + HAVING COUNT(DISTINCT ...).
| user_id | name |
|---|---|
| 1 | Tanaka |
| 4 | Suzuki |
-- ✗ AND: one row cannot be both “Coffee” and “Cake” → 0 rows -- WHERE p.product_name = 'Coffee' AND p.product_name = 'Cake' SELECT u.user_id, u.name FROM users AS u INNER JOIN purchases AS p ON u.user_id = p.user_id WHERE p.product_name IN ('Coffee', 'Cake') -- first narrow candidate rows with OR GROUP BY u.user_id, u.name HAVING COUNT(DISTINCT p.product_name) = 2 -- keep only groups with both products ORDER BY u.user_id; /* Execution order: 1. FROM users AS u → Read users 2. INNER JOIN purchases AS p → Join (expanded 1:N) 3. WHERE product_name IN (...) → Keep target products 4. GROUP BY u.user_id, u.name → Group by user 5. HAVING COUNT(DISTINCT product_name) → Keep users with both products 6. SELECT u.user_id, u.name → Project two columns 7. ORDER BY u.user_id → Sort and output */
LEGEND
① Target data (immediately after JOIN)
INNER JOIN users AS u ON u.user_id = p.user_idThis is the state immediately after joining users and purchases by user_id. The 1:N relationship expands each user by the number of purchase rows. We will now apply the WHERE clause to this 8-row table.| user_id | name | product_name |
|---|---|---|
| 1 | Tanaka | Coffee |
| 1 | Tanaka | Cake |
| 2 | Sato | Coffee |
| 2 | Sato | Sandwich |
| 3 | Yamada | Cake |
| 3 | Yamada | Sandwich |
| 4 | Suzuki | Coffee |
| 4 | Suzuki | Cake |
WHERE IN → GROUP BY → HAVING COUNT(DISTINCT) = N
IN ('Coffee', 'Cake') (an OR condition), then group them by user with GROUP BY user_id, and finally keep only users whose group contains two distinct products with HAVING COUNT(DISTINCT product_name) = 2. Expand vertically by row, then evaluate horizontally by group is the essence of SQL-style set operations.HAVING COUNT(DISTINCT product_name) = 2 is the number of target products. To find users who bought all three products (Coffee, Cake, and Sandwich), use = 3. Always make N equal to the number of elements in IN. If the number of elements changes dynamically, write = (SELECT COUNT(DISTINCT ...) FROM ...) with a subquery.purchases AS a JOIN purchases AS b ON a.user_id = b.user_id WHERE a.product='Coffee' AND b.product='Cake' can produce the same result, but adding a third or fourth target product makes the number of joins grow and the query explosively complex. The IN + HAVING COUNT(DISTINCT) pattern scales much better.product_tags with IN + HAVING), candidates who have all required skills (candidate_skills), and users who have every specified permission (user_roles). Making N dynamic generalizes the query to find every row that satisfies all conditions.