NULL — Learn Propagation, GROUP BY, CASE WHEN, Sort Order, and FULL OUTER JOIN from the Basics
BasicNULL BasicsNULL PropagationGROUP BY and NULLNULLS FIRST / LASTFULL OUTER JOINPostgreSQL-ready5 questions
QUESTION 6
NULL Arithmetic and String Propagation — NULL 'infects' expressions and quietly erases the result
NULL PropagationArithmeticString Concatenation ||COALESCE Correction
Background

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 is a contagious value: An expression containing NULL becomes NULL as a whole. 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.
Problem

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.

Tables used
► employee_pay (6 rows)
emp_idnamebase_salarybonusdept
1Tanaka30000050000Sales
2Suzuki250000NULLEngineering
3Sato40000080000NULL
4Ito280000NULLSales
5Yamada35000030000Engineering
6Takahashi320000NULLNULL

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

Expected output (emp_id ascending):

emp_idnametotal_paylabel
1Tanaka350000Sales: Tanaka
2Suzuki250000Engineering: Suzuki
3Sato480000Unassigned: Sato
4Ito280000Sales: Ito
5Yamada380000Engineering: Yamada
6Takahashi320000Unassigned: Takahashi
Model Answer
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
*/
Explanation (table transitions & key points)
SELECT emp_id, name, base_salary + COALESCE(bonus, 0) AS total_pay, COALESCE(dept, 'Unassigned') || ': ' || name AS label FROM employee_pay ORDER BY emp_id;
LEGEND
Rows read / loaded
① 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.
1 / 4
emp_idnamebase_salarybonusdept
1Tanaka30000050000Sales
2Suzuki250000NULLEngineering
3Sato40000080000NULL
4Ito280000NULLSales
5Yamada35000030000Engineering
6Takahashi320000NULLNULL
6 rows read
LEARNING POINTS
NULL “infects” arithmetic and string concatenation, making the entire expression NULL: Every arithmetic operation (+, −, ×, ÷) and string concatenation (||) with NULL returns NULL without exception. 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).”
“Correct with COALESCE before the operation” is the basic pattern: For a column that may propagate NULL, convert it safely with 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.
PostgreSQL's || operator and the CONCAT function handle NULL differently: PostgreSQL's || 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 ||.
ANTI-PATTERNS
Assume that 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.
Assume 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.
Practical column: The “early NULL correction” pattern in data pipelines
NULL-propagation bugs progress quietly. If 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.
QUESTION 7
GROUP BY and NULL Groups — NULL values are aggregated as one “peer group”
GROUP BY NULLSUM + GROUP BYNULL GroupingCOALESCE Label Conversion
Background

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
WHERE and GROUP BY treat NULL differently: A NULL comparison in WHERE is UNKNOWN → the row is excluded. NULLs in GROUP BY are aggregated into one group (a special case). Unless WHERE removes NULL first, the NULL group appears in the aggregate result. Assigning a label with COALESCE is important for making this group visible.
Problem

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.

Tables used
► page_views (9 rows)
view_idpagedeviceviews
1topmobile500
2topNULL200
3toppc300
4topmobile400
5aboutpc100
6aboutNULL150
7aboutmobile200
8topNULL50
9aboutpc80

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

Expected output (page ascending, then device_label ascending):

pagedevice_labeltotal_views
aboutmobile200
aboutpc180
aboutUnknown150
topmobile900
toppc300
topUnknown250

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.

Model Answer
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
*/
Explanation (table transitions & key points)
SELECT page, COALESCE(device, 'Unknown') AS device_label, SUM(views) AS total_views FROM page_views GROUP BY page, device ORDER BY page, device_label;
LEGEND
Rows read / loaded
① 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.
1 / 5
view_idpagedeviceviews
1topmobile500
2topNULL200
3toppc300
4topmobile400
5aboutpc100
6aboutNULL150
7aboutmobile200
8topNULL50
9aboutpc80
9 rows read
LEARNING POINTS
GROUP BY aggregates NULL into one group, unlike WHERE: 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 can select or exclude the NULL group: After GROUP BY, use 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.
The GROUP BY column and SELECT's COALESCE work independently: Group with 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.
ANTI-PATTERNS
Miss the difference between 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.
Expose a NULL label to the dashboard by outputting the GROUP BY NULL group without COALESCE: If you forget 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.
Practical column: Making the “Unknown” group explicit to improve analysis quality
Explicitly including data whose device or region is NULL in an “Unknown” group makes data quality visible. For example, learning that “12% of page_views have an unknown device” can prompt improvements to user-agent collection. Excluding the NULL group with 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.
QUESTION 8
CASE WHEN and NULL Traps — NULL slips through every WHEN and falls into ELSE
CASE WHENIS NULL CheckNULL Falling to ELSEDefensive Branch Design
Background

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
Omitting ELSE returns NULL: If none of the WHEN conditions is TRUE and there is no ELSE, the CASE expression returns NULL. Always write ELSE explicitly to prevent NULL misclassification bugs.
Problem

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.

