The NOT IN and NULL Subquery Trap — One NULL in a subquery makes every row disappear as UNKNOWN
col NOT IN (a, b, c) is internally equivalent to col <> a AND col <> b AND col <> c. If the list contains even one NULL, col <> NULL → UNKNOWN, and UNKNOWN propagates through the AND chain, so all rows are excluded.
-- When the list contains NULL, UNKNOWN propagates and all rows are excluded col NOT IN (10, 30, NULL) -- Internal expansion: col <> 10 AND col <> 30 AND col <> NULL (← UNKNOWN) -- Solution 1: NOT EXISTS (unaffected by NULL) WHERE NOT EXISTS (SELECT 1 FROM sub WHERE sub.col = t.col) -- Solution 2: exclude NULL from the subquery WHERE col NOT IN (SELECT col FROM sub WHERE col IS NOT NULL)
From the employees table, extract employees in departments that are not managed (their dept_id does not exist in managed_depts). Return the columns emp_id, name, dept_id, ordered by emp_id ascending.
| emp_id | name | dept_id |
|---|---|---|
| 1 | Tanaka | 10 |
| 2 | Suzuki | 20 |
| 3 | Sato | 30 |
| 4 | Ito | 40 |
| 5 | Yamada | NULL |
| 6 | Takahashi | 20 |
| dept_id | dept_name |
|---|---|
| 10 | Sales |
| 30 | HR |
| NULL | Undefined |
| emp_id | name | dept_id |
|---|---|---|
| 2 | Suzuki | 20 |
| 4 | Ito | 40 |
| 5 | Yamada | NULL |
| 6 | Takahashi | 20 |
The Aggregate Function NULL Trap — COUNT(*) / COUNT(column) / AVG silently shrink the denominator
Aggregate functions automatically ignore NULL. Only COUNT(*) counts every row; COUNT(col), SUM(col), and AVG(col) exclude rows where the column is NULL. In particular, AVG's shrinking denominator creates a quiet bug in which "the average for representatives with results" is reported as "the average for everyone."
-- Different behavior COUNT(*) -- count all rows (including NULL) COUNT(amount) -- count non-NULL rows only -- AVG excludes NULL and shrinks the denominator (equivalent to SUM / COUNT(amount)) AVG(amount) -- Average for everyone, treating NULL as 0 AVG(COALESCE(amount, 0))
From the sales_results table, calculate the headcount, number of representatives with results, average sales for representatives with results, and average for everyone including 0 results by department (dept). Return the columns dept, head_count, active_reps, avg_amount, true_avg, ordered by dept ascending.
| rep_id | dept | amount |
|---|---|---|
| 1 | Tokyo | 250000 |
| 2 | Tokyo | 300000 |
| 3 | Tokyo | NULL |
| 4 | Tokyo | NULL |
| 5 | Osaka | 150000 |
| 6 | Osaka | 200000 |
| 7 | Osaka | 180000 |
| 8 | Osaka | NULL |
| dept | head_count | active_reps | avg_amount | true_avg |
|---|---|---|---|---|
| Osaka | 4 | 3 | 176667 | 132500 |
| Tokyo | 4 | 2 | 275000 | 137500 |
LEFT JOIN ON-Clause Filters — Preserve non-matching rows as NULL
The purpose of a LEFT JOIN is to preserve every row from the left table. If you want to specify a condition on right-table columns (such as orders above a certain amount) while still preserving left-table rows that do not meet the condition as NULL, write the filter condition in the ON clause.
-- Writing the condition in ON preserves non-matching rows as NULL FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id AND o.amount > 20000
For every customer in the customers table, display the maximum amount of orders above 20,000 yen (large_order). For customers with no large order or no orders at all, display NULL for large_order. Return the columns customer_id, customer_name, large_order, ordered by customer_id ascending.
| customer_id | customer_name |
|---|---|
| 1 | Tanaka Trading |
| 2 | Suzuki Trading |
| 3 | Sato Trading |
| 4 | New Customer |
| order_id | customer_id | amount |
|---|---|---|
| 1 | 1 | 10000 |
| 2 | 1 | 35000 |
| 3 | 1 | 40000 |
| 4 | 2 | 28000 |
| 5 | 2 | 8000 |
| 6 | 3 | 5000 |
| 7 | 3 | 12000 |
| customer_id | customer_name | large_order |
|---|---|---|
| 1 | Tanaka Trading | 40000 |
| 2 | Suzuki Trading | 28000 |
| 3 | Sato Trading | NULL |
| 4 | New Customer | NULL |
Turn Division by Zero into NULL with NULLIF — Use NULL as a Defensive Tool
NULLIF(expr, value) returns NULL when expr = value; otherwise it returns expr unchanged. Using it to prevent division by zero safely returns NULL instead of raising an error.
-- Basic: NULL when a = b; otherwise return a NULLIF(a, b) -- Prevent division by zero (return NULL when units=0, making the result NULL) revenue / NULLIF(units, 0) -- Return 0 when division by zero would occur COALESCE(revenue / NULLIF(units, 0), 0)
For each daily campaign record in the campaign_daily table, calculate and output clicks, conversion rate (conv_rate = conversions / clicks), and revenue per click (rev_per_click = revenue / clicks). For rows where clicks is 0, display NULL for conv_rate and rev_per_click (prevent division by zero with NULLIF). Return the columns date, campaign, clicks, conv_rate, rev_per_click, ordered by date and campaign ascending.
| date | campaign | clicks | conversions | revenue |
|---|---|---|---|---|
| 2024-03-01 | CP_A | 1000 | 50 | 200000 |
| 2024-03-01 | CP_B | 0 | 0 | 0 |
| 2024-03-02 | CP_A | 800 | 32 | 160000 |
| 2024-03-02 | CP_B | 500 | 20 | 100000 |
| 2024-03-03 | CP_A | 0 | 0 | 0 |
| 2024-03-03 | CP_B | 600 | 24 | 120000 |
| date | campaign | clicks | conv_rate | rev_per_click |
|---|---|---|---|---|
| 2024-03-01 | CP_A | 1000 | 0.0500 | 200.0000 |
| 2024-03-01 | CP_B | 0 | NULL | NULL |
| 2024-03-02 | CP_A | 800 | 0.0400 | 200.0000 |
| 2024-03-02 | CP_B | 500 | 0.0400 | 200.0000 |
| 2024-03-03 | CP_A | 0 | NULL | NULL |
| 2024-03-03 | CP_B | 600 | 0.0400 | 200.0000 |
Window Functions and NULL Propagation — Boundary NULL in LAG and chained NULLs in month-over-month growth
LAG(col, offset, default) returns the value from a previous row, but NULL can arise for two different reasons: the column itself is NULL, or the first row has no previous row (a boundary NULL). These causes must be distinguished, and the design must prevent unintended NULL propagation.
-- ① Prevent boundary NULL only (the first row becomes 0, but a NULL in the previous row remains NULL) LAG(revenue, 1, 0) OVER (ORDER BY month) -- ② Prevent both boundary NULL and NULL in the data itself (practical pattern) LAG(COALESCE(revenue, 0), 1, 0) OVER (ORDER BY month)
LAG is used only when the previous row does not exist, such as for the first row. It cannot prevent NULL when the previous row exists but its value is NULL; in that case, NULL is returned unchanged. In practice, protect the target column with COALESCE and also provide LAG's third argument to obtain a reliable default value (such as 0) in every situation.From the monthly_revenue table, calculate monthly revenue (revenue), previous-month revenue (prev_revenue), and month-over-month growth rate (mom_growth).
When revenue is NULL because data was not collected, treat it as 0. When using LAG() to obtain the previous month's revenue, also return 0 for the first month and when the previous month's revenue is 0 (including a value corrected from NULL).
Calculate mom_growth = ROUND((current month - previous month) / previous month, 4), and display NULL when the previous month is 0 and the calculation cannot be performed (division by zero). Return month, revenue, prev_revenue, mom_growth, ordered by month ascending.
| month | revenue |
|---|---|
| 2024-01 | 1000000 |
| 2024-02 | 1200000 |
| 2024-03 | NULL |
| 2024-04 | 1100000 |
| 2024-05 | 1400000 |
| 2024-06 | 1350000 |
| month | revenue | prev_revenue | mom_growth |
|---|---|---|---|
| 2024-01 | 1000000 | 0 | NULL |
| 2024-02 | 1200000 | 1000000 | 0.2000 |
| 2024-03 | 0 | 1200000 | -1.0000 |
| 2024-04 | 1100000 | 0 | NULL |
| 2024-05 | 1400000 | 1100000 | 0.2727 |
| 2024-06 | 1350000 | 1400000 | -0.0357 |