NULL — Learn GROUP BY, NOT IN Traps, FILTER Aggregation, and Multiple LEFT JOINs in Practice
AdvancedAdvanced NULLNOT IN Traps / NOT EXISTSFILTER AggregationON vs WHERELAG / LEAD and NULLPostgreSQL-ready5 questions
QUESTION 1
GROUP BY and NULL — NULL is treated as one 'peer group'
GROUP BY + NULLCOALESCE + GROUP BYNULL GroupingCOUNT DISTINCT and NULL
Background

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 col) and NULL: 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.
Problem

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.

Tables used
► sales (6 rows)
sale_idregionamount
1Tokyo15000
2Osaka8000
3Tokyo12000
4NULL5000
5Osaka9000
6NULL7000

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

Expected output (total_amount descending):

regiontotal_amountsale_count
Tokyo270002
Osaka170002
Unknown120002

Tokyo: 15000+12000=27000 / Osaka: 8000+9000=17000 / Unknown (NULL): 5000+7000=12000.

QUESTION 2
Window Functions and NULL — Distinguish 'boundary NULL' from 'data NULL' in LAG
LAG / LEADNULL PropagationWindow FunctionsNULLS FIRST/LAST
Background

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)
NULL propagation: An arithmetic operation containing NULL always returns NULL. In 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.
Problem

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.

Tables used
► daily_sales (7 rows)
dtamount
2024-01-0110000
2024-01-0212000
2024-01-03NULL
2024-01-049000
2024-01-0511000
2024-01-06NULL
2024-01-0713000

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

Expected output (dt ascending):

dtamountprev_amountgrowth_rate
2024-01-0110000NULLNULL
2024-01-02120001000020.0
2024-01-03NULL12000NULL
2024-01-049000NULLNULL
2024-01-0511000900022.2
2024-01-06NULL11000NULL
2024-01-0713000NULLNULL
QUESTION 3
The NOT IN NULL Trap — One NULL can make every row 'quietly disappear'
NOT IN and NULLNOT EXISTSSubquery NULL TrapAnti-Join Patterns
Background

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)
A NULL in a NOT IN list is a 'time bomb' that makes every row UNKNOWN: In a subquery such as NOT IN (SELECT col FROM ...), one NULL in the subquery column excludes every row. NOT EXISTS is the recommended NULL-safe alternative.
Problem

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.

Tables used
► candidates (5 rows)
candidate_idnamedept_id
1Tanaka10
2Suzuki20
3SatoNULL
4Ito10
5Yamada30
► blocked_dept_ids (2 rows)
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

Expected output (candidate_id ascending):

candidate_idnamedept_id
2Suzuki20
3SatoNULL
5Yamada30

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.

QUESTION 4
FILTER and CASE WHEN Aggregation — A practical pattern for conditional NULL aggregation
FILTER AggregationCASE WHEN + COUNT/AVGConditional AggregationNULL and AVG (Empty Groups)
Background

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
When the aggregate result is 0 versus NULL: 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.
Problem

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.

Tables used
► app_events (8 rows)
event_iduser_idevent_typeduration_sec
1U1view15
2U1clickNULL
3U1purchase45
4U2view20
5U2clickNULL
6U2clickNULL
7U3purchase60
8U3view10

Note: click events have NULL duration_sec (not measured). U2 has no purchase events, so avg_purchase_sec = NULL.

Expected Output

Expected output (user_id ascending):

user_idview_countclick_countpurchase_countavg_purchase_sec
U111145.0
U2120NULL
U310160.0
QUESTION 5
Multiple-Condition LEFT JOIN — Results change depending on filters in ON versus WHERE
ON vs WHERECASE WHEN AggregationAdvanced LEFT JOINSUM=NULL vs COUNT=0
Background

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 NULL versus COUNT NULL: When every CASE result in a group is NULL, SUM returns NULL while COUNT returns 0. SUM needs COALESCE to convert NULL to 0, while COUNT does not.
Problem

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.

Tables used
► members (5 rows)
member_idname
1Tanaka
2Suzuki
3Sato
4Ito
5Yamada
► orders (8 rows)
order_idmember_idstatusamount
11completed5000
21cancelled2000
32completed8000
42completed3000
53pending1500
63cancelled1000
74completed6000
81pending4000

Note: member_id=5 (Yamada) has no orders. Sato has no completed order (only pending and cancelled).

Expected Output

Expected output (member_id ascending):

member_idnamecompleted_amountcancelled_count
1Tanaka50001
2Suzuki110000
3Sato01
4Ito60000
5Yamada00

Sato: no completed order → SUM=NULL → COALESCE → 0. Yamada: no orders → every joined order column is NULL → COALESCE → 0.