Senior 8 min · March 06, 2026

SQL CASE — Missing ELSE Silently Skips Rows in SUM

APAC revenue dropped to $0 when a CASE inside SUM lacked ELSE, silently excluding 18% of orders.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • CASE is SQL's built-in if-else — two forms: simple (equality only) and searched (any boolean expression per WHEN)
  • Simple CASE compares one column against fixed values; searched CASE evaluates a fresh boolean per WHEN clause
  • CASE inside COUNT requires no ELSE (or ELSE NULL) — ELSE 0 counts every row including non-matches and silently inflates your totals
  • Missing ELSE returns NULL with no error, no warning — that NULL silently corrupts SUM, vanishes rows from JOINs, and crashes application code downstream
  • CASE in ORDER BY maps status labels to numeric priorities for business-defined sort order — no application-side sorting required
  • Biggest mistake: expecting WHEN NULL to match NULL rows in simple CASE — it never does; NULL = anything is NULL, not TRUE; use WHEN col IS NULL explicitly in searched CASE
  • New enum values introduced by feature launches will silently return NULL from every existing CASE that has no ELSE — always write ELSE
Plain-English First

Imagine you work at a coffee shop and your manager gives you three instructions: if a customer has ordered more than five drinks this month, hand them a Gold card; if they have ordered between three and five, give them Silver; everyone else gets Bronze. You check each customer against those rules in order, stop at the first one that fits, and hand over the right card.

That is exactly what a SQL CASE statement does. It looks at a value, walks down a list of conditions in order, and returns a different result depending on which condition is true first. The moment a condition matches, everything below it is ignored.

The subtle part — and the part that causes production incidents — is what happens when nothing matches. Your manager forgot to tell you what to do for a customer with zero orders. In a programming language, that would probably throw an error. SQL just hands you nothing: a NULL. No warning, no crash, just silence. And that silence can corrupt a revenue total, hide a row from a report, or surface as a confusing empty field in your application. That is why every CASE expression in production code needs an ELSE clause, even if the ELSE is just a visible placeholder that flags the unexpected case rather than hiding it.

Every real-world database holds messy data — status codes instead of human-readable labels, raw numbers that need to be bucketed into categories, columns that need different formatting depending on context, and edge cases that no one anticipated when the schema was first designed. Without a way to apply conditional logic inside a query, you are forced to pull all that raw data into application code just to reshape it into something meaningful. That is slow, it creates a second place where business logic lives, and it means every schema change or new status code requires a code deployment instead of a query change.

The SQL CASE statement solves this by letting you embed if-else logic directly inside SELECT, ORDER BY, WHERE, and even aggregate functions like SUM and COUNT. Instead of making your application do the heavy lifting after the fact, you push the logic down to the database — where the data already lives — and get back exactly the shape you need in a single round-trip.

What most tutorials cover is the basic syntax. What they skip is the failure modes: the missing ELSE that corrupts a revenue total and nobody notices until the CFO asks a question in a board meeting, the ELSE 0 inside a COUNT that inflates every metric on your dashboard, the NULL row that silently vanishes from a JOIN because the CASE expression returned NULL and NULL never equals anything. These are not edge cases you will encounter someday — they are patterns that appear in production code at companies of every size, written by developers who understood the syntax perfectly.

By the end of this article you will know the difference between the two forms of CASE and when each one is appropriate, how to use CASE inside aggregate functions to build pivot-style reports from a single table scan, the exact silent bugs that trip up experienced developers and how to write code that makes them visible rather than hiding them, and the questions interviewers use to probe whether you have actually operated SQL in production or just read about it.

Simple vs Searched CASE — Choosing the Right Form

SQL gives you two syntactically different forms of CASE, and picking the wrong one is the first place developers lose time — not because it throws an error, but because the simple form silently cannot express what you are trying to say.

The simple CASE names one expression at the top and compares it against a list of fixed values using equality. Think of it as a switch statement. You write the column once, and each WHEN clause names a value it might equal. It is concise and easy to scan when every condition is a pure equality check against known constants. Status code translation is its natural habitat: one column, a finite list of possible values, one label per value.

The searched CASE evaluates a completely fresh boolean expression in every WHEN clause. There is no column named up front. Each WHEN can check a different column, use a range operator, call a function, combine multiple conditions with AND and OR, or check for NULL with IS NULL. It is strictly more powerful than the simple form — anything simple CASE can express, searched CASE can also express, but not vice versa.

The practical rule is this: if every one of your WHEN clauses looks like WHEN 'some_fixed_value', use the simple form. The moment any condition needs >, <, BETWEEN, IS NULL, IN with a subquery, or references more than one column, switch to searched CASE. Trying to express a range check inside simple CASE is a common source of confusion because the database interprets WHEN 50 as equality, not as a threshold, and the query compiles without error but returns NULL for every row that is not exactly 50.

One thing that catches people off guard: both forms produce identical execution plans in every major database. The difference is purely about what logic you can express and how readable the result is. There is no performance argument for choosing one over the other — only a correctness and clarity argument.

simple_vs_searched_case.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
-- ============================================================
-- SETUP: orders table used throughout all examples
-- ============================================================
CREATE TABLE orders (
    order_id     INT            PRIMARY KEY,
    customer_id  INT            NOT NULL,
    total_amount DECIMAL(10, 2) NOT NULL,
    status_code  CHAR(1)        -- 'P' = Pending, 'S' = Shipped, 'D' = Delivered, 'C' = Cancelled
                                -- NULL is possible — a workflow bug can leave this unset
);

