NULL — Learn IS NULL, COALESCE, COUNT, and LEFT JOIN from the Basics
BasicNULL BasicsIS NULL / IS NOT NULLCOALESCE / NULLIFCOUNT and NULLLEFT JOIN Anti-JoinPostgreSQL-ready5 questions
QUESTION 1
IS NULL / IS NOT NULL — Equality Comparisons with NULL Always Become UNKNOWN
IS NULLIS NOT NULLNULL ChecksThree-Valued Logic
Background

SQL has three-valued logic: TRUE / FALSE / UNKNOWN. Every comparison operator with NULL (=, !=, <, >) returns UNKNOWN, so WHERE deleted_at = NULL does not work as expected.

-- ✗ Does not work (all rows are excluded)
WHERE deleted_at = NULL     -- NULL = NULL → UNKNOWN → WHERE excludes it

-- ✓ Correct
WHERE deleted_at IS NULL       -- NULL IS NULL → TRUE → passes
WHERE deleted_at IS NOT NULL   -- NULL IS NOT NULL → FALSE → excluded
WHERE-clause pass condition: A WHERE clause lets only rows whose result is TRUE pass. UNKNOWN (the result of an equality comparison with NULL) is treated like FALSE, so the row is excluded. Only IS NULL and IS NOT NULL reliably return TRUE or FALSE for NULL.
Problem

From the users table, assign the status 'active' to users whose deleted_at is NULL and 'deleted' to users whose deleted_at is not NULL. Return the columns id, name, status in ascending id order.

Tables used
► users (5 rows)
idnamedeleted_at
1TanakaNULL
2Suzuki2024-01-10
3SatoNULL
4Ito2024-01-15
5YamadaNULL

Note: deleted_at = NULL means no deletion date is set, so the user is active. A non-NULL value means the user has been deleted. This is a typical soft-delete schema.

Expected Output

Expected output (id ascending):

idnamestatus
1Tanakaactive
2Suzukideleted
3Satoactive
4Itodeleted
5Yamadaactive
Model Answer
SELECT
  id,
  name,
  CASE
    WHEN deleted_at IS NULL THEN 'active'  -- NULL → active
    ELSE                       'deleted'  -- non-NULL → deleted
  END AS status
FROM  users
ORDER BY id;

/*
  Execution order (SQL's logical evaluation order):
  1. FROM users                    → read the rows
  2. CASE WHEN deleted_at IS NULL  → classify with IS NULL
  3. SELECT id, name, status       → output the 3 columns
  4. ORDER BY id                   → sort by id ascending
  */
Explanation (table transitions & key points)
SELECT id, name, CASE WHEN deleted_at IS NULL THEN 'active' ELSE 'deleted' END AS status FROM users ORDER BY id;
LEGEND
Rows read / loaded
① FROM users (5 rows)
FROM usersRead the 5 rows from the users table. The deleted_at column contains both NULL (no deletion date set = active) and date values (deletion date set = deleted).
1 / 3
idnamedeleted_at
1TanakaNULL
2Suzuki2024-01-10
3SatoNULL
4Ito2024-01-15
5YamadaNULL
5 rows read
LEARNING POINTS
SQL's three-valued logic — comparisons with NULL always return UNKNOWN: Comparing NULL with ordinary operators (=, !=, <, >) returns neither TRUE nor FALSE, but UNKNOWN. A WHERE clause lets only TRUE rows pass, so UNKNOWN is effectively treated as FALSE. The biggest trap is that NULL = NULL is not TRUE, contrary to intuition.
Only IS NULL / IS NOT NULL can correctly detect NULL: IS NULL returns TRUE when the target is NULL, while IS NOT NULL returns TRUE for non-NULL values. These are the only two operators that reliably return TRUE or FALSE for NULL. Always use IS NULL / IS NOT NULL for NULL checks.
NULL means “no value” or “unknown,” and is different from 0 or an empty string: NULL is neither 0 nor an empty string (''). deleted_at IS NULL means that no deletion date is set, so the user is active. In the soft-delete pattern, logical deletion is managed by whether deleted_at is NULL; forgetting WHERE deleted_at IS NULL can make deleted users leak into analysis.
ANTI-PATTERNS
Exclude every row with WHERE col = NULL / WHERE col != NULL: Both equality and inequality comparisons with NULL return UNKNOWN, so no rows pass the WHERE clause. Writing col != NULL to find “non-NULL rows” is the same trap. Always use IS NULL / IS NOT NULL.
Combine AND/OR with NULL incorrectly: In WHERE score > 0 AND score != NULL, score != NULL becomes UNKNOWN, so the AND result is also UNKNOWN and every row is excluded. Check NULL with IS NULL on its own, then combine that condition with other conditions using AND/OR. Also, NOT (col IS NULL) is equivalent to col IS NOT NULL.
Practical column: Soft deletes and logical deletion management
The soft-delete pattern is widely used in modern web applications: instead of executing DELETE, it manages whether a record is “deleted” based on whether deleted_at is NULL. Forgetting WHERE deleted_at IS NULL can cause deleted users to appear in reports. In dbt, a best practice is to centralize WHERE deleted_at IS NULL in a macro or view so downstream queries do not have to remember the NULL check. Another effective design is to maintain a separate is_active flag (BOOLEAN) in the application layer to prevent query-side mistakes.
QUESTION 2
COUNT(*) vs COUNT(col) — NULL Is Silently Excluded from Aggregates
COUNT(*)COUNT(col)NULL and Aggregate FunctionsUsage-Rate Calculation
Background