Tables used
► exam_results (7 rows)
student_idnamescore
1Tanaka85
2Suzuki42
3SatoNULL
4Ito70
5Yamada55
6TakahashiNULL
7Nakamura60

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

Expected output (student_id ascending):

student_idnamescoregrade
1Tanaka85A
2Suzuki42D
3SatoNULLAbsent
4Ito70B
5Yamada55D
6TakahashiNULLAbsent
7Nakamura60C
Model Answer
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
*/
Explanation (table transitions & key points)
SELECT student_id, name, score, CASE WHEN score IS NULL THEN 'Absent' WHEN score >= 80 THEN 'A' WHEN score >= 70 THEN 'B' WHEN score >= 60 THEN 'C' ELSE 'D' END AS grade FROM exam_results ORDER BY student_id;
LEGEND
Rows read / loaded
① 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.
1 / 4
student_idnamescore
1Tanaka85
2Suzuki42
3SatoNULL
4Ito70
5Yamada55
6TakahashiNULL
7Nakamura60
7 rows read
LEARNING POINTS
CASE WHEN evaluates top to bottom, and NULL slips through every comparison WHEN: CASE WHEN evaluates conditions in order and finalizes the result at the first TRUE WHEN. Comparisons with NULL (>=, <=, =) become UNKNOWN and that WHEN is skipped. ELSE receives rows for which no WHEN was TRUE, and omitting ELSE returns NULL. NULL does not mean “the condition is false”; it means “the condition cannot be evaluated,” which causes an unintended fall through to ELSE.
Put the NULL check (IS NULL) first in CASE WHEN: Put 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.
Omitting ELSE returns NULL for rows that satisfy no condition, including NULL rows: With no ELSE, as in 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.
ANTI-PATTERNS
Write a range-based CASE WHEN without an IS NULL check: 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.
Omit ELSE when combining CASE with an aggregate function: If you write 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.
Practical column: A NULL-defense checklist for CASE WHEN
In analytical SQL, defensive handling of NULL produces reliable reports. Checklist: ① Before writing a range-based CASE WHEN, ask whether the column can be NULL. ② If it can, add 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.
QUESTION 9
ORDER BY and NULL Sort Order — The trap where DESC puts NULL first
ORDER BY NULLNULLS FIRSTNULLS LASTDBMS Sort-Order Differences
Background

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
NULL sorting in MySQL / SQL Server: NULL is treated as the smallest value, so it comes first in ASC and last in DESC. This is the opposite of PostgreSQL (ASC=last, DESC=first), so take care when migrating across DBMSs. BigQuery and Snowflake use the same rules as PostgreSQL.
Problem

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.

Tables used
► products (7 rows)
product_idnamelast_sold_at
1Product A2024-03-15
2Product B2024-01-20
3Product CNULL
4Product D2024-03-20
5Product ENULL
6Product F2024-02-10
7Product GNULL

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

Expected output (most recently sold first, unsold products last):

product_idnamelast_sold_at
4Product D2024-03-20
1Product A2024-03-15
6Product F2024-02-10
2Product B2024-01-20
3Product CNULL
5Product ENULL
7Product GNULL

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.