INSERT INTO orders VALUES
    (1, 101,    25.00, 'P'),
    (2, 102,   340.50, 'S'),
    (3, 103,    12.99, 'D'),
    (4, 104,   980.00, 'D'),
    (5, 105,    55.75, 'C'),
    (6, 106,   150.00, 'S'),
    (7, 107,  3200.00, 'D');

-- ============================================================
-- SIMPLE CASE: one column checked against fixed values
-- Use this when every WHEN is a pure equality check.
-- Perfect for translating status codes to readable labels.
-- ============================================================
SELECT
    order_id,
    total_amount,
    CASE status_code           -- column named ONCE here; each WHEN is a value it might equal
        WHEN 'P' THEN 'Pending'
        WHEN 'S' THEN 'Shipped'
        WHEN 'D' THEN 'Delivered'
        WHEN 'C' THEN 'Cancelled'
        ELSE 'Unknown'         -- guards against future status codes — always include this
    END AS order_status
FROM orders;

-- ============================================================
-- SEARCHED CASE: fresh boolean per WHEN — required for ranges
-- Use this the moment any condition needs >, <, BETWEEN, IS NULL,
-- or touches more than one column.
-- ============================================================
SELECT
    order_id,
    total_amount,
    CASE
        WHEN total_amount < 50.00                  THEN 'Small'
        WHEN total_amount BETWEEN 50.00 AND 499.99 THEN 'Medium'  -- BETWEEN is inclusive on both ends
        WHEN total_amount >= 500.00                THEN 'Large'
        ELSE 'Uncategorised'   -- catches NULLs and any gap you left in the range logic
    END AS order_tier
FROM orders
ORDER BY total_amount;

-- ============================================================
-- COMMON MISTAKE: trying range logic inside simple CASE
-- The database reads WHEN 50 as status_code = 50, not >= 50.
-- Every row that is not exactly 50 returns NULL — no error.
-- ============================================================
-- SELECT
--     order_id,
--     CASE total_amount
--         WHEN 50  THEN 'Medium'   -- interpreted as total_amount = 50, not >= 50
--         WHEN 500 THEN 'Large'    -- interpreted as total_amount = 500, not >= 500
--     END AS wrong_tier            -- every other row silently returns NULL
-- FROM orders;
Output
-- Simple CASE result:
order_id | total_amount | order_status
----------+--------------+--------------
1 | 25.00 | Pending
2 | 340.50 | Shipped
3 | 12.99 | Delivered
4 | 980.00 | Delivered
5 | 55.75 | Cancelled
6 | 150.00 | Shipped
7 | 3200.00 | Delivered
-- Searched CASE result (ordered by total_amount):
order_id | total_amount | order_tier
----------+--------------+------------
3 | 12.99 | Small
1 | 25.00 | Small
5 | 55.75 | Medium
6 | 150.00 | Medium
2 | 340.50 | Medium
4 | 980.00 | Large
7 | 3200.00 | Large
How SQL Evaluates CASE
  • Top-to-bottom evaluation, first TRUE match wins — every subsequent WHEN is ignored even if it would also match
  • Simple CASE is a switch statement on one column; searched CASE is a full if-else tree that can reference anything
  • No WHEN matches and no ELSE written means the expression returns NULL — not an error, just silence
  • The SQL standard does not guarantee WHEN clauses are skipped after a match at the optimizer level — do not rely on CASE for side-effect isolation or division-by-zero protection in complex expressions
  • Both forms produce identical execution plans — choose based on what logic you can express and how readable the result is, not performance
Production Insight
A developer used simple CASE to bucket orders by value, writing WHEN 50 THEN 'Medium' and WHEN 500 THEN 'Large'. The query compiled without error. Every order that was not exactly $50.00 or exactly $500.00 returned NULL for the tier column. The dashboard showed blank tier labels for 94% of orders before anyone noticed — because the query succeeded and the NULLs looked like a data quality problem rather than a code problem.
The fix was switching to searched CASE with WHEN total_amount BETWEEN 50 AND 499.99 THEN 'Medium'. Three minutes of work. Six hours of investigation to find it.
Rule: if your WHEN clause does not look like WHEN 'exact_value', you need searched CASE. Simple CASE is equality-only, and it will not warn you when you have asked it to do something it cannot.
Key Takeaway
Simple CASE is equality-only. The moment you need a range, a NULL check, a function call, or multi-column logic, it cannot help you — and it will not tell you that it cannot help you. It will just return NULL.
Both forms produce identical execution plans — the choice is about what you can express and how readable it is.
Rule: if your WHEN clause does not look like WHEN 'fixed_value', switch to searched CASE before the query ships.
Which CASE Form to Use
IfAll conditions are equality checks against fixed values on one column
UseUse simple CASE — cleaner, shorter, easier to scan for code review
IfAny condition uses >, <, BETWEEN, IS NULL, IN with a subquery, or NOT
UseUse searched CASE — simple CASE cannot express these; it will silently return NULL instead of an error
IfConditions span more than one column
UseUse searched CASE — simple CASE is locked to the single expression named at the top
IfTranslating a finite set of enum codes to human-readable labels
UseUse simple CASE — it is purpose-built for this pattern and easier to audit for completeness
IfBuilding conditional aggregates (COUNT, SUM, AVG with a condition)
UseUse searched CASE — more flexible for the THEN 1 / ELSE NULL pattern and handles NULL inputs correctly

CASE Inside Aggregate Functions — The Conditional COUNT Pattern

This is where CASE goes from a label-translation tool to something genuinely powerful — and it is the pattern that separates developers who write reporting SQL from developers who write good reporting SQL.