All standard SQL aggregate functions (COUNT, SUM, AVG, MIN, MAX) ignore NULL. The sole exception is COUNT(*), which counts every row regardless of NULL.

COUNT(*)            -- count all rows (including NULL)
COUNT(coupon_code)  -- count non-NULL values only
SUM(amount)         -- sum while excluding NULL rows
AVG(score)          -- average while excluding NULL rows (the denominator also drops them)
The essential difference between COUNT(col) and COUNT(*): COUNT(col) excludes a row when the expression evaluates to NULL. COUNT(*) counts the row itself, so it is unaffected by NULL. This difference is critical when calculating rates such as usage, response, and completion rates.
Problem

From the orders table, return the total number of orders (total_orders), the number of orders using a coupon (coupon_orders), and the coupon usage rate (%) (coupon_rate) in one row. Round coupon_rate to one decimal place.

Tables used
► orders (6 rows)
order_iduser_idamountcoupon_code
11013000SALE10
21025000NULL
31012000SALE10
41038000NULL
51041500NEW20
61024000NULL

Note: coupon_code = NULL means the coupon was not used. The difference between COUNT(*) and COUNT(coupon_code) is the key to the usage-rate calculation.

Expected Output

Expected output (one row):

total_orderscoupon_orderscoupon_rate
6350.0
Model Answer
SELECT
  COUNT(*)                         AS total_orders,  -- all 6 rows (including NULL)
  COUNT(coupon_code)               AS coupon_orders, -- exclude NULL → 3 rows
  ROUND(
    COUNT(coupon_code) * 100.0    -- convert to numeric with 100.0 (avoid integer division)
      / COUNT(*),
    1
  )                                AS coupon_rate
FROM  orders;

/*
  Execution order (SQL's logical evaluation order):
  1. FROM orders                            → read the rows
  2. COUNT(*)                               → count all rows
  3. COUNT(coupon_code)                     → count after excluding NULL rows
  4. COUNT(coupon_code) * 100.0 / COUNT(*)  → calculate the usage rate
  5. ROUND(..., 1)                          → round to one decimal place
  6. SELECT                                 → output 3 columns in one row
  */
