NULL — Learn NOT IN traps, aggregate NULLs, JOIN conversion, NULLIF, and division by zero in Practice
AdvancedApplied NULLNOT IN / NOT EXISTSCOUNT / AVG denominatorLEFT JOIN implicit conversionNULLIF / division by zeroPostgreSQL support5 questions
QUESTION 6
The NOT IN and NULL Subquery Trap — One NULL in a subquery makes every row disappear as UNKNOWN
NOT IN / NOT EXISTSCorrelated subqueryUNKNOWN excludes allNULL-safe comparison
Background

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)
Principle of NULL-safe exclusion: With NOT IN and a subquery, always check whether the subquery can return NULL. NOT EXISTS checks whether the correlated subquery returns no rows, so it is unaffected by NULL. This is a "time bomb" that makes every row disappear the moment NULL enters the data.
Problem

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.

Tables used
► employees (6 rows)
emp_idnamedept_id
1Tanaka10
2Suzuki20
3Sato30
4Ito40
5YamadaNULL
6Takahashi20
► managed_depts (3 rows)
dept_iddept_name
10Sales
30HR
NULLUndefined

Note: managed_depts.dept_id contains NULL (an undefined department). Writing NOT IN (SELECT dept_id FROM managed_depts) lets NULL enter the list and makes all rows disappear. Use NOT EXISTS for a NULL-safe result.

Expected Output

Expected output (emp_id ascending):

emp_idnamedept_id
2Suzuki20
4Ito40
5YamadaNULL
6Takahashi20

Note: dept_id=10 (Tanaka) and dept_id=30 (Sato) are managed departments and are excluded. With NOT IN, the NULL in managed_depts makes UNKNOWN exclude all 6 rows, producing 0 rows.

QUESTION 7
The Aggregate Function NULL Trap — COUNT(*) / COUNT(column) / AVG silently shrink the denominator
COUNT / AVGAggregate NULL exclusionShrinking denominatorCOALESCE correction
Background

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))
AVG's "invisible denominator shrinkage": When AVG excludes amount=NULL (no result), the average for representatives with results is reported as the average for all representatives. If NULL means "0 (no result)," correct it with COALESCE and use true_avg; if NULL means "missing (cannot be aggregated)," keep AVG as-is or display the count explicitly.
Problem

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.

Tables used
► sales_results (8 rows)
rep_iddeptamount
1Tokyo250000
2Tokyo300000
3TokyoNULL
4TokyoNULL
5Osaka150000
6Osaka200000
7Osaka180000
8OsakaNULL

Note: rep_id=3,4 (Tokyo) and rep_id=8 (Osaka) have amount=NULL (no results this month). COUNT(*) counts everyone, while COUNT(amount) counts only representatives with results. Pay attention to how far avg_amount and true_avg diverge.

Expected Output

Expected output (dept ascending):

depthead_countactive_repsavg_amounttrue_avg
Osaka43176667132500
Tokyo42275000137500

Note: Osaka: SUM=530000, AVG (denominator 3)=176667, true_avg (denominator 4)=132500. Tokyo: SUM=550000, AVG (denominator 2)=275000, true_avg (denominator 4)=137500. The gap between avg_amount and true_avg shows the effect of excluding NULL.

QUESTION 8
LEFT JOIN ON-Clause Filters — Preserve non-matching rows as NULL
LEFT JOINON-clause filterCondition evaluation during joinPreserve non-matching rows as NULL
Background

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
When the ON clause is evaluated: The ON clause is evaluated during the join. Rows that do not satisfy the condition are not joined, and the left-table row is retained with NULLs on the right. To filter right-table rows while displaying non-matching rows as NULL, put the condition in the ON clause.
Problem

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.

Tables used
► customers (4 rows)
customer_idcustomer_name
1Tanaka Trading
2Suzuki Trading
3Sato Trading
4New Customer
► orders (7 rows)
order_idcustomer_idamount
1110000
2135000
3140000
4228000
528000
635000
7312000

Note: customer 4 (New Customer) has no orders. Customer 3 (Sato Trading) has orders, but none above 20,000 yen. Put the filter condition in the ON clause to preserve customers 3 and 4 in the result.

Expected Output

Expected output (customer_id ascending):

customer_idcustomer_namelarge_order
1Tanaka Trading40000
2Suzuki Trading28000
3Sato TradingNULL
4New CustomerNULL

The ON-clause filter preserves non-matching customers 3 and 4 as NULL, so all 4 customers are correctly returned.

QUESTION 9
Turn Division by Zero into NULL with NULLIF — Use NULL as a Defensive Tool
NULLIFPrevent division by zeroUse NULL as a valid resultCombining with COALESCE
Background

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)
Use NULL as a tool: NULL means "unknown," but NULLIF can actively convert zero to NULL to safely skip division by zero. This intentionally uses NULL propagation so that the result becomes NULL rather than causing an error.
Problem

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.

Tables used
► campaign_daily (6 rows)
datecampaignclicksconversionsrevenue
2024-03-01CP_A100050200000
2024-03-01CP_B000
2024-03-02CP_A80032160000
2024-03-02CP_B50020100000
2024-03-03CP_A000
2024-03-03CP_B60024120000

Note: CP_B (2024-03-01) and CP_A (2024-03-03) have clicks=0 (days with no delivery). Because dividing by 0 raises an error, convert zero to NULL with NULLIF(clicks, 0) before division. Round conv_rate and rev_per_click to 4 decimal places.

Expected Output

Expected output (date and campaign ascending):

datecampaignclicksconv_raterev_per_click
2024-03-01CP_A10000.0500200.0000
2024-03-01CP_B0NULLNULL
2024-03-02CP_A8000.0400200.0000
2024-03-02CP_B5000.0400200.0000
2024-03-03CP_A0NULLNULL
2024-03-03CP_B6000.0400200.0000
QUESTION 10
Window Functions and NULL Propagation — Boundary NULL in LAG and chained NULLs in month-over-month growth
LAG / window functionsNULL propagation chainCTE patternMonth-over-month / time-series analysis
Background

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)
Defend against both kinds of NULL: The third argument of 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.
Problem

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.

Tables used
► monthly_revenue (6 rows)
monthrevenue
2024-011000000
2024-021200000
2024-03NULL
2024-041100000
2024-051400000
2024-061350000

Note: treat revenue=NULL in 2024-03 (data not collected) as 0. For the first month, such as 2024-01, and when the previous month is 0 (2024-04), use NULLIF to safely output NULL instead of raising a division-by-zero error.

Expected Output

Expected output (month ascending):

monthrevenueprev_revenuemom_growth
2024-0110000000NULL
2024-02120000010000000.2000
2024-0301200000-1.0000
2024-0411000000NULL
2024-05140000011000000.2727
2024-0613500001400000-0.0357

Note: in 2024-03, revenue becomes 0 and month-over-month growth is -1.0000 (-100%). In 2024-04, prev_revenue is 0, so division by zero is avoided and mom_growth is NULL.