The problem it solves: you need a dashboard that shows total orders, delivered orders, in-flight orders, delivered revenue, and average large-order value. The naive approach runs five separate queries, each scanning the full table. On a 50-million-row orders table, that is 250 million rows scanned for a single page load. The correct approach runs one query that scans the table once and computes all five numbers simultaneously.

The mechanism is nesting a CASE expression inside an aggregate function. COUNT and SUM both have a relationship with NULL that you can exploit. COUNT ignores NULLs — it only counts non-NULL values. So COUNT(CASE WHEN status_code = 'D' THEN 1 END) returns 1 for delivered rows and NULL (the implicit default when no ELSE is written) for everything else. COUNT counts the 1s and skips the NULLs. You get a delivered-only count from a full-table scan.

For SUM, the pattern is slightly different. Return the actual value you want summed when the condition is true, and 0 when it is not: SUM(CASE WHEN status_code = 'D' THEN total_amount ELSE 0 END). You could also use ELSE NULL here — SUM skips NULLs too — but ELSE 0 makes the intent explicit to the next person reading the query.

The critical asymmetry to burn into memory: ELSE 0 is correct inside SUM but catastrophic inside COUNT. Inside COUNT, 0 is a non-NULL value. COUNT counts it. Every non-matching row contributes 0 to the count, which means COUNT returns the total row count regardless of your condition. The query produces the wrong number with no error and no indication that anything went wrong. This is the single most common CASE bug in production dashboard code, and it is invisible without knowing to look for it.

conditional_aggregates.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
-- ============================================================
-- GOAL: single-pass dashboard — one query, one table scan,
-- multiple conditional metrics
-- ============================================================
SELECT
    -- Total orders regardless of status
    COUNT(*)                                                          AS total_orders,

    -- COUNT with conditional CASE: no ELSE means non-matches return NULL,
    -- COUNT skips NULLs — only delivered rows are counted
    COUNT(CASE WHEN status_code = 'D' THEN 1 END)                    AS delivered_count,

    -- Multiple conditions in one CASE for in-flight orders
    COUNT(CASE WHEN status_code IN ('P', 'S') THEN 1 END)            AS in_flight_count,

    -- ELSE 0 is correct in SUM — makes intent explicit, handles non-matches cleanly
    SUM(CASE WHEN status_code = 'D' THEN total_amount ELSE 0 END)    AS delivered_revenue,

    -- AVG with conditional CASE: NULL rows are excluded from both
    -- the numerator and the denominator — AVG adjusts automatically
    AVG(CASE WHEN total_amount >= 500.00 THEN total_amount END)       AS avg_large_order_value,

    -- *** THE TRAP — shown here so you recognise it in code review ***
    -- ELSE 0 inside COUNT: every non-matching row still returns 0,
    -- COUNT counts 0 as non-NULL, result = total_orders, not delivered_count
    COUNT(CASE WHEN status_code = 'D' THEN 1 ELSE 0 END)             AS wrong_delivered_count

FROM orders;

-- ============================================================
-- Tier breakdown: CASE inside GROUP BY
-- One query replaces three separate queries with WHERE clauses.
-- In standard SQL you must repeat the CASE expression in GROUP BY;
-- PostgreSQL allows alias references, most other databases do not.
-- ============================================================
SELECT
    CASE
        WHEN total_amount < 50.00                  THEN 'Small'
        WHEN total_amount BETWEEN 50.00 AND 499.99 THEN 'Medium'
        WHEN total_amount >= 500.00                THEN 'Large'
        ELSE 'Uncategorised'
    END                          AS order_tier,
    COUNT(*)                     AS order_count,
    ROUND(SUM(total_amount), 2)  AS tier_revenue,
    ROUND(AVG(total_amount), 2)  AS avg_order_value
FROM orders
GROUP BY
    CASE
        WHEN total_amount < 50.00                  THEN 'Small'
        WHEN total_amount BETWEEN 50.00 AND 499.99 THEN 'Medium'
        WHEN total_amount >= 500.00                THEN 'Large'
        ELSE 'Uncategorised'
    END