Explanation (table transitions & key points)
SELECT COUNT(*) AS total_orders, COUNT(coupon_code) AS coupon_orders, ROUND( COUNT(coupon_code) * 100.0 / COUNT(*), 1 ) AS coupon_rate FROM orders;
LEGEND
Rows read / loaded
① FROM orders (6 rows)
FROM ordersRead the 6 rows from orders. The coupon_code column contains NULL (coupon not used: order_id=2,4,6) and values (coupon used: order_id=1,3,5).
1 / 4
order_iduser_idamountcoupon_code
11013000SALE10
21025000NULL
31012000SALE10
41038000NULL
51041500NEW20
61024000NULL
6 rows read
LEARNING POINTS
COUNT(col) does not count NULL: COUNT(expr) excludes a row when the expression evaluates to NULL. COUNT(*) counts the row itself and is unaffected by NULL. This difference is the key to accurately calculating usage, response, and completion rates.
SUM / AVG / MAX / MIN also ignore NULL: All standard SQL aggregate functions ignore NULL. It is a misconception that “SUM(score) adds NULL as 0.” NULL is completely excluded from aggregate calculations. When aggregating a column where NULL means “not entered,” be aware that the sample size changes unintentionally.
COUNT(*) − COUNT(col) gives the number of NULLs: Subtract the non-NULL count from the total row count to calculate the number of NULLs. COUNT(*) - COUNT(coupon_code) = 3 is the number of orders that did not use a coupon. This is equivalent to COUNT(CASE WHEN coupon_code IS NULL THEN 1 END), but more concise.
ANTI-PATTERNS
Use COUNT(coupon_code) as the denominator of the usage rate: COUNT(coupon_code) * 100.0 / COUNT(coupon_code) is always 100%. Coupon usage rate is the proportion of all orders, so the denominator must be COUNT of the whole population = COUNT(*). Clearly defining the denominator is fundamental to aggregate design.
Get 0 by dividing two integers: COUNT(coupon_code) / COUNT(*) = 3 / 6 = 0 (integer division in PostgreSQL). Cast before dividing with * 100.0 or ::numeric. Multiplying by the 100.0 literal automatically promotes the expression to numeric and enables fractional calculation.
Practical column: Calculating user behavior rates and handling NULL
In e-commerce and app analytics, the definitions of numerator and denominator matter for metrics such as click-through, purchase, and completion rates. Whether you use COUNT(*) or COUNT(col) for the denominator can substantially change the result. Document whether NULL means “not performed” or “no data (missing)” and standardize the denominator across reports; this is fundamental to data quality management. Especially for dashboard KPI definitions, design SQL so that everyone can obtain the same number from the same calculation.
QUESTION 3
COALESCE / NULLIF — Two Tools for Safely Replacing and Producing NULL
COALESCENULLIFNULL ReplacementPrevent Division by Zero
Background

COALESCE and NULLIF are functions for handling NULL safely. Both are standard NULL-handling patterns that appear frequently in production SQL.

-- COALESCE: evaluate from left to right and return the first non-NULL value
COALESCE(discount_price, price)
-- discount_price = NULL → use price (fallback)
-- discount_price = 800  → return 800 (ignore price)

-- NULLIF: return NULL when a = b; otherwise return a
NULLIF(stock, 0)
-- stock = 0  → NULL (avoid a division-by-zero error)
-- stock = 50 → 50 (return it unchanged)
The golden pattern for preventing division by zero with NULLIF: x / NULLIF(y, 0) returns NULL when y = 0, so it avoids a Division by zero error. NULLIF is a useful reversal of perspective: convert a specific value to NULL to make a calculation safe.
Problem

From the products table, calculate the effective price (effective_price = discount_price when present, otherwise price) and the inventory turnover rate (sold ÷ stock → turnover_rate, NULL when stock is 0). Return the columns product_id, name, effective_price, turnover_rate in ascending product_id order. Round turnover_rate to two decimal places.

Tables used
► products (5 rows)
product_idnamepricediscount_pricestocksold
1A10008005020
2B2000NULL3030
3C50040000
4D3000250010040
5E1500NULL05

Note: product_id=3,5: stock=0 → turnover_rate should be NULL. product_id=2,5: discount_price=NULL → use the regular price.

Expected Output

Expected output (product_id ascending):

product_idnameeffective_priceturnover_rate
1A8000.40
2B20001.00
3C400NULL
4D25000.40
5E1500NULL
Model Answer
SELECT
  product_id,
  name,
  COALESCE(discount_price, price)   AS effective_price,  -- return the first non-NULL value
  ROUND(
    sold::numeric / NULLIF(stock, 0),  -- stock=0 → NULL (avoid division by zero)
    2
  )                                   AS turnover_rate
FROM  products
ORDER BY product_id;

/*
  Execution order (SQL's logical evaluation order):
  1. FROM products                      → read the rows
  2. COALESCE(discount_price, price)    → return the first non-NULL value
  3. NULLIF(stock, 0)                   → convert stock=0 to NULL
  4. sold::numeric / NULLIF(stock, 0)   → calculate turnover rate safely
  5. ROUND(..., 2) / SELECT / ORDER BY   → round and output
  */