Model Answer
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
*/
Explanation (table transitions & key points)
SELECT product_id, name, last_sold_at FROM products ORDER BY last_sold_at DESC NULLS LAST;
LEGEND
Rows read / loaded
① 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.
1 / 4
product_idnamelast_sold_at
1Product A2024-03-15
2Product B2024-01-20
3Product CNULL
4Product D2024-03-20
5Product ENULL
6Product F2024-02-10
7Product GNULL
7 rows read
LEARNING POINTS
PostgreSQL's NULL sort rules: ASC defaults to NULLS LAST and DESC to NULLS FIRST: In ASC (ascending), NULL comes last by default; in DESC (descending), NULL comes first by default. This is the root cause of a common production bug: you ask for recently sold products first (DESC), but unsold products (NULL) appear at the top.
Use 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.
Portable NULL sorting with COALESCE: Convert NULL to a sentinel value and push it to the end with an expression such as 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.
ANTI-PATTERNS
Sort a dashboard with 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.
Write 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.
Practical column: NULL sort-order differences between DBMSs and portability
PostgreSQL (ASC=NULLS LAST, DESC=NULLS FIRST) and MySQL / SQL Server (NULL is treated as the smallest value, so it comes first in ASC and last in DESC) sort NULL in opposite ways. BigQuery and Snowflake use PostgreSQL's rules (ASC=NULLS LAST, DESC=NULLS FIRST). When moving between DBMSs or to a cloud data warehouse, the difference in NULL sorting can appear as a changed row order in aggregate reports. Teams should consistently decide whether to specify NULLS FIRST / NULLS LAST or correct the value with COALESCE.
QUESTION 10
FULL OUTER JOIN and NULL — Detect missing data in both tables at once
FULL OUTER JOINCOALESCEBidirectional Missing-Data DetectionData-Quality Check
Background

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)
LEFT / RIGHT / FULL OUTER JOIN compared: LEFT JOIN detects “rows missing on the right” (learned in Q5). FULL OUTER JOIN detects “rows that exist on only one side” in both directions at once. It is powerful for budget-vs-actual reconciliation, master-data matching, and data-quality checks.
Problem

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.

Tables used
► expected_revenue (5 rows)
monthexpected
2024-01500000
2024-02600000
2024-03550000
2024-04700000
2024-05650000
► actual_revenue (5 rows)
monthactual
2024-01480000
2024-02620000
2024-03590000
2024-04710000
2024-06430000

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

Expected output (month ascending):

monthexpectedactualstatus
2024-01500000480000Normal
2024-02600000620000Normal
2024-03550000590000Normal
2024-04700000710000Normal
2024-05650000NULLActual missing
2024-06NULL430000Budget missing
Model Answer
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
*/
Explanation (table transitions & key points)
SELECT COALESCE(e.month, a.month) AS month, e.expected, a.actual, CASE WHEN e.month IS NULL THEN 'Budget missing' WHEN a.month IS NULL THEN 'Actual missing' ELSE 'Normal' END AS status FROM expected_revenue e FULL OUTER JOIN actual_revenue a ON e.month = a.month ORDER BY month;
LEGEND
Rows read / loaded
① 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.
1 / 5
e.month (expected)e.expecteda.month (actual)a.actual
2024-015000002024-01480000
2024-026000002024-02620000
2024-035500002024-03590000
2024-047000002024-04710000
2024-05650000(none)(none)
(none)(none)2024-06430000
expected: 5 rows / actual: 5 rows (2024-05 expected only, 2024-06 actual only)
LEARNING POINTS
FULL OUTER JOIN = LEFT JOIN + RIGHT JOIN: It returns every row that exists only in the left table (NULL on the right), every row that exists only in the right table (NULL on the left), and every row present in both. LEFT JOIN detects “left rows missing from the right” (learned in Q5), while FULL OUTER JOIN detects “rows that exist on only one side” in both directions at once. It is powerful for data-quality checks, master-data matching, and budget-vs-actual comparisons.
When one month's side is NULL, use 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.
Classify the direction of missing data with 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.
ANTI-PATTERNS
Lose data by using INNER JOIN: INNER JOIN removes months that exist in only one table (2024-05 and 2024-06) from the result. The information that “there is no data for this month” is itself important, but INNER JOIN makes it invisible. Always use FULL OUTER JOIN for data-quality checks.
Forget 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.
Practical column: FULL OUTER JOIN in practice and the dbt budget-vs-actual reconciliation pattern
Reconciling monthly budgets (the budget table) with actuals (the actual table) is one of the most common requests for a data analytics team. FULL OUTER JOIN detects both “a month with a budget but no actuals” and “a month with actuals but no budget (a budget-management omission)” at once. In dbt, implementing FULL OUTER JOIN as a budget-vs-actual bridge model in the mart layer and adding a status column lets data engineers detect upstream gaps quickly. The combination of COALESCE + IS NULL is also useful for checking consistency between master and fact tables, such as detecting transactions for products absent from the product master.