ORDER BY tier_revenue DESC;
Output
-- Single-pass dashboard (note wrong_delivered_count = total_orders = 7):
total_orders | delivered_count | in_flight_count | delivered_revenue | avg_large_order_value | wrong_delivered_count
--------------+-----------------+-----------------+-------------------+-----------------------+----------------------
7 | 3 | 3 | 4192.99 | 2090.00 | 7
-- Tier breakdown:
order_tier | order_count | tier_revenue | avg_order_value
--------------+-------------+--------------+-----------------
Large | 2 | 4180.00 | 2090.00
Medium | 3 | 546.25 | 182.08
Small | 2 | 37.99 | 19.00
COUNT vs SUM: The ELSE Trap
CASE inside COUNT: use THEN 1 with NO ELSE, or ELSE NULL explicitly. COUNT ignores NULLs, so only matching rows are counted. The moment you write ELSE 0, COUNT counts every row regardless of your condition — 0 is not NULL. CASE inside SUM: use THEN value ELSE 0. Non-matching rows contribute zero to the total rather than being excluded. ELSE NULL also works because SUM skips NULLs, but ELSE 0 makes the intent visible to the next reader. The asymmetry is: in SUM, 0 and NULL behave the same way. In COUNT, 0 and NULL behave completely differently. This distinction is the source of the most common production CASE bug.
Production Insight
A reporting team had built a dashboard using five separate COUNT queries with different WHERE clauses, each scanning the full 50-million-row orders table. Page load time was 12 seconds on a busy afternoon. The fix was consolidating all five into a single SELECT using CASE inside COUNT — one table scan instead of five. Page load dropped to 2.4 seconds with no schema changes, no indexing work, and no infrastructure changes. The query was structurally simpler than the five it replaced.
The one thing that almost caused a bug in the rewrite: a junior engineer added ELSE 0 to one of the COUNT expressions by analogy with the SUM expressions directly above it in the query. Code review caught it before it shipped. That column would have returned 50,000,000 for every row in the result — the total table count — instead of the conditional count.
Rule: conditional aggregates replace multiple queries with a single table scan. That alone justifies learning this pattern. The ELSE 0 trap inside COUNT is the only thing to watch for.
Key Takeaway
CASE inside COUNT: no ELSE or ELSE NULL — writing ELSE 0 makes COUNT return the total row count for every row and silently breaks every conditional count in your dashboard.
CASE inside SUM: ELSE 0 is correct — it makes non-matching rows contribute zero rather than being excluded, which is the intended behaviour and communicates intent clearly.
This single pattern replaces multiple queries with one table scan. On large tables the I/O difference is not marginal — it is the difference between a fast dashboard and one that times out.
Conditional Aggregate Pattern Selection
IfCount rows matching a condition
UseCOUNT(CASE WHEN cond THEN 1 END) — no ELSE; the implicit NULL is what makes COUNT skip non-matching rows
IfSum a column only for rows matching a condition
UseSUM(CASE WHEN cond THEN amount ELSE 0 END) — ELSE 0 makes intent explicit; ELSE NULL also works but is less readable
IfAverage a column for a subset of rows
UseAVG(CASE WHEN cond THEN amount END) — NULL rows are excluded from both numerator and denominator automatically; the denominator adjusts to the matching row count
IfMultiple breakdowns needed from the same table
UseMultiple CASE expressions in one SELECT — one table scan, multiple metrics, one round-trip
IfPivot-style cross-tab report without vendor-specific PIVOT syntax
UseCASE inside SUM with GROUP BY — portable across all major databases, no proprietary syntax required

CASE in ORDER BY — Dynamic Sorting Without Application Code

Most developers know CASE in SELECT. Fewer reach for it in ORDER BY, which is where it solves a problem that comes up constantly in production: sorting rows by a business-defined priority that has nothing to do with alphabetical or numeric order.

The pattern is simple. You write a CASE expression in ORDER BY that maps each business label to a number. Lower number means higher priority. The expression does not need to appear in SELECT at all — it is used purely to control sort order, invisible to the result set and to the application consuming it.

Consider a customer support queue. You want Pending orders at the top because they need action now, then Shipped orders because they are in transit and worth watching, then Delivered as low priority, and Cancelled orders pushed to the bottom since they are closed. Sorting alphabetically on status_code gives you C, D, P, S — exactly backwards from what you want. Sorting on the readable label strings is equally wrong: Cancelled, Delivered, Pending, Shipped. Neither matches the business priority.

Adding a CASE in ORDER BY that maps 'P' → 1, 'S' → 2, 'D' → 3, 'C' → 4 gives you exactly the right order. The CASE expression is computed at query time, costs essentially nothing compared to the I/O of fetching the rows, and is invisible in the output. Any unknown or future status code falls to ELSE 5 and sinks to the bottom rather than appearing at a random position.

The same pattern solves a related problem that trips up cross-database code: NULL sorting position. SQL Server and Oracle sort NULLs last in ascending order by default. PostgreSQL and MySQL sort NULLs first. If you have NULLs in your sort column and your application depends on consistent NULL position across databases, a CASE expression in ORDER BY is the portable fix. Map NULL to 0 to push it first, or to 999 to push it last, regardless of what the underlying database would do by default.

dynamic_order_by.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
-- ============================================================
-- SCENARIO: support queue — show orders in business priority:
--   1. Pending   — needs action immediately
--   2. Shipped   — in transit, watch for delivery issues
--   3. Delivered — resolved, low urgency
--   4. Cancelled — closed, lowest priority
-- Alphabetical sort on status_code gives C, D, P, S — wrong.
-- Alphabetical sort on the label gives Cancelled first — also wrong.
-- ============================================================
SELECT
    order_id,
    customer_id,
    total_amount,
    status_code,
    CASE status_code
        WHEN 'P' THEN 'Pending'
        WHEN 'S' THEN 'Shipped'
        WHEN 'D' THEN 'Delivered'
        WHEN 'C' THEN 'Cancelled'
        ELSE 'Unknown'
    END AS order_status
FROM orders
ORDER BY
    -- This CASE is invisible in the result set — used only for sort control
    CASE status_code
        WHEN 'P' THEN 1   -- highest priority: show first
        WHEN 'S' THEN 2
        WHEN 'D' THEN 3
        WHEN 'C' THEN 4   -- lowest priority: show last
        ELSE 5            -- unknown or future statuses sink below Cancelled
    END ASC,
    -- Secondary sort: within the same status, largest orders first
    total_amount DESC;

-- ============================================================
-- PORTABLE NULL HANDLING: force NULLs to a specific position
-- regardless of database-default NULL sort behaviour
-- (PostgreSQL sorts NULLs first ASC; SQL Server sorts them last)
-- ============================================================
SELECT
    order_id,
    status_code
FROM orders
ORDER BY
    CASE WHEN status_code IS NULL THEN 1 ELSE 0 END ASC,  -- NULLs last
    status_code ASC;
