NULL Values: Intermediate
What a sentinel is
| Column | Sentinel seen in practice | How it got there |
|---|---|---|
| age (INTEGER) | 0 or -1 | required form field, column declared NOT NULL |
| price (DECIMAL) | 0.00 or 9999.99 | placeholder until the pricing team filled it in |
| signup_date (TIMESTAMP) | 1900-01-01 or 1970-01-01 | default of the source system when no date was known |
| country (VARCHAR) | 'N/A', 'unknown', 'none', '' | dropdown default, or a required field typed around |
| middle_name (VARCHAR) | '' or the text 'NULL' | CSV export wrote the word NULL for a missing value |
| quantity (INTEGER) | -1 or 999999 | out-of-range code meaning 'not applicable' |
The damage a fake zero does
Profiling a column to find its sentinels
A profile of discount_pct across 50,000 orders returns these counts: 0 has 31,200 rows, 5 has 8,100, 10 has 7,900, 15 has 2,750 and 100 has 50. Which value most needs a second look before you average the column?
Strings that pretend to be NULL
Text columns hide missing data behind placeholders: 'N/A', 'unknown', 'none', 'TBD', an empty string, or the 4-character word 'NULL' that a CSV export wrote when it met a real NULL. Every one of them is a value. carrier_code IS NULL is FALSE for each of them, and COUNT(carrier_code) counts each of them. The shipments table also carries a carrier code: 1 shipment has a true NULL there, and 3 others carry 'N/A'.
- ▸It cannot happen in the real world: a weight of -1, an age of 0, a price of 0.00 on a product that is for sale
- ▸It repeats far more than its neighbours in a GROUP BY count
- ▸It sits at the edge of its type or the calendar: 9999, -1, 1900-01-01, 1970-01-01
- ▸It reads like a placeholder: 'N/A', 'unknown', 'none', 'TBD', '' or the text 'NULL'
- ▸The source schema declares the column NOT NULL, so something had to be written when nothing was known
How a join pairs rows
A join reads 2 tables and pairs their rows. FROM orders o INNER JOIN customers c names the tables o and c for the rest of the query, and the ON clause gives the condition a pair has to satisfy. o.customer_id = c.customer_id is TRUE when both sides hold the same value, FALSE when they differ, and NULL when either side is NULL, because a comparison with NULL has no answer. The join keeps pairs whose condition is TRUE and nothing else. A NULL key therefore pairs with no row at all, including a NULL key on the other side: NULL = NULL is NULL, never TRUE.
INNER JOIN drops the row
SELECTo.order_id,o.customer_id,c.nameFROM orders AS oINNER JOIN customers AS cON o.customer_id = c.customer_id
The orders table has 100 rows; 10 have a NULL customer_id and each of the other 90 holds a key that appears exactly once in customers. The customers table has 50 rows, 1 of them with a NULL customer_id. How many rows does this query return?
LEFT JOIN keeps the row and marks the gap
SELECTe.emp_id,e.dept_id,d.dept_nameFROM employees AS eLEFT JOIN departments AS dON e.dept_id = d.dept_id
Telling the 2 NULLs apart
The CASE tests d.dept_id, the join key, in place of d.dept_name. A department row with a key but a NULL name would still be a match, and testing the name would label it unmatched. Test the key the ON clause compared, which every matched row carries.
The filter that undoes a LEFT JOIN
The first tab returns no rows. E-101 is in Sales and fails the test as it should. E-103 and E-105 have a NULL dept_name, so NULL != 'Sales' is NULL and WHERE discards them: the LEFT JOIN kept them and the next clause threw them out. The second tab's OR d.dept_name IS NULL brings both back.
Counting after a join
> Each row of transactions carries a merchant_id that is NULL when the card terminal sent none. Keep every transaction and label it 'no merchant id', 'merchant not found' or 'matched'.
First non-NULL wins
COALESCE takes a list of expressions and returns the first one that is not NULL, reading left to right. If every argument is NULL, it returns NULL. It is a CASE expression in compact form: COALESCE(a, b, c) means CASE WHEN a IS NOT NULL THEN a WHEN b IS NOT NULL THEN b ELSE c END. The function skips NULL and only NULL; a 0 or an empty string is a value and is returned like any other.
| Expression | Returns | Why |
|---|---|---|
| COALESCE(NULL, 7) | 7 | first argument is NULL, second is a value |
| COALESCE(3, 7) | 3 | first argument already has a value |
| COALESCE(NULL, NULL, 'x') | 'x' | third argument is the first non-NULL |
| COALESCE(NULL, NULL) | NULL | nothing to return |
| COALESCE(0, 7) | 0 | 0 is a value, only NULL is skipped |
| COALESCE('', 'x') | '' | an empty string is a value too |
SELECTid,COALESCE(phone, email, 'Unknown') AS contactFROM contacts
The arguments must share a type
COALESCE returns one column, so every argument has to fit one type. COALESCE(age, 'unknown') fails, because INTEGER and VARCHAR cannot share a column; cast the column first and the default fits. Numeric types widen instead of failing: COALESCE(quantity, 0.0) with an INTEGER quantity returns a DECIMAL, because the literal 0.0 is a decimal.
COALESCE inside an aggregate changes the answer
SUM skips NULL, so SUM(COALESCE(tip, 0)) gives the same total as SUM(tip) whenever at least one tip is present. When every tip in a group is NULL, SUM returns NULL and the COALESCE version returns 0: missing versus zero again. AVG and COUNT go further, since COALESCE turns a skipped row into a counted one. The rides table has 5 rides, 2 with no recorded tip.
The reviews table has 10 rows, 3 of them with a NULL rating, and the other 7 ratings add up to 28. What does AVG(COALESCE(rating, 0)) return?
Labelling the NULL group
> The support_tickets table stores priority as VARCHAR, NULL for a ticket nobody prioritised. Count tickets per priority with the NULL group labelled 'unset', busiest group first.
Turning a chosen value into NULL
NULLIF(a, b) compares a with b. When they are equal it returns NULL; otherwise it returns a unchanged. As a CASE it reads CASE WHEN a = b THEN NULL ELSE a END. It is the tool for the sentinels of the first section: name the code, and every row holding it becomes a true missing value that aggregates skip, COUNT(column) leaves out and IS NULL finds.
| Expression | Returns | Why |
|---|---|---|
| NULLIF(0, 0) | NULL | the 2 arguments are equal |
| NULLIF(34, 0) | 34 | different, so the first argument comes back |
| NULLIF('N/A', 'N/A') | NULL | equal strings |
| NULLIF('n/a', 'N/A') | 'n/a' | string comparison is case sensitive |
| NULLIF(NULL, 0) | NULL | the first argument was already NULL |
| NULLIF(7, NULL) | 7 | 7 = NULL is NULL, never TRUE, so 7 comes back |
SELECTuser_id,age,NULLIF(age, 0) AS clean_ageFROM legacy_users
The accounts.status column holds, row by row: 'active', 'closed', NULL, 'Active'. What does this query return, row by row?
Repairing an average
Same 5 rows, and NULLIF restores the figures the WHERE filter found, 35.3 and 3, while every row stays in play. WHERE age > 0 would drop the 2 users entirely, with their cities, orders and signup dates; NULLIF blanks one field and keeps the rest of the row.
The one-value limit
Division by zero
Clean first, then default
The 2 functions nest. COALESCE(NULLIF(description, ''), 'No description') runs the inner call first: an empty string becomes NULL and a real description passes through. The outer call then fills every NULL, whether it arrived as NULL or came from NULLIF, with one default. The reverse nesting, NULLIF(COALESCE(description, ''), ''), turns NULL into '' and then '' into NULL, so both kinds of missing collapse into one NULL and nothing is filled.
The country column holds, row by row: 'US', N/A, NULL, ''. What does COALESCE(NULLIF(country, N/A), 'Unknown') return, row by row?
A safe divisor with a default
Label the unmatched side of a LEFT JOIN
SELECTe.emp_id,COALESCE(d.dept_name, 'Unassigned') AS dept_nameFROM employees AS eLEFT JOIN departments AS dON e.dept_id = d.dept_id
One query over the shipments table
- Convert sentinels to NULL in a CTE, then join and aggregate the clean columns
- Name the default for what it means: 'Unassigned', 'No description', 'no carrier'
- Report COUNT(*) beside COUNT(clean_column) so the reader sees how much was missing
- Put NULLIF on any divisor that can hold 0, and decide separately whether the rate defaults
- COALESCE a measure to 0 before AVG unless the report means 'missing counts as zero'
- Filter a LEFT JOIN's right-side column in WHERE without an IS NULL branch
- Swap the nesting by habit: NULLIF(COALESCE(x, ''), '') blanks and fills nothing
- Replace an old sentinel with a new one such as -1; the next reader will have to find it
> In tickets, resolution_hours holds -1 for a ticket still open and team holds '' for an unassigned one. Clean both sentinels once in a CTE, then report each team's ticket count, resolved count and average resolution hours, with the empty team labelled 'Unassigned'.
> A food delivery company publishes a weekly restaurant report from a restaurant_weeks table, one row per restaurant per week with order_count, refund_count and prep_minutes, joined to the restaurants table for names and sorted by refund rate. A restaurant paused for the week still gets a row with order_count 0, prep_minutes is -1 when the kitchen confirmed no order that week, and rows for restaurants that left the platform have no match in restaurants.
Spot disguised missing data, keep rows alive through joins, and move values into and out of NULL on purpose.
COALESCE: NULL won't fix itself
- Category
- SQL
- Difficulty
- intermediate
- Duration
- 31 minutes
- Challenges
- 13 hands-on challenges
Topics covered: Sentinel values and fake NULLs, NULLs in INNER and LEFT JOINs, COALESCE for defaults, NULLIF for sentinel values, COALESCE and NULLIF patterns
Lesson Sections
- Sentinel values and fake NULLs (concepts: Null Handling)
What a sentinel is A sentinel is an ordinary value a system wrote in place of a missing one. The column was declared NOT NULL, a form refused to submit without the field, or a CSV export had no way to write NULL. The table now holds a number or a string that looks like data and means 'we never knew'. Every aggregate from the last lesson treats a sentinel as real. NULL is skipped by COUNT(age), AVG(age) and MIN(age); a 0 is included by all 3. A column of fake zeros gives a lower average, a wrong
- NULLs in INNER and LEFT JOINs (concepts: Nulls in Joins)
How a join pairs rows An INNER JOIN returns the pairs and nothing more. A LEFT JOIN returns the pairs, then adds back every row of the left table that found no partner, with the right table's columns filled with NULL for those rows. INNER JOIN drops the row The 4 orders meet 4 customers and 2 rows come out. ORD-1 and ORD-2 find Alice and Bob. The NULL customer_id on ORD-3 makes every comparison NULL, so it pairs with nobody, including the Guest customer whose key is also NULL. ORD-4 carries C-10
- COALESCE for defaults (concepts: Null Defaulting)
First non-NULL wins C-1 has a phone and the phone wins. C-2 has no phone, so the email comes through. C-3 has neither, and the literal 'Unknown' at the end guarantees that the contact column is never NULL. C-4 has both and still returns the phone: argument order is priority order, and COALESCE never looks past the first value it finds. The arguments must share a type COALESCE inside an aggregate changes the answer Both sums are 7.0, but the averages differ: 7 over the 3 tipped rides is 2.33, and
- NULLIF for sentinel values (concepts: Null Substitution)
Turning a chosen value into NULL Users 2 and 4 held the sentinel 0 and now hold NULL in clean_age; users 1, 3 and 5 pass through. The raw age column stays beside it, so each row records both what was stored and what was known. Repairing an average Back to legacy_users, where the first section showed AVG(age) reporting 21.2 because of 2 fake zeros. NULLIF inside the aggregate repairs the number without filtering a single row out of the query. The one-value limit NULLIF names exactly 1 value. A co
- COALESCE and NULLIF patterns (concepts: Null Defaulting)
Clean first, then default P-2 and P-3 arrive as '' and NULL and leave as 'No description' in display_text and as NULL in normalized. P-4 slips through both columns as 'N/A', because the query named '' as the only sentinel. A second NULLIF or a CASE with IN would catch it, once a profile has found it. A safe divisor with a default The rate from the last section returned NULL for the campaign with no impressions. A dashboard sorting campaigns by rate wants a number in every row, so COALESCE wraps