When you group a column containing NULL with GROUP BY, all rows whose value is NULL are collected into one group called 'NULL'. Unlike a normal comparison (NULL = NULL → UNKNOWN), GROUP BY treats NULL values as belonging to the same group.
/* GROUP BY region only: NULL remains NULL in the aggregation */ SELECT region, COUNT(*) FROM sales GROUP BY region; -- > Tokyo|2, Osaka|2, NULL|2 /* Give the NULL group a label with COALESCE */ SELECT COALESCE(region, 'Unknown'), COUNT(*) FROM sales GROUP BY COALESCE(region, 'Unknown'); -- > Tokyo|2, Osaka|2, Unknown|2
COUNT(DISTINCT region) excludes NULL and returns the number of unique non-NULL values (only the two types Tokyo and Osaka). Using COUNT(DISTINCT COALESCE(region, 'Unknown')) includes the NULL group and returns three types.From the sales table, calculate the total sales amount by region (total_amount) and the number of orders (sale_count). Aggregate NULL region values as 'Unknown' and return the results in descending total_amount order. The output columns must be region, total_amount, sale_count.
| sale_id | region | amount |
|---|---|---|
| 1 | Tokyo | 15000 |
| 2 | Osaka | 8000 |
| 3 | Tokyo | 12000 |
| 4 | NULL | 5000 |
| 5 | Osaka | 9000 |
| 6 | NULL | 7000 |
Note: region=NULL means the order has no region set (2 orders). GROUP BY alone leaves the NULL group displayed as NULL; it does not label it 'Unknown'.
Expected output (total_amount descending):
| region | total_amount | sale_count |
|---|---|---|
| Tokyo | 27000 | 2 |
| Osaka | 17000 | 2 |
| Unknown | 12000 | 2 |
Tokyo: 15000+12000=27000 / Osaka: 8000+9000=17000 / Unknown (NULL): 5000+7000=12000.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
The window function LAG(col) references the value from the previous row, but the NULL it returns can have two different causes with different meanings.
/* ① Boundary NULL: the first row has no previous row, so it returns NULL */ LAG(amount) OVER (ORDER BY dt) /* ② Data NULL: the previous row's value itself is NULL (missing) */ -- The default-value argument avoids only ① boundary NULL, not ② data NULL LAG(amount, 1, 0) OVER (ORDER BY dt)
amount / prev_amount - 1, if either amount or prev_amount is NULL, growth_rate is also NULL. This accurately represents that the growth rate between missing data points cannot be calculated.Using the daily_sales table, calculate the day-over-day growth rate (growth_rate). Use a CTE and LAG to obtain prev_amount, then calculate growth_rate = (amount / prev_amount − 1) × 100. On a day when amount or prev_amount is NULL, growth_rate must also be NULL. Return dt, amount, prev_amount, growth_rate in ascending dt order, rounding growth_rate to one decimal place.
| dt | amount |
|---|---|
| 2024-01-01 | 10000 |
| 2024-01-02 | 12000 |
| 2024-01-03 | NULL |
| 2024-01-04 | 9000 |
| 2024-01-05 | 11000 |
| 2024-01-06 | NULL |
| 2024-01-07 | 13000 |
Note: amount is missing (NULL) on 2024-01-03 and 2024-01-06. growth_rate is also NULL on rows where LAG reads one of those NULL values.
Expected output (dt ascending):
| dt | amount | prev_amount | growth_rate |
|---|---|---|---|
| 2024-01-01 | 10000 | NULL | NULL |
| 2024-01-02 | 12000 | 10000 | 20.0 |
| 2024-01-03 | NULL | 12000 | NULL |
| 2024-01-04 | 9000 | NULL | NULL |
| 2024-01-05 | 11000 | 9000 | 22.2 |
| 2024-01-06 | NULL | 11000 | NULL |
| 2024-01-07 | 13000 | NULL | NULL |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
If a NOT IN list contains even one NULL, every row evaluates to UNKNOWN → WHERE excludes all rows, returning zero rows. This is a critical trap.
/* If NOT IN contains NULL, every row becomes UNKNOWN and is excluded */ dept_id NOT IN (10, NULL) -- For dept_id=20: IN evaluates to UNKNOWN → NOT IN is also UNKNOWN → excluded (bug) /* NOT EXISTS is a NULL-safe anti-join */ WHERE NOT EXISTS (SELECT 1 FROM t WHERE t.dept_id = c.dept_id) -- With no matching row (including NULL comparisons), EXISTS=FALSE → NOT EXISTS=TRUE (passes)
NOT IN (SELECT col FROM ...), one NULL in the subquery column excludes every row. NOT EXISTS is the recommended NULL-safe alternative.From the candidates table, exclude candidates whose dept_id appears in blocked_dept_ids. Note that the dept_id column of blocked_dept_ids contains NULL. If a candidate's dept_id is NULL (Sato), include that candidate because you cannot confirm that the candidate is blocked. The output columns must be candidate_id, name, dept_id, ordered by ascending candidate_id.
| candidate_id | name | dept_id |
|---|---|---|
| 1 | Tanaka | 10 |
| 2 | Suzuki | 20 |
| 3 | Sato | NULL |
| 4 | Ito | 10 |
| 5 | Yamada | 30 |
| dept_id |
|---|
| 10 |
| NULL |
Note: blocked_dept_ids.dept_id contains NULL, which is the trap. Using NOT IN would incorrectly remove Suzuki, Sato, and Yamada as well.
Expected output (candidate_id ascending):
| candidate_id | name | dept_id |
|---|---|---|
| 2 | Suzuki | 20 |
| 3 | Sato | NULL |
| 5 | Yamada | 30 |
dept_id=10 (Tanaka and Ito) are blocked and excluded. Sato, whose dept_id is NULL, passes NOT EXISTS because being blocked cannot be proven.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
Adding FILTER (WHERE ...) to an aggregate function lets you aggregate only rows that satisfy a condition. It is important to understand its equivalence to CASE WHEN and the difference in how NULL is handled when the aggregated column contains NULL.
/* FILTER clause: aggregate only matching rows */ COUNT(*) FILTER (WHERE event_type = 'click') -- COUNT(*) counts rows, so a NULL in another column still counts as 1 /* Specify the target column and skip NULL */ AVG(duration_sec) FILTER (WHERE event_type = 'purchase') -- rows where duration_sec is NULL are excluded from the calculation
COUNT returns 0 when zero rows match the condition. In contrast, AVG and SUM return NULL when no rows match. AVG(col) also returns NULL when every candidate value of col is NULL.Using the app_events table, calculate per-user counts by event type (view_count, click_count, purchase_count) and the average duration of purchase events (avg_purchase_sec). Return user_id, view_count, click_count, purchase_count, avg_purchase_sec in ascending user_id order, rounding avg_purchase_sec to one decimal place.
| event_id | user_id | event_type | duration_sec |
|---|---|---|---|
| 1 | U1 | view | 15 |
| 2 | U1 | click | NULL |
| 3 | U1 | purchase | 45 |
| 4 | U2 | view | 20 |
| 5 | U2 | click | NULL |
| 6 | U2 | click | NULL |
| 7 | U3 | purchase | 60 |
| 8 | U3 | view | 10 |
Note: click events have NULL duration_sec (not measured). U2 has no purchase events, so avg_purchase_sec = NULL.
Expected output (user_id ascending):
| user_id | view_count | click_count | purchase_count | avg_purchase_sec |
|---|---|---|---|---|
| U1 | 1 | 1 | 1 | 45.0 |
| U2 | 1 | 2 | 0 | NULL |
| U3 | 1 | 0 | 1 | 60.0 |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
When you aggregate data after a LEFT JOIN, both the location of the filter and the aggregation method can change the result substantially.
/* 1. Difference between ON and WHERE */ LEFT JOIN orders o ON m.id = o.member_id AND o.status = 'completed' -- ✓ members with no match remain (correct LEFT JOIN behavior) LEFT JOIN orders o ON m.id = o.member_id WHERE o.status = 'completed' -- × members with no orders disappear (effectively becomes INNER JOIN) /* 2. Aggregate multiple conditions with CASE WHEN */ SUM(CASE WHEN o.status = 'completed' THEN o.amount END) COUNT(CASE WHEN o.status = 'cancelled' THEN 1 END)
SUM returns NULL while COUNT returns 0. SUM needs COALESCE to convert NULL to 0, while COUNT does not.Using the members and orders tables, calculate the total amount of completed orders per member (completed_amount) and the number of cancelled orders (cancelled_count). Members with no orders must also be output as 0. Return member_id, name, completed_amount, cancelled_count in ascending member_id order.
| member_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Suzuki |
| 3 | Sato |
| 4 | Ito |
| 5 | Yamada |
| order_id | member_id | status | amount |
|---|---|---|---|
| 1 | 1 | completed | 5000 |
| 2 | 1 | cancelled | 2000 |
| 3 | 2 | completed | 8000 |
| 4 | 2 | completed | 3000 |
| 5 | 3 | pending | 1500 |
| 6 | 3 | cancelled | 1000 |
| 7 | 4 | completed | 6000 |
| 8 | 1 | pending | 4000 |
Note: member_id=5 (Yamada) has no orders. Sato has no completed order (only pending and cancelled).
Expected output (member_id ascending):
| member_id | name | completed_amount | cancelled_count |
|---|---|---|---|
| 1 | Tanaka | 5000 | 1 |
| 2 | Suzuki | 11000 | 0 |
| 3 | Sato | 0 | 1 |
| 4 | Ito | 6000 | 0 |
| 5 | Yamada | 0 | 0 |
Sato: no completed order → SUM=NULL → COALESCE → 0. Yamada: no orders → every joined order column is NULL → COALESCE → 0.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free