Output
-- Support queue result — Pending first, Cancelled last,
-- largest amounts within each status group shown first:
order_id | customer_id | total_amount | status_code | order_status
----------+-------------+--------------+-------------+--------------
1 | 101 | 25.00 | P | Pending
2 | 102 | 340.50 | S | Shipped
6 | 106 | 150.00 | S | Shipped
7 | 107 | 3200.00 | D | Delivered
4 | 104 | 980.00 | D | Delivered
3 | 103 | 12.99 | D | Delivered
5 | 105 | 55.75 | C | Cancelled
The Priority Mapping Pattern
  • Map each business label to a number — lower number means higher in the result set when sorting ASC
  • The CASE expression lives only in ORDER BY, not in SELECT — result set stays clean, application gets unsorted data it does not need to re-sort
  • Unknown or future values go to ELSE with a number that pushes them to the bottom — they do not appear in a random position
  • Combine with secondary sort keys (total_amount DESC) after the CASE for deterministic ordering within each priority group
  • Portably force NULLs first or last using CASE WHEN col IS NULL THEN 0 ELSE 1 END — eliminates cross-database NULL sort inconsistency
Production Insight
A support team was working from a queue sorted alphabetically on status_code. Cancelled orders appeared first because 'C' sorts before 'D', 'P', and 'S'. The Pending orders — the ones requiring immediate action — were near the bottom of the list. Over one week the team missed 12 Pending orders entirely because they stopped scrolling before reaching them. The fix was a single CASE expression in ORDER BY mapping statuses to numeric priorities. One line of SQL eliminated a process problem that had been blamed on the team rather than the tool.
The secondary lesson from that incident: sort order is a product requirement, not a database detail. When a business says 'show me the most urgent items first', that requirement belongs in the query, not in application code that runs after the database returns unsorted rows. Pushing it into the application means every consumer of that data has to implement the same sort logic independently — and they will implement it differently.
Key Takeaway
CASE in ORDER BY maps business labels to numeric sort priorities — the database handles ordering correctly, no application-side re-sorting required.
It is the portable solution to the NULL sort position inconsistency across databases.
Rule: if the required sort order does not match alphabetical or numeric order on any column, CASE in ORDER BY is the fix — not post-processing in application code, and not a separate sort step after the query.

Common Mistakes That Create Silent, Hard-to-Debug Bugs

The worst bugs in SQL are not the ones that throw errors — those are easy to find. The dangerous ones return wrong data successfully, with a green status code, and the only evidence that something is wrong is in the numbers themselves. CASE has two classic silent-failure modes that affect developers who have been writing SQL for years, not just beginners.

Missing ELSE and the NULL problem. When none of your WHEN conditions match and you have not written an ELSE clause, SQL does not throw an exception. It returns NULL. If that NULL feeds into a SUM, the row disappears from the total. If it feeds into a JOIN condition, the row vanishes from the result set. If it surfaces in application code, it might cause a NullPointerException in Java, a TypeError in Python, or just an empty string in the UI. In every case, the query that produced it returned HTTP 200 with a successful result. The damage is done quietly.

The correct response to this is to always write an ELSE clause, even in situations where you are confident you have covered every value. If you are covering status codes 'P', 'S', 'D', 'C' and you are certain those are the only four that exist, write ELSE 'UNEXPECTED: ' || status_code anyway. When a new status code ships three months from now — and it will — that sentinel value appears visibly in your output instead of silently producing NULL. A visible anomaly you can diagnose is always better than a silent one you discover at a board meeting.

NULL rows silently fall through in simple CASE. If your column can contain NULL and you write CASE status_code WHEN NULL THEN 'Missing', the WHEN clause will never match. This is not a bug in the database — it is Three-Valued Logic. NULL = NULL evaluates to NULL, not TRUE. CASE only executes a WHEN branch when the condition evaluates to TRUE. NULL is not TRUE, so the branch is skipped and the row falls to ELSE or returns NULL if there is no ELSE.

To explicitly handle NULL rows you must switch to searched CASE and write WHEN status_code IS NULL THEN 'Missing' as its own branch. Place it before the equality checks. CASE stops at the first match, so if a later branch could also match, the NULL check needs to win first. This ordering discipline — NULL check first, narrowest conditions next, broadest condition last — prevents entire classes of silent data errors.

null_handling_in_case.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
-- ============================================================
-- Add a row with NULL status to expose the silent-NULL failure
-- This simulates a real scenario: a workflow bug leaves the
-- status_code unset for orders created during a system outage
-- ============================================================
INSERT INTO orders VALUES (8, 108, 75.00, NULL);

-- ============================================================
-- BAD: no ELSE, no NULL check
-- Order 8 returns NULL silently — no error, no warning
-- If this column feeds a SUM, order 8 disappears from the total
-- If this feeds a JOIN, the row vanishes from the result set
-- ============================================================
SELECT
    order_id,
    CASE status_code
        WHEN 'P' THEN 'Pending'
        WHEN 'S' THEN 'Shipped'
        WHEN 'D' THEN 'Delivered'
        WHEN 'C' THEN 'Cancelled'
        -- No ELSE — order 8 returns NULL here
        -- The query succeeds; the NULL is invisible unless you know to look
    END AS order_status_bad
FROM orders
WHERE order_id = 8;

-- ============================================================
-- ALSO BAD: simple CASE with WHEN NULL — this never matches
-- NULL = NULL evaluates to NULL, not TRUE; the branch is skipped
-- ============================================================
SELECT
    order_id,
    CASE status_code
        WHEN NULL THEN 'Missing'   -- NEVER matches — NULL = NULL is NULL, not TRUE
        WHEN 'P'  THEN 'Pending'
        ELSE 'Other'
    END AS wrong_null_handling