Explanation (table transitions & key points)
SELECT product_id, name, COALESCE(discount_price, price) AS effective_price, ROUND( sold::numeric / NULLIF(stock, 0), 2 ) AS turnover_rate FROM products ORDER BY product_id;
LEGEND
Rows read / loaded
① FROM products (5 rows)
FROM productsRead the 5 rows from products. The discount_price column contains both NULL (no sale price) and values (sale prices). Rows with stock=0 (id=3,5) are the division-by-zero trap.
1 / 4
product_idnamepricediscount_pricestocksold
1A10008005020
2B2000NULL3030
3C50040000
4D3000250010040
5E1500NULL05
5 rows read
LEARNING POINTS
COALESCE is a “return the first non-NULL value” fallback function: It evaluates arguments from left to right and returns the first non-NULL value. You can build a multi-level fallback by chaining several arguments with COALESCE(a, b, c). It returns NULL only when all arguments are NULL. It is the most widely used function for setting defaults on nullable columns.
NULLIF takes the reverse approach: “convert a specific value to NULL”: NULLIF(a, b) returns NULL when a = b and returns a unchanged otherwise. In addition to the division-by-zero pattern x / NULLIF(y, 0), it can standardize empty strings as NULL with NULLIF(col, '') or convert a sentinel value (−1 = not set) to NULL.
COALESCE arguments must have compatible types: Mixing INT and TEXT as in COALESCE(price, 'price unavailable') causes a cast error. Use 0 (the same numeric type) as the default for numeric columns and '' (an empty string) for text columns, or explicitly cast with ::text to make the types consistent.
ANTI-PATTERNS
Use MySQL's IFNULL / SQL Server's ISNULL in PostgreSQL: IFNULL(a, b) and ISNULL(a, b) do not work in PostgreSQL because those functions do not exist there. Using the standard SQL COALESCE makes the query portable across DBMSs. Make COALESCE your habit to keep the team's codebase consistent.
Fill NULL with a default before counting: COUNT(COALESCE(score, 0)) makes every row's score non-NULL, so its result differs from COUNT(score) and equals COUNT(*). Use COALESCE only when you intend to include NULL as 0 in the aggregate; when you want to count only non-NULL rows, use COUNT(score) as-is.
Practical column: Default-value strategy and “early NULL resolution”
Designing default values for NULL is important in data-analysis pipelines. “Fill missing values with 0” and “keep NULL and use COUNT(*) as the denominator” have different business meanings. In dbt, a common early NULL resolution pattern is to contain COALESCE in a staging model (the upstream layer) and design downstream mart models on the assumption that NULL is absent. This keeps business-logic queries simple and reduces NULL-related bugs.
QUESTION 4
AVG and NULL — Understand How the “Average Quietly Lies”
AVG and NULLCOUNT(*) vs COUNT(col)Missing Values and AggregationResponse-Rate Calculation
Background

Aggregate functions automatically exclude NULL when calculating, so the denominator of the result changes. This is the source of the bug where “the average quietly lies.”

-- When score = [80, NULL, 90, NULL]
AVG(score)                -- = (80 + 90) / 2 = 85.0  ← denominator is 2 (NULL excluded)
COUNT(score)              -- = 2  (non-NULL count)
COUNT(*)                  -- = 4  (total count)
COUNT(*) - COUNT(score)  -- = 2  (NULL count = unanswered count)

-- Include everyone in the average by treating NULL as 0
AVG(COALESCE(score, 0)) -- = (80 + 0 + 90 + 0) / 4 = 42.5
“Average among respondents” and “average among everyone” are different: AVG(score) is the average among respondents (non-NULL values), while AVG(COALESCE(score, 0)) is the average among everyone with unanswered responses treated as 0 points. Which is correct depends on the business requirement. Always check COUNT(*) and COUNT(col) together to understand the missing-data rate.
Problem

From the survey table, calculate the average score by category (avg_score), the number of respondents (responded), the number who did not respond (not_responded), and the response rate (%) (response_rate). Return the columns category, avg_score, responded, not_responded, response_rate in ascending category order. Round avg_score and response_rate to one decimal place.

Tables used
► survey (8 rows)
respondent_idcategoryscoresubmitted_at
1A802024-01-01
2ANULLNULL
3A902024-01-02
4B702024-01-01
5BNULLNULL
6BNULLNULL
7B602024-01-03
8ANULLNULL

Note: score/submitted_at = NULL means unanswered. Because AVG excludes NULL, it becomes the “average among respondents.”

Expected Output

Expected output (category ascending):

categoryavg_scorerespondednot_respondedresponse_rate
A85.02250.0
B65.02250.0

A: score=[80,NULL,90,NULL] → AVG=(80+90)/2=85.0 (the denominator excludes NULL, so it is 2). B: score=[70,NULL,NULL,60] → AVG=(70+60)/2=65.0.

