In SQL, every arithmetic operation involving NULL (+, −, ×, ÷) and string concatenation (the || operator) returns NULL without exception. This is called NULL propagation.
-- Arithmetic: every operation with NULL becomes NULL NULL + 100 -- NULL (it disappears even when added) NULL * 0 -- NULL (multiplying by zero does not clear it!) base_salary + NULL -- NULL (it propagates even when the other side has a value) -- String concatenation (|| operator): NULL makes the whole expression NULL 'Sales' || NULL -- NULL (PostgreSQL: || propagates NULL) -- The CONCAT function treats NULL as an empty string (PostgreSQL/MySQL) CONCAT(NULL, 'suffix') -- 'suffix'
NULL * 0 = NULL (multiplying by zero does not produce zero!) is especially counterintuitive. The rule of thumb is to correct a value with COALESCE(col, default) before performing the operation.From the employee_pay table, calculate effective pay (total_pay = base_salary + bonus, adding bonus as 0 when it is NULL) and a display label (label = dept || ': ' || name, concatenating dept as 'Unassigned' when it is NULL). Return the columns emp_id, name, total_pay, label in ascending emp_id order.
| emp_id | name | base_salary | bonus | dept |
|---|---|---|---|---|
| 1 | Tanaka | 300000 | 50000 | Sales |
| 2 | Suzuki | 250000 | NULL | Engineering |
| 3 | Sato | 400000 | 80000 | NULL |
| 4 | Ito | 280000 | NULL | Sales |
| 5 | Yamada | 350000 | 30000 | Engineering |
| 6 | Takahashi | 320000 | NULL | NULL |
Note: emp_id=2,4,6 have bonus=NULL (no bonus recorded). emp_id=3,6 have dept=NULL (not assigned to a department). Keep in mind what happens when an expression is evaluated without COALESCE.
Expected output (emp_id ascending):
| emp_id | name | total_pay | label |
|---|---|---|---|
| 1 | Tanaka | 350000 | Sales: Tanaka |
| 2 | Suzuki | 250000 | Engineering: Suzuki |
| 3 | Sato | 480000 | Unassigned: Sato |
| 4 | Ito | 280000 | Sales: Ito |
| 5 | Yamada | 380000 | Engineering: Yamada |
| 6 | Takahashi | 320000 | Unassigned: Takahashi |
SELECT emp_id, name, base_salary + COALESCE(bonus, 0) AS total_pay, -- correct NULL to 0 before adding COALESCE(dept, 'Unassigned') || ': ' || name AS label -- correct NULL to 'Unassigned' before concatenating FROM employee_pay ORDER BY emp_id; /* Execution order (SQL's logical evaluation order): 1. FROM employee_pay → read the rows 2. SELECT → evaluate the columns (total_pay, label) 3. ORDER BY emp_id → sort and output */
LEGEND
① FROM employee_pay (6 rows)
FROM employee_payRead the 6 rows from employee_pay. bonus is NULL for emp_id=2,4,6 and dept is NULL for emp_id=3,6. If these values are used directly in arithmetic or string concatenation, NULL propagates through the entire expression.| emp_id | name | base_salary | bonus | dept |
|---|---|---|---|---|
| 1 | Tanaka | 300000 | 50000 | Sales |
| 2 | Suzuki | 250000 | NULL | Engineering |
| 3 | Sato | 400000 | 80000 | NULL |
| 4 | Ito | 280000 | NULL | Sales |
| 5 | Yamada | 350000 | 30000 | Engineering |
| 6 | Takahashi | 320000 | NULL | NULL |
NULL * 0 = NULL (multiplying by zero does not produce zero!), 100 - NULL = NULL, and 'prefix' || NULL = NULL are all results of NULL propagation. Because NULL means “unknown value,” an operation involving an unknown value is also “unknown (NULL).”COALESCE(col, default) before performing the operation. base_salary + COALESCE(bonus, 0) adds bonus as 0 when it is NULL and prevents total_pay from becoming NULL. This “resolve NULL at the entrance” pattern is a data-quality rule of thumb.|| propagates NULL, while CONCAT(a, b) treats a NULL argument as an empty string and concatenates it. CONCAT's NULL behavior can differ subtly across DBMSs, so when portability matters, the safest approach is to correct values with COALESCE first and then use ||.SUM(base_salary + bonus) treats bonus=NULL rows as 0: base_salary + NULL becomes NULL, and SUM excludes it from the aggregation (the NULL-ignoring behavior of aggregate functions learned in Q2 makes the value disappear twice). The correct expression is SUM(base_salary + COALESCE(bonus, 0)). A NULL-propagation bug is one of the quietest ways to make an aggregate result too small.NULL * 0 = 0: If you try to turn a score with no recorded result (NULL) into 0 by calculating score * weight, the result is NULL whenever score is NULL. Multiplying by zero does not clear NULL. Use COALESCE(score, 0) * weight or CASE WHEN score IS NULL THEN 0 ELSE score * weight END.bonus_revenue is NULL for only certain months in the calculation monthly revenue = base_revenue + bonus_revenue, the issue cascades into cumulative totals, month-over-month comparisons, and charts. In dbt, a recommended early NULL correction pattern applies COALESCE(bonus_revenue, 0) AS bonus_revenue in a staging model so downstream models do not have to think about NULL. To find NULL-propagation bugs, COUNT(*) - COUNT(computed_col) is an effective way to count rows where the computed column became NULL.In a WHERE clause, col = NULL evaluates to UNKNOWN and rows are excluded (as learned in Q1), but GROUP BY puts rows with NULL in the same group. This is a special behavior defined by the SQL standard.
-- GROUP BY special case: NULL = NULL is UNKNOWN in equality comparison, but -- GROUP BY aggregates NULL values into one group SELECT device, COUNT(*) FROM page_views GROUP BY device; -- rows where device = NULL are collected into one group and counted -- HAVING can select or exclude the NULL group HAVING device IS NULL -- select only the NULL group HAVING device IS NOT NULL -- exclude the NULL group
From the page_views table, aggregate total views for each page and device. Display rows where device is NULL as 'Unknown' (using COALESCE), and return page, device_label, total_views in ascending page order, then ascending device_label order.
| view_id | page | device | views |
|---|---|---|---|
| 1 | top | mobile | 500 |
| 2 | top | NULL | 200 |
| 3 | top | pc | 300 |
| 4 | top | mobile | 400 |
| 5 | about | pc | 100 |
| 6 | about | NULL | 150 |
| 7 | about | mobile | 200 |
| 8 | top | NULL | 50 |
| 9 | about | pc | 80 |
Note: rows with device=NULL (view_id=2,6,8) are accesses from an unknown device. Note that GROUP BY device puts the NULL values into one group.
Expected output (page ascending, then device_label ascending):
| page | device_label | total_views |
|---|---|---|
| about | mobile | 200 |
| about | pc | 180 |
| about | Unknown | 150 |
| top | mobile | 900 |
| top | pc | 300 |
| top | Unknown | 250 |
about/pc: 100+80=180. top/mobile: 500+400=900. top/NULL: 200+50=250. GROUP BY uses the original device column for grouping; SELECT's COALESCE only converts the display label.
SELECT page, COALESCE(device, 'Unknown') AS device_label, -- convert to a display label (GROUP BY uses the original column) SUM(views) AS total_views FROM page_views GROUP BY page, device -- aggregate NULL as one group (special behavior) ORDER BY page, device_label; /* Execution order (SQL's logical evaluation order): 1. FROM page_views → read the rows 2. GROUP BY page, device → group the rows 3. SELECT → aggregate and assign labels (SUM, COALESCE) 4. ORDER BY page, device_label → sort and output */
LEGEND
① FROM page_views (9 rows)
FROM page_viewsRead the 9 rows from page_views. Rows where device is NULL (view_id=2,6,8) are accesses from an unknown device. How GROUP BY handles these rows is the key point of this question.| view_id | page | device | views |
|---|---|---|---|
| 1 | top | mobile | 500 |
| 2 | top | NULL | 200 |
| 3 | top | pc | 300 |
| 4 | top | mobile | 400 |
| 5 | about | pc | 100 |
| 6 | about | NULL | 150 |
| 7 | about | mobile | 200 |
| 8 | top | NULL | 50 |
| 9 | about | pc | 80 |
col = NULL in WHERE is UNKNOWN → the row is excluded, but GROUP BY puts rows with NULL into the same group. This is a special SQL-standard behavior: although NULL ≠ NULL in equality comparison, NULL values are aggregated into one group by GROUP BY. Remember that WHERE and GROUP BY handle NULL differently.HAVING device IS NULL to select only the NULL group, or HAVING device IS NOT NULL to exclude it. When you want to treat the NULL group as “Unknown,” use COALESCE only for display in SELECT and group by the original device column.GROUP BY device, then convert the display label with COALESCE(device, 'Unknown') AS device_label in SELECT. If you use GROUP BY COALESCE(device, 'Unknown'), rows where device is NULL may be grouped together with rows whose device is the literal string 'Unknown', causing an unintended aggregation. Keep grouping and label conversion separate.GROUP BY COALESCE(device, 'Unknown') and GROUP BY device: The first expression makes COALESCE(device, 'Unknown') the grouping key, so NULL rows and rows whose device is the literal string 'Unknown' are aggregated together when such data exists. Unless that is intentional, group by the original column and use COALESCE only for the display conversion in SELECT.COALESCE(device, 'Unknown') in SELECT, the NULL group remains NULL in the output. BI tools may display NULL as blank or as an error, confusing end users. Always assign a label with COALESCE or CASE WHEN IS NULL.HAVING device IS NOT NULL hides the problem. From an analysis-quality perspective, keep the NULL group visible, include it in total_views, and monitor the scale of missing data regularly.CASE WHEN evaluates conditions from top to bottom and finalizes the result at the first WHEN that is TRUE. Comparisons with NULL (>=, <=, =) evaluate to UNKNOWN, so that WHEN is skipped and evaluation moves to the next WHEN. As a result, NULL slips through every WHEN and falls into ELSE.
-- Evaluation of each WHEN when score is NULL CASE WHEN score >= 80 THEN 'A' -- NULL >= 80 → UNKNOWN → skipped WHEN score >= 60 THEN 'C' -- NULL >= 60 → UNKNOWN → skipped ELSE 'D' -- NULL falls here (an absent student becomes 'D'!) END -- Correct pattern: put the IS NULL check in the first WHEN CASE WHEN score IS NULL THEN 'Absent' -- IS NULL returns a definite TRUE/FALSE WHEN score >= 80 THEN 'A' END
From the exam_results table, assign a grade (A/B/C/D/Absent) according to the score.
score >= 80: 'A' / 70 ≤ score < 80: 'B' / 60 ≤ score < 70: 'C' / score < 60: 'D' / score is NULL: 'Absent'
Return student_id, name, score, grade in ascending student_id order.
| student_id | name | score |
|---|---|---|
| 1 | Tanaka | 85 |
| 2 | Suzuki | 42 |
| 3 | Sato | NULL |
| 4 | Ito | 70 |
| 5 | Yamada | 55 |
| 6 | Takahashi | NULL |
| 7 | Nakamura | 60 |
Note: student_id=3,6 have score=NULL (absent students). If CASE WHEN starts with score >= 80 without an IS NULL check, NULL rows fall into ELSE and are incorrectly classified as 'D'.
Expected output (student_id ascending):
| student_id | name | score | grade |
|---|---|---|---|
| 1 | Tanaka | 85 | A |
| 2 | Suzuki | 42 | D |
| 3 | Sato | NULL | Absent |
| 4 | Ito | 70 | B |
| 5 | Yamada | 55 | D |
| 6 | Takahashi | NULL | Absent |
| 7 | Nakamura | 60 | C |
SELECT student_id, name, score, CASE WHEN score IS NULL THEN 'Absent' -- check NULL first (important) WHEN score >= 80 THEN 'A' WHEN score >= 70 THEN 'B' WHEN score >= 60 THEN 'C' ELSE 'D' -- all rows with score < 60 (NULL already caught above) END AS grade FROM exam_results ORDER BY student_id; /* Execution order (SQL's logical evaluation order): 1. FROM exam_results → read the rows 2. SELECT → evaluate the columns (classify grade with CASE WHEN) 3. ORDER BY student_id → sort and output */
LEGEND
① FROM exam_results (7 rows)
FROM exam_resultsRead the 7 rows from exam_results. score is NULL for student_id=3,6 (absent students). The position of the IS NULL check is the key to classifying them correctly with CASE WHEN.| student_id | name | score |
|---|---|---|
| 1 | Tanaka | 85 |
| 2 | Suzuki | 42 |
| 3 | Sato | NULL |
| 4 | Ito | 70 |
| 5 | Yamada | 55 |
| 6 | Takahashi | NULL |
| 7 | Nakamura | 60 |
WHEN score IS NULL THEN 'Absent' in the first WHEN to catch NULL rows reliably. IS NULL returns a definite TRUE/FALSE rather than UNKNOWN, so the first WHEN catches the rows deterministically. It works immediately before ELSE as well, but placing it first is clearer because it explicitly marks NULL as a special case.CASE WHEN score >= 80 THEN 'A' WHEN score >= 60 THEN 'C' END, rows with score=NULL or with no matching condition return NULL. Always remember that CASE ... END returns NULL by default and write ELSE explicitly.CASE WHEN score >= 60 THEN 'Pass' ELSE 'Fail' END produces NULL >= 60 → UNKNOWN → WHEN skipped → ELSE → 'Fail' when score is NULL. This is a common bug in which absent students (NULL) are treated as failures and included in aggregates. For a column with a special NULL meaning, always add WHEN col IS NULL THEN '...' as the first WHEN.SUM(CASE WHEN category = 'A' THEN amount END) without ELSE, CASE returns NULL for non-A rows and rows where category is NULL, and SUM excludes those values. If excluded rows should count as 0, always write ELSE 0.WHEN col IS NULL THEN '...' as the first WHEN. ③ Always make ELSE explicit (avoid ELSE NULL). ④ Compare expected and actual output to verify how NULL rows are classified. Especially in KPI calculations and dashboard queries, misclassifying NULL can undermine trust in the numbers, so regular NULL-count checks should be part of monitoring.DBMSs differ in how ORDER BY sorts NULL. In PostgreSQL, the default for DESC sorting is NULLS FIRST (NULL first), so asking for “the most recent items first” can put NULL (not yet sold) at the top.
-- PostgreSQL default sort rules ORDER BY last_sold_at ASC -- NULLS LAST is the default (NULL last) ORDER BY last_sold_at DESC -- NULLS FIRST is the default (NULL first! ← trap) -- Explicit specification (PostgreSQL / Oracle / standard SQL) ORDER BY last_sold_at DESC NULLS LAST -- put NULL last ORDER BY last_sold_at ASC NULLS FIRST -- put NULL first -- MySQL / SQL Server do not support NULLS FIRST/LAST → alternative ORDER BY COALESCE(last_sold_at, '1900-01-01'::date) DESC
From the products table, display the most recently sold products first (last_sold_at DESC), with products that have not been sold yet (NULL) last. Return product_id, name, last_sold_at.
| product_id | name | last_sold_at |
|---|---|---|
| 1 | Product A | 2024-03-15 |
| 2 | Product B | 2024-01-20 |
| 3 | Product C | NULL |
| 4 | Product D | 2024-03-20 |
| 5 | Product E | NULL |
| 6 | Product F | 2024-02-10 |
| 7 | Product G | NULL |
Note: product_id=3,5,7 have last_sold_at=NULL (not yet sold). With only ORDER BY ... DESC, PostgreSQL's NULLS FIRST default puts these products at the top.
Expected output (most recently sold first, unsold products last):
| product_id | name | last_sold_at |
|---|---|---|
| 4 | Product D | 2024-03-20 |
| 1 | Product A | 2024-03-15 |
| 6 | Product F | 2024-02-10 |
| 2 | Product B | 2024-01-20 |
| 3 | Product C | NULL |
| 5 | Product E | NULL |
| 7 | Product G | NULL |
Note: NULL rows (product_id=3,5,7) are fixed at the end by NULLS LAST. The relative order of NULLs is undefined, so product_id order is not guaranteed.
SELECT product_id, name, last_sold_at FROM products ORDER BY last_sold_at DESC NULLS LAST; -- explicitly put NULL last /* Execution order (SQL's logical evaluation order): 1. FROM products → read the rows 2. SELECT → retrieve the columns 3. ORDER BY last_sold_at DESC NULLS LAST → sort and output */
LEGEND
① FROM products (7 rows)
FROM productsRead the 7 rows from products. last_sold_at is NULL for product_id=3,5,7 (not yet sold). Where these rows belong under ORDER BY DESC is the key point of this question.| product_id | name | last_sold_at |
|---|---|---|
| 1 | Product A | 2024-03-15 |
| 2 | Product B | 2024-01-20 |
| 3 | Product C | NULL |
| 4 | Product D | 2024-03-20 |
| 5 | Product E | NULL |
| 6 | Product F | 2024-02-10 |
| 7 | Product G | NULL |
NULLS FIRST / NULLS LAST to specify NULL's position explicitly: ORDER BY col DESC NULLS LAST fixes NULL at the end, while ORDER BY col ASC NULLS FIRST fixes NULL at the beginning. This is syntax from the SQL standard (ISO/IEC 9075) and is available in PostgreSQL, Oracle, BigQuery, and Snowflake. MySQL, SQL Server, and SQLite do not support NULLS FIRST/LAST, so use an alternative there.ORDER BY COALESCE(last_sold_at, '1900-01-01'::date) DESC. This works across DBMSs, but confirm that the sentinel cannot collide with real data and document the intent in a comment.ORDER BY date_col DESC and let NULL (data not collected) appear first: A weekly report may be sorted by “most recently updated,” yet rows with update_date=NULL (data not collected) appear at the top. Without knowing NULLS LAST, it is easy to “solve” the issue by using WHERE date_col IS NOT NULL, removing data that should have been shown instead of fixing the sort.NULLS LAST in MySQL or SQL Server and get a syntax error: Porting ORDER BY col DESC NULLS LAST from PostgreSQL to MySQL or SQL Server causes an error. For portability, use ORDER BY CASE WHEN col IS NULL THEN 1 ELSE 0 END, col DESC (push NULL to the end) or use a sentinel value with COALESCE.FULL OUTER JOIN combines LEFT JOIN and RIGHT JOIN and keeps every row from both tables. When one table has no matching row, the columns from the other side become NULL.
SELECT COALESCE(a.id, b.id) AS id, -- reliably get whichever value exists a.value AS val_a, b.value AS val_b FROM table_a a FULL OUTER JOIN table_b b ON a.id = b.id -- a.value IS NULL → a row that exists only in table_b (missing from a) -- b.value IS NULL → a row that exists only in table_a (missing from b)
FULL OUTER JOIN expected_revenue (budget) and actual_revenue (actuals), and output the monthly budget, actuals, and status ('Normal' / 'Actual missing' / 'Budget missing'). Return month, expected, actual, status in ascending month order.
| month | expected |
|---|---|
| 2024-01 | 500000 |
| 2024-02 | 600000 |
| 2024-03 | 550000 |
| 2024-04 | 700000 |
| 2024-05 | 650000 |
| month | actual |
|---|---|
| 2024-01 | 480000 |
| 2024-02 | 620000 |
| 2024-03 | 590000 |
| 2024-04 | 710000 |
| 2024-06 | 430000 |
Note: 2024-05 exists only in expected_revenue (actual missing). 2024-06 exists only in actual_revenue (budget missing). FULL OUTER JOIN detects both kinds of missing data at once.
Expected output (month ascending):
| month | expected | actual | status |
|---|---|---|---|
| 2024-01 | 500000 | 480000 | Normal |
| 2024-02 | 600000 | 620000 | Normal |
| 2024-03 | 550000 | 590000 | Normal |
| 2024-04 | 700000 | 710000 | Normal |
| 2024-05 | 650000 | NULL | Actual missing |
| 2024-06 | NULL | 430000 | Budget missing |
SELECT COALESCE(e.month, a.month) AS month, -- reliably get a month that exists on either side e.expected, a.actual, CASE WHEN e.month IS NULL THEN 'Budget missing' -- actual only → e.month is NULL WHEN a.month IS NULL THEN 'Actual missing' -- expected only → a.month is NULL ELSE 'Normal' END AS status FROM expected_revenue e FULL OUTER JOIN actual_revenue a ON e.month = a.month ORDER BY month; /* Execution order (SQL's logical evaluation order): 1. FROM expected_revenue e → read the left table 2. FULL OUTER JOIN actual_revenue a → join while keeping all rows 3. SELECT → evaluate the columns (month, status) 4. ORDER BY month → sort and output */
LEGEND
① FROM expected_revenue (5 rows) & actual_revenue (5 rows)
FROM expected_revenue e / actual_revenue aRead the 5 rows from expected_revenue as the left table. The 5 rows in actual_revenue match for 2024-01 through 2024-04; 2024-05 exists only in expected, and 2024-06 exists only in actual. FULL OUTER JOIN detects both kinds of missing data.| e.month (expected) | e.expected | a.month (actual) | a.actual |
|---|---|---|---|
| 2024-01 | 500000 | 2024-01 | 480000 |
| 2024-02 | 600000 | 2024-02 | 620000 |
| 2024-03 | 550000 | 2024-03 | 590000 |
| 2024-04 | 700000 | 2024-04 | 710000 |
| 2024-05 | 650000 | (none) | (none) |
| (none) | (none) | 2024-06 | 430000 |
COALESCE(e.month, a.month) to retrieve the month reliably: After FULL OUTER JOIN, e.month or a.month can be NULL, so COALESCE(e.month, a.month) retrieves whichever month exists. ORDER BY month (referencing the alias) then sorts by the COALESCE result and works correctly.e.month IS NULL / a.month IS NULL: e.month IS NULL means the row exists only on the right (actuals, budget missing); a.month IS NULL means it exists only on the left (expected, actuals missing). Adding a STATUS column with CASE WHEN makes data inconsistencies between the two tables immediately visible. This is a standard pattern for budget management, master-data matching, and data-quality dashboards.COALESCE(e.month, a.month) and leave ORDER BY with NULL: If you write only SELECT e.month after FULL OUTER JOIN, e.month is NULL for the right-only row (2024-06). ORDER BY e.month can put that row in an unintended position, and the month column itself is output as NULL. Unify the month column with COALESCE(e.month, a.month) AS month and reference that alias in later processing.