FROM orders
WHERE order_id = 8;
-- Returns: 'Other' — not 'Missing'; the WHEN NULL branch is silently skipped

-- ============================================================
-- GOOD: searched CASE with explicit IS NULL check first,
-- then equality branches, then a sentinel ELSE
-- ============================================================
SELECT
    order_id,
    CASE
        WHEN status_code IS NULL THEN 'Status Not Set'          -- catches NULLs first
        WHEN status_code = 'P'   THEN 'Pending'
        WHEN status_code = 'S'   THEN 'Shipped'
        WHEN status_code = 'D'   THEN 'Delivered'
        WHEN status_code = 'C'   THEN 'Cancelled'
        ELSE 'UNEXPECTED: ' || status_code  -- visible sentinel for future enum values
    END AS order_status_safe
FROM orders;

-- ============================================================
-- PROOF: missing ELSE corrupts SUM — both return the same
-- number here because order 8's status is NULL and is excluded
-- from both variants (SUM skips NULL). The important difference
-- is intent and future-proofing: ELSE 0 explicitly handles
-- non-matching rows; no ELSE relies on NULL being skipped.
-- ============================================================
SELECT
    -- Non-matching rows return NULL (no ELSE); SUM skips them silently
    SUM(
        CASE WHEN status_code IN ('P','S','D','C') THEN total_amount END
    ) AS revenue_no_else,

    -- ELSE 0 makes non-matching rows contribute zero explicitly
    SUM(
        CASE WHEN status_code IN ('P','S','D','C') THEN total_amount ELSE 0 END
    ) AS revenue_with_else

FROM orders;
Output
-- BAD query result for order 8 (no ELSE):
order_id | order_status_bad
----------+------------------
8 | NULL ← query succeeded; data is wrong
-- ALSO BAD: WHEN NULL in simple CASE (never matches):
order_id | wrong_null_handling
----------+--------------------
8 | Other ← 'Missing' branch was silently skipped
-- GOOD: searched CASE with IS NULL first:
order_id | order_status_safe
----------+--------------------
8 | Status Not Set
-- SUM comparison (same result here; the ELSE 0 version
-- communicates intent clearly and is protected against
-- future status codes being incorrectly excluded):
revenue_no_else | revenue_with_else
-----------------+-------------------
4267.99 | 4267.99
Why Missing ELSE Is Silent
  • No WHEN matches and no ELSE written: the expression returns NULL, query succeeds, no exception
  • NULL in SUM is silently skipped — the row's value disappears from your total
  • NULL in a JOIN ON condition makes the row vanish from the result set entirely — the JOIN treats it as non-matching
  • NULL surfacing in application code (Java, Python, JavaScript) may throw a NullPointerException, produce empty strings, or silently write zero to a downstream record
  • Write ELSE 'UNEXPECTED: ' || column to turn a silent NULL into a visible, diagnosable anomaly that shows exactly which value caused it
Production Insight
The production incident at the top of this article — $2.4M APAC revenue disappearing from an executive dashboard — came down to a missing ELSE clause and a new status code that nobody told the CASE expression about. The query ran. The job completed. The dashboard refreshed. Everything looked green. The number was completely wrong.
Engineering spent six hours tracing the NULL propagation after the CFO flagged it. The fix was four characters: ELSE 0. The investigation and postmortem cost more engineering time than a year of writing ELSE clauses on every CASE expression in the codebase.
The second lesson from that incident: the assumption 'we control all inserts' is always temporary. Feature launches, migrations, third-party integrations, and data imports introduce new values into columns that your CASE expressions do not know about. The ELSE clause is not defensive programming for edge cases — it is the correct way to write a CASE expression in a codebase that will continue to change.
Key Takeaway
Missing ELSE returns NULL silently — no error, no warning, no stack trace, just wrong data that succeeds all the way to the user.
WHEN NULL never matches in simple CASE — you must use searched CASE with WHEN col IS NULL as the first branch.
Rule: always write ELSE. ELSE 'UNEXPECTED: ' || column turns a silent, invisible bug into a visible anomaly you can actually find.
● Production incidentPOST-MORTEMseverity: high

Revenue dashboard showed $0 for an entire region due to missing ELSE in CASE

Symptom
APAC revenue dropped from $2.4M to $0 overnight on the executive dashboard. No alert fired. The query returned a successful result set — it just contained wrong numbers. Nobody in engineering noticed because the monitoring was built around query errors and execution time, not data correctness. The CFO spotted the discrepancy during a board meeting while comparing the dashboard against a printed report from the previous quarter.
Assumption
The engineering team had written the CASE expression six months earlier when the order lifecycle had four states: Pending, Shipped, Delivered, and Cancelled. They were confident they controlled all inserts and would never see an unexpected status code. The ELSE clause felt unnecessary. That assumption held until a new refund workflow shipped.
Root cause
A new refund workflow introduced status_code 'R' for Returned orders. The CASE expression inside SUM — which summed total_amount only for known status codes — had no ELSE clause. For every returned order, the CASE expression returned NULL. SUM ignores NULLs by design, so those rows were silently excluded from the revenue total with no error and no warning. APAC had the highest return rate in the business at 18%, which is why the discrepancy was largest there and most visible. Regions with lower return rates showed smaller but equally incorrect totals that nobody had caught yet.
Fix
Three changes shipped together. ELSE 0 was added to every CASE expression inside SUM across all dashboard queries. A daily monitoring query was added that scans the orders table for status_code values not present in a known-values table — any new code triggers an alert before it reaches a dashboard. A parity check was added that compares SUM(total_amount) with and without the CASE filter on a 24-hour lag; if they diverge by more than 1%, an alert fires to the data engineering on-call.
Key lesson
  • Always write ELSE in every CASE expression — even when you control all inserts and believe you have covered every value
  • Missing ELSE does not error; it returns NULL, which SUM silently skips — the query succeeds with wrong data
  • New enum values introduced by feature launches will break every existing CASE expression that lacks ELSE — this is a near-certain event in any active codebase
  • Monitor CASE-dependent dashboards with a parity check against raw column totals — a divergence of more than a rounding error means a CASE expression is silently excluding rows