Model Answer
SELECT
  category,
  ROUND(AVG(score), 1)                         AS avg_score,     -- average excluding NULL
  COUNT(score)                                   AS responded,    -- non-NULL count
  COUNT(*) - COUNT(score)                        AS not_responded,-- NULL count
  ROUND(COUNT(score) * 100.0 / COUNT(*), 1)     AS response_rate -- response rate (%)
FROM  survey
GROUP BY category
ORDER BY category;

/*
  Execution order (SQL's logical evaluation order):
  1. FROM survey                         → read the rows
  2. GROUP BY category                   → group by category
  3. AVG(score)                          → average after excluding NULL (respondent average)
  4. COUNT(score)                        → count respondents
  5. COUNT(*)                            → count the population
  6. COUNT(*) - COUNT(score)             → calculate non-respondents
  7. COUNT(score) * 100.0 / COUNT(*)     → calculate response rate
  8. ROUND / SELECT / ORDER BY category  → round and output
  */
Explanation (table transitions & key points)
SELECT category, ROUND(AVG(score), 1) AS avg_score, COUNT(score) AS responded, COUNT(*) - COUNT(score) AS not_responded, ROUND(COUNT(score) * 100.0 / COUNT(*), 1) AS response_rate FROM survey GROUP BY category ORDER BY category;
LEGEND
Rows read / loaded
① FROM survey (8 rows)
FROM surveyRead the 8 rows from survey. Rows where score and submitted_at are NULL (respondent_id=2,5,6,8) are unanswered. How AVG handles these NULLs is the key point of this question.
1 / 4
respondent_idcategoryscoresubmitted_at
1A802024-01-01
2ANULLNULL
3A902024-01-02
4B702024-01-01
5BNULLNULL
6BNULLNULL
7B602024-01-03
8ANULLNULL
8 rows read
LEARNING POINTS
AVG ignores NULL, so the denominator also drops NULL rows: AVG(score) is the sum of non-NULL rows divided by the number of non-NULL rows. If 2 of 4 rows are NULL, the denominator is 2. Always remember that it is the average among respondents, not the average among everyone. The denominator of AVG can change dramatically when a column has many NULLs.
COUNT(*) − COUNT(col) efficiently gives the number of NULLs: Subtract the non-NULL count from the total row count to obtain the NULL count. This is equivalent to SUM(CASE WHEN score IS NULL THEN 1 ELSE 0 END), but more concise. Always tracking the number of missing values is fundamental to analysis quality.
Use COALESCE when you want to average NULL as 0: AVG(COALESCE(score, 0)) treats unanswered responses as 0 points and calculates the average among everyone. Use “average among respondents” or “average among everyone (unanswered = 0)” according to the business requirement. Documenting which one is used in the data specification is important.
ANTI-PATTERNS
Misunderstand AVG as “the average among everyone” because you do not know it ignores NULL: AVG on a column with many NULLs is calculated from a smaller sample than the full population. Always output COUNT(*) and COUNT(col) together and check the missing-data rate (= 1 - COUNT(col)/COUNT(*)). If the missing-data rate exceeds 30%, the average has low representativeness and should be treated cautiously.
Miss differences in missing-data rates between groups after GROUP BY: When NULL is unevenly distributed across groups, each group's AVG is calculated from a different sample size. Comparing avg_score directly when group A has a 90% response rate and group B has 20% is meaningless. Always interpret it together with response_rate.
Practical column: Managing missing-data rates in survey and user-score analysis
NULL in survey data and rating scores means “unanswered,” which is fundamentally different from 0. Reporting only the average score hides missing responses. Reporting avg_score together with response_rate communicates data reliability accurately. Be cautious when interpreting an average score with a response rate below 50%, because it has low representativeness. In dbt, a best practice for data quality is to include responded / not_responded / response_rate in the same model as avg_score.
QUESTION 5
LEFT JOIN and NULL — The Anti-Join Pattern Where “No Match” Produces NULL
LEFT JOINIS NULLAnti-JoinFind Customers with No Purchases
Background

LEFT JOIN keeps every row from the left table and fills every column from the right table with NULL when no matching row exists. This property enables an anti-join that extracts rows with no match.

SELECT c.name, o.order_id
FROM   customers c
LEFT JOIN orders o
  ON c.customer_id = o.customer_id

