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
IS NULL and IS NOT NULL reliably return TRUE or FALSE for NULL.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.
| id | name | deleted_at |
|---|---|---|
| 1 | Tanaka | NULL |
| 2 | Suzuki | 2024-01-10 |
| 3 | Sato | NULL |
| 4 | Ito | 2024-01-15 |
| 5 | Yamada | NULL |
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 (id ascending):
| id | name | status |
|---|---|---|
| 1 | Tanaka | active |
| 2 | Suzuki | deleted |
| 3 | Sato | active |
| 4 | Ito | deleted |
| 5 | Yamada | active |
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 */
LEGEND
① 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).| id | name | deleted_at |
|---|---|---|
| 1 | Tanaka | NULL |
| 2 | Suzuki | 2024-01-10 |
| 3 | Sato | NULL |
| 4 | Ito | 2024-01-15 |
| 5 | Yamada | NULL |
NULL = NULL is not TRUE, contrary to intuition.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.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.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.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.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.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)
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.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.
| order_id | user_id | amount | coupon_code |
|---|---|---|---|
| 1 | 101 | 3000 | SALE10 |
| 2 | 102 | 5000 | NULL |
| 3 | 101 | 2000 | SALE10 |
| 4 | 103 | 8000 | NULL |
| 5 | 104 | 1500 | NEW20 |
| 6 | 102 | 4000 | NULL |
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 (one row):
| total_orders | coupon_orders | coupon_rate |
|---|---|---|
| 6 | 3 | 50.0 |
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 */
LEGEND
① 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).| order_id | user_id | amount | coupon_code |
|---|---|---|---|
| 1 | 101 | 3000 | SALE10 |
| 2 | 102 | 5000 | NULL |
| 3 | 101 | 2000 | SALE10 |
| 4 | 103 | 8000 | NULL |
| 5 | 104 | 1500 | NEW20 |
| 6 | 102 | 4000 | 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.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.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.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.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.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)
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.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.
| product_id | name | price | discount_price | stock | sold |
|---|---|---|---|---|---|
| 1 | A | 1000 | 800 | 50 | 20 |
| 2 | B | 2000 | NULL | 30 | 30 |
| 3 | C | 500 | 400 | 0 | 0 |
| 4 | D | 3000 | 2500 | 100 | 40 |
| 5 | E | 1500 | NULL | 0 | 5 |
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 (product_id ascending):
| product_id | name | effective_price | turnover_rate |
|---|---|---|---|
| 1 | A | 800 | 0.40 |
| 2 | B | 2000 | 1.00 |
| 3 | C | 400 | NULL |
| 4 | D | 2500 | 0.40 |
| 5 | E | 1500 | NULL |
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 */
LEGEND
① 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.| product_id | name | price | discount_price | stock | sold |
|---|---|---|---|---|---|
| 1 | A | 1000 | 800 | 50 | 20 |
| 2 | B | 2000 | NULL | 30 | 30 |
| 3 | C | 500 | 400 | 0 | 0 |
| 4 | D | 3000 | 2500 | 100 | 40 |
| 5 | E | 1500 | NULL | 0 | 5 |
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(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(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.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.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.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
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.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.
| respondent_id | category | score | submitted_at |
|---|---|---|---|
| 1 | A | 80 | 2024-01-01 |
| 2 | A | NULL | NULL |
| 3 | A | 90 | 2024-01-02 |
| 4 | B | 70 | 2024-01-01 |
| 5 | B | NULL | NULL |
| 6 | B | NULL | NULL |
| 7 | B | 60 | 2024-01-03 |
| 8 | A | NULL | NULL |
Note: score/submitted_at = NULL means unanswered. Because AVG excludes NULL, it becomes the “average among respondents.”
Expected output (category ascending):
| category | avg_score | responded | not_responded | response_rate |
|---|---|---|---|---|
| A | 85.0 | 2 | 2 | 50.0 |
| B | 65.0 | 2 | 2 | 50.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.
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 */
LEGEND
① 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.| respondent_id | category | score | submitted_at |
|---|---|---|---|
| 1 | A | 80 | 2024-01-01 |
| 2 | A | NULL | NULL |
| 3 | A | 90 | 2024-01-02 |
| 4 | B | 70 | 2024-01-01 |
| 5 | B | NULL | NULL |
| 6 | B | NULL | NULL |
| 7 | B | 60 | 2024-01-03 |
| 8 | A | NULL | NULL |
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.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.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.responded / not_responded / response_rate in the same model as avg_score.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
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.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.
| customer_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Suzuki |
| 3 | Sato |
| 4 | Ito |
| 5 | Yamada |
| order_id | customer_id | amount |
|---|---|---|
| 1 | 1 | 3000 |
| 2 | 3 | 5000 |
| 3 | 1 | 2000 |
| 4 | 5 | 1500 |
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 (customer_id ascending):
| customer_id | name |
|---|---|
| 2 | Suzuki |
| 4 | Ito |
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 */
LEGEND
① 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.| customer_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Suzuki |
| 3 | Sato |
| 4 | Ito |
| 5 | Yamada |
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.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.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.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.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.