Production debug guideSymptom to action mapping for common CASE-related production issues5 entries
Symptom · 01
Aggregate returns NULL or an unexpectedly low number for a subset of rows
Fix
Check every CASE expression feeding that aggregate for a missing ELSE clause. The diagnostic query is: SELECT COUNT(*) AS total_rows, COUNT(CASE WHEN your_condition THEN 1 END) AS matching_rows FROM your_table. If total_rows and matching_rows differ significantly, the gap represents rows that fell through to NULL and were silently excluded. Add ELSE 0 for SUM, ELSE NULL for COUNT to make the intent explicit and the exclusion visible.
Symptom · 02
COUNT returns total row count instead of a conditional count
Fix
You wrote ELSE 0 inside COUNT. COUNT counts every non-NULL value, and 0 is not NULL, so every non-matching row is still counted. Remove the ELSE entirely or change it to ELSE NULL. The correct pattern is COUNT(CASE WHEN condition THEN 1 END) with no ELSE — NULL is the default when no branch matches, and COUNT skips NULLs automatically.
Symptom · 03
NULL rows never appear in CASE results even though you wrote a WHEN clause for them
Fix
You are using simple CASE syntax (CASE col WHEN val THEN ...). Simple CASE compares using equality, and NULL = val evaluates to NULL, never TRUE. Switch to searched CASE and add WHEN col IS NULL THEN ... as the first branch before any equality checks. Place the NULL check first because CASE stops at the first match — if a later branch could also match, the NULL check needs to win.
Symptom · 04
Query works correctly in PostgreSQL but returns wrong sort order or fails in MySQL or SQL Server
Fix
You referenced a SELECT alias inside ORDER BY or GROUP BY. PostgreSQL allows this as an extension; most other databases do not. Repeat the full CASE expression verbatim in the ORDER BY and GROUP BY clauses. It is more verbose but is the only approach that is portable across all major databases.
Symptom · 05
Dashboard shows stale or incorrect category labels after a schema migration or new enum value was added
Fix
A CASE expression hardcodes status values and has no ELSE or has ELSE NULL, so new values fall through silently. Add ELSE 'UNMAPPED: ' || column_name to the CASE expression immediately — this surfaces unexpected values as visible strings in the output rather than as NULLs that vanish into aggregates. Then add the new value to the WHEN list and remove the debug ELSE before the next release.
Simple CASE vs Searched CASE
AspectSimple CASESearched CASE
Syntax formCASE column WHEN val THEN result END — column named once at the topCASE WHEN condition THEN result END — no column named; each WHEN is a full boolean expression
Condition type supportedEquality only (=) — the database implicitly compares the named column against each WHEN valueAny boolean: >, <, >=, <=, BETWEEN, IS NULL, IS NOT NULL, IN, NOT IN, LIKE, AND, OR, function calls
Multiple columns per WHENNo — locked to the single expression named at the top of the CASEYes — each WHEN can reference any combination of columns, functions, or subqueries
NULL handlingWHEN NULL never matches because NULL = NULL evaluates to NULL, not TRUE — NULL rows fall to ELSE or return NULLAdd WHEN col IS NULL THEN ... as an explicit branch; must come before equality checks to win on first-match evaluation
Readability for simple enum mappingCleaner and easier to scan — one column named, each WHEN is just a valueMore verbose for simple lookups — full condition repeated per WHEN
Range and conditional logicCannot express ranges — WHEN 50 means equality to 50, not greater-than-or-equal; returns NULL silently for non-matching rowsFull support — BETWEEN, >, <, and compound conditions work exactly as expected
PerformanceIdentical — same execution plan as searched CASE for equivalent logicIdentical — same execution plan as simple CASE for equivalent logic
Use in ORDER BY for priority sortingYes — CASE status WHEN 'P' THEN 1 WHEN 'S' THEN 2 is concise for this patternYes — preferred when sort priority depends on a range or multi-column condition
Use inside aggregate functionsYes — works for simple equality-based conditional aggregatesYes — preferred; handles NULL inputs and complex conditions cleanly

Key takeaways

1
Simple CASE is equality-only sugar. The moment you need a range, a NULL check, a function result, or conditions across more than one column, switch to searched CASE
simple CASE will silently return NULL rather than telling you it cannot express the logic.
2
CASE inside COUNT requires ELSE NULL or no ELSE at all
writing ELSE 0 makes COUNT return the total row count for every group and silently inflates every conditional metric on your dashboard with no error and no warning.
3
Always write an ELSE clause
a missing ELSE does not error, it returns NULL, and that NULL silently corrupts SUM totals, vanishes rows from JOINs, and surfaces as crashes or empty fields in application code. Write ELSE 'UNEXPECTED: ' || column to make the failure visible rather than silent.
4
CASE in ORDER BY is the clean, portable way to enforce business-defined sort priority
numeric priority mapping in ORDER BY, invisible in the result set, works across all SQL databases, and handles NULL sort position portably without database-specific NULLS FIRST or NULLS LAST syntax.