-- customer_id=2,4: no match in orders → o.order_id = NULL
WHERE o.order_id IS NULL  -- let only unmatched (no-purchase) rows pass
The anti-join pattern: LEFT JOIN + WHERE right_table.pk IS NULL extracts left-table rows for which no corresponding row exists in the right table. A NOT IN subquery risks excluding every row when the right table contains NULL, so LEFT JOIN + IS NULL is a safer and often more efficient pattern.
Problem

LEFT JOIN the customers and orders tables to find customers who have never placed an order (customers with no purchases). Return the columns customer_id, name in ascending customer_id order.

Tables used
► customers (5 rows)
customer_idname
1Tanaka
2Suzuki
3Sato
4Ito
5Yamada
► orders (4 rows)
order_idcustomer_idamount
113000
235000
312000
451500

Note: customer_id=2 (Suzuki) and 4 (Ito) have no order records in the orders table. Detect them with IS NULL after the LEFT JOIN.

Expected Output

Expected output (customer_id ascending):

customer_idname
2Suzuki
4Ito
Model Answer
SELECT
  c.customer_id,
  c.name
FROM  customers c
LEFT JOIN orders o
  ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL   -- right table is NULL = no match (customer with no purchase)
ORDER BY c.customer_id;

/*
  Execution order (SQL's logical evaluation order):
  1. FROM customers c                         → read the rows
  2. LEFT JOIN orders o                       → keep all left-table rows while joining
  3. WHERE o.order_id IS NULL                 → pass only rows where the right side is NULL
  4. SELECT c.customer_id, c.name / ORDER BY  → output in ascending customer_id order
  */
Explanation (table transitions & key points)
SELECT c.customer_id, c.name FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id WHERE o.order_id IS NULL ORDER BY c.customer_id;
LEGEND
Rows read / loaded
① FROM customers (5 rows)
FROM customers cRead the 5 rows from customers. LEFT JOIN each of them to orders, then detect customers without an order record by the NULL values.
1 / 4
customer_idname
1Tanaka
2Suzuki
3Sato
4Ito
5Yamada
5 rows read
LEARNING POINTS
A LEFT JOIN makes the right side NULL when there is no match: LEFT JOIN keeps every row from the left table and makes every column from the right table NULL when no corresponding row exists. Filtering with WHERE right_table.pk IS NULL to extract only these “no match” rows is the anti-join pattern. It is widely used to find customers with no purchases, users with no follows, and tasks that are not complete.
A NULL in a NOT IN subquery can exclude every row: WHERE customer_id NOT IN (SELECT customer_id FROM orders) can return UNKNOWN for NOT IN (..., NULL, ...) and exclude every row if orders.customer_id contains even one NULL. Use NOT EXISTS or LEFT JOIN + IS NULL instead of NOT IN to avoid this NULL trap safely.
Putting a right-table condition after the JOIN can turn LEFT JOIN into INNER JOIN: If you write WHERE o.status = 'paid' after a LEFT JOIN, rows where o.status is NULL (unmatched rows) are excluded because NULL = 'paid' is UNKNOWN. Put right-table filtering in the ON clause as part of the join condition, or filter in a subquery before the LEFT JOIN.
ANTI-PATTERNS
Break LEFT JOIN by putting a right-table filter in WHERE: LEFT JOIN orders o ON ... WHERE o.status = 'paid' excludes NULL rows (unmatched rows) because NULL = 'paid' becomes UNKNOWN, making the query effectively an INNER JOIN. Include the right-table filter in the ON clause as ON c.id = o.customer_id AND o.status = 'paid'. Then unmatched rows remain NULL.
Write an anti-join as WHERE o.order_id = NULL: WHERE o.order_id = NULL evaluates the equality comparison as UNKNOWN, so the result always has 0 rows. Always use IS NULL to detect NULL in the right table. This is the same IS NULL rule from Q1 applied to an anti-join.
Practical column: Three practical anti-join patterns
Finding “things that have not happened,” such as customers with no purchases, users with no follows, and tasks that are not complete, is common in data analysis. Comparing the three approaches: ① LEFT JOIN + IS NULL (recommended): simple and safe, with no NULL trap and an efficient execution plan. ② NOT EXISTS: safe with a correlated subquery, but somewhat more verbose. ③ NOT IN: usable only when the right table is guaranteed not to contain NULL. In practice, LEFT JOIN + IS NULL is the standard anti-join pattern because it is the simplest and safest.