Common mistakes to avoid

5 patterns
×

Omitting ELSE entirely

Symptom
A new status code, a NULL value, or any value not covered by a WHEN clause causes CASE to return NULL silently. That NULL propagates into SUM (the row's value is skipped from the total), into a JOIN ON clause (the row vanishes from the result set), or into application code (NullPointerException, empty string, or a zero written to a downstream record). The query returns a successful result. No error is raised. The data is wrong.
Fix
Always write ELSE in every CASE expression without exception. Use ELSE 'UNEXPECTED: ' || column_name to turn a silent NULL into a visible, diagnosable sentinel value. For CASE inside SUM, use ELSE 0 to make non-matching rows contribute zero explicitly. Treat the ELSE clause as mandatory — not as defensive code for edge cases, but as the correct and complete form of the expression.
×

Using ELSE 0 inside COUNT instead of no ELSE or ELSE NULL

Symptom
COUNT returns the total row count instead of the conditional count. Every non-matching row returns 0 from the CASE expression, and 0 is not NULL, so COUNT counts it. The result is that the conditional COUNT is identical to COUNT(*) for every group. Dashboard metrics are uniformly inflated and wrong with no error or warning.
Fix
Inside COUNT, write CASE WHEN condition THEN 1 END with no ELSE clause. The implicit NULL for non-matching rows is what makes COUNT skip them. If you want to be explicit, write ELSE NULL. Never write ELSE 0 inside COUNT. The asymmetry — ELSE 0 is correct in SUM, catastrophic in COUNT — is the most common production CASE bug and is worth adding to your team's code review checklist.
×

Expecting simple CASE to match NULL values with WHEN NULL

Symptom
Rows where the compared column is NULL silently fall through every WHEN clause, including WHEN NULL. They reach ELSE or return NULL if no ELSE exists. The developer expected 'Missing' to appear for NULL rows; instead those rows show whatever ELSE produces, or NULL if ELSE is absent.
Fix
Switch to searched CASE and add WHEN col IS NULL THEN 'Missing' as the first WHEN branch, before any equality checks. Simple CASE uses equality comparison internally and NULL = anything evaluates to NULL, never TRUE. Only the IS NULL operator correctly identifies NULL values. Place the NULL check first because CASE stops at the first match.
×

Referencing a SELECT alias in GROUP BY or ORDER BY across databases

Symptom
Query works correctly in PostgreSQL (which allows alias references in GROUP BY and ORDER BY as an extension) but fails to compile or returns wrong results in MySQL, SQL Server, or Oracle. The alias reference is non-standard SQL behaviour.
Fix
Repeat the full CASE expression verbatim in GROUP BY and ORDER BY. Do not reference the alias defined in SELECT. This is more verbose but is the only approach that is portable across all major databases without database-specific workarounds.
×

Writing WHEN conditions out of order in range bucketing

Symptom
A row with total_amount = 500 matches 'Medium' instead of 'Large' because WHEN total_amount BETWEEN 50 AND 999 is listed before WHEN total_amount >= 500. CASE stops at the first TRUE match regardless of how specific or general the condition is. The later, more restrictive WHEN never executes for rows already matched.
Fix
Order WHEN clauses from most restrictive to least restrictive. For ranges: put NULL checks first, then the narrowest range, then progressively broader ranges, then the catch-all ELSE. For status codes: order does not technically matter as long as the values are mutually exclusive, but consistent ordering (alphabetical or by business priority) makes the expression easier to audit.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between a simple CASE and a searched CASE in SQL,...
Q02SENIOR
How would you count the number of orders in each status category — Pendi...
Q03SENIOR
If a CASE expression has no ELSE clause and none of the WHEN conditions ...
Q04SENIOR
Explain why CASE WHEN col = 'value' never matches NULL rows, and how to ...
Q05JUNIOR
How would you use CASE to implement a custom sort order in SQL — for exa...
Q06SENIOR
You're building a dashboard that shows revenue broken down by order tier...
Q01 of 06JUNIOR

What is the difference between a simple CASE and a searched CASE in SQL, and when would you choose one over the other?

ANSWER
Simple CASE names one expression at the top and compares it against fixed values using equality: CASE col WHEN 'A' THEN 'Label A' WHEN 'B' THEN 'Label B' END. Every WHEN is implicitly an equality check against that one column. Searched CASE evaluates a complete boolean expression per WHEN with no column named at the top: CASE WHEN col > 100 THEN 'Large' WHEN col BETWEEN 50 AND 100 THEN 'Medium' END. Each WHEN can reference any column, use any operator, or combine conditions with AND and OR. Simple CASE is appropriate when every condition is a pure equality check against fixed constants on one column — status code translation is the canonical example. Searched CASE is required the moment any condition needs a range operator, an IS NULL check, a function call, or references more than one column. Simple CASE cannot express those things; it will not error, but it will silently return NULL for rows that do not exactly match a WHEN value. Both forms produce identical execution plans. The choice is purely about what logic you can express and how readable the result is in code review.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can I use a SQL CASE statement in a WHERE clause?
02
Does SQL CASE short-circuit like an if-else in programming languages?
03
What is the difference between CASE and IIF or IF in SQL?
04
Can CASE expressions be nested inside each other?
05
How does CASE interact with database indexes?
🔥

That's SQL Basics. Mark it forged?

8 min read · try the examples if you haven't

Previous
SQL NULL Handling
13 / 16 · SQL Basics
Next
SQL Date and Time Functions