skip to content

Conditional Aggregation with CASE

Wrapping CASE inside SUM or COUNT turns row conditions into per-group measures — the portable way to pivot rows into columns without a PIVOT dialect. This pattern shows up in nearly every hands-on SQL interview, from cohort counts to status breakdowns.

part ofSQLoverview, primer and where to startread it →
on this pageshow

questions

5

Using SUM(CASE WHEN …), how do you count shipped and cancelled orders per customer?

level: juniorimportance: must knowfreq 78%

answer

  1. one query, several different measures
  2. the condition moves inside the aggregate
  3. each row contributes 1 or 0
  4. SUM of ones equals a count
  5. SUM(CASE WHEN cond THEN 1 ELSE 0 END)

basics

~20 s

Put a CASE inside the aggregate. SUM(CASE WHEN status = 'shipped' THEN 1 ELSE 0 END) contributes 1 for each matching row and 0 for the rest, so one GROUP BY query returns a separate count per condition.

solid answer

~50 s

Conditional aggregation wraps a `CASE` expression in an aggregate so each row contributes a different value depending on a condition. `SELECT customer_id, SUM(CASE WHEN status = 'shipped' THEN 1 ELSE 0 END) AS shipped, SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled, COUNT(*) AS total FROM orders GROUP BY customer_id` reads the table once and returns all three measures side by side. `CASE` is evaluated per row before aggregation, producing a stream of 1s and 0s that `SUM` adds up — so the sum equals the number of matching rows in the group. The same shape works for money instead of flags: `SUM(CASE WHEN status = 'shipped' THEN amount ELSE 0 END)`. Crucially the `GROUP BY` still sees every row, so a customer with no shipped orders still appears, with a 0.

code

sql · 7 lines
sql
SELECT customer_id,
       SUM(CASE WHEN status = 'shipped'   THEN 1 ELSE 0 END) AS shipped,
       SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled,
       COUNT(*)                                              AS total_orders
FROM orders
GROUP BY customer_id;
-- one row per customer, one column per status

go deeper

for a junior

Memorise the shape SUM(CASE WHEN cond THEN 1 ELSE 0 END) and be able to type it into a GROUP BY query on the spot; this is a common live-coding screener.

for a middle

Explain the mechanics: CASE is evaluated per row before aggregation, so the aggregate sees a column of 1s and 0s, and every row still reaches the grouping step.

for a senior

Show judgment about what belongs in the CASE versus the WHERE clause, and be precise about how ELSE 0 changes an AVG's denominator versus leaving non-matching rows NULL.

for a principal

Be ready to argue when a wide hand-rolled cross-tab in SQL is the right report shape at all, versus returning tidy rows and letting the reporting layer lay them out.

## What conditional aggregation is An aggregate function collapses the rows of a group into one value. Applied plainly, it measures *every* row in the group: `COUNT(*)` counts them all, `SUM(amount)` adds them all. That gives one query one answer per aggregate. Conditional aggregation removes that limit by placing a `CASE` expression inside the aggregate's argument, so each row contributes a value chosen by a condition. The aggregate then measures only the subset you care about, while the grouping still ranges over the whole group. ## The counting idiom ```sql SELECT customer_id, SUM(CASE WHEN status = 'shipped' THEN 1 ELSE 0 END) AS shipped, SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled, COUNT(*) AS total FROM orders GROUP BY customer_id; ``` Read it row by row. For each order, the first `CASE` yields 1 if the status is `'shipped'` and 0 otherwise; `SUM` adds that column of 1s and 0s within the group, so the total equals the number of shipped orders. The second column does the same for cancellations, and `COUNT(*)` gives the group size. One pass over the table produces a small cross-tab: one row per customer, one column per question. ## Why interviewers ask it The naive alternatives are worse. Running three separate `GROUP BY` queries with different `WHERE` clauses and stitching them together in application code costs three round trips and hand-written merge logic. Joining several grouped subqueries together works but is verbose, and any inner side that produces no row for a customer turns into a NULL you have to `COALESCE`. Conditional aggregation expresses all the measures in one `SELECT` list, on one grouping, with no join at all. ## Summing values, not just flags Nothing forces the `THEN` branch to be `1`. Any expression works: ```sql SUM(CASE WHEN status = 'shipped' THEN amount ELSE 0 END) AS shipped_revenue ``` Other aggregates take a conditional argument too. `MAX(CASE WHEN status = 'shipped' THEN created_at END)` gives the latest shipment date per customer, and `MIN`/`MAX` are the usual choice when the matching subset holds at most one interesting value. `AVG` deserves care, because the `ELSE` branch changes the denominator. `AVG(CASE WHEN status = 'shipped' THEN amount ELSE 0 END)` averages shipped amounts *and zeros for every other row*, which is a different statistic from `AVG(CASE WHEN status = 'shipped' THEN amount END)` — the latter leaves non-matching rows NULL, and aggregates skip NULL input, so it averages shipped orders only. Decide deliberately which of those you want. ## Conditions and three-valued logic The `CASE` condition is an ordinary SQL predicate: it can combine `AND`/`OR`, use `BETWEEN`, `IN`, `IS NULL`, and reference any column of the row — including columns that are not in the `GROUP BY` list, because the expression is evaluated before the rows are collapsed. A `WHEN` branch is taken only when its condition is **TRUE**. If a comparison involves NULL and evaluates to unknown, the branch is *not* taken and control falls through to `ELSE`. So `SUM(CASE WHEN discount > 0 THEN 1 ELSE 0 END)` scores a row with a NULL discount as 0, not as a match; if NULL should mean something else, test for it explicitly with `IS NULL`. What the condition cannot do is reference another row or another aggregate — nesting an aggregate inside the `CASE` is not allowed. ## Practical notes A column alias defined in the `SELECT` list cannot be referenced by a sibling expression in that same list, so if you need to combine two conditional sums (say, into a ratio) you either repeat both expressions or compute them in a derived table or CTE and do the arithmetic one level up. Name the output columns. A wide result of anonymous `SUM(CASE …)` expressions is unreadable; `AS shipped`, `AS cancelled` turn it into a report. And keep the condition list short and stable — the columns are fixed at the time you write the query, which is fine for a handful of known statuses and a poor fit for an open-ended set of values. Finally, note what conditional aggregation preserves: because the condition lives inside the aggregate rather than in `WHERE`, every row still reaches the grouping step, so groups that match nothing still appear with a zero instead of vanishing from the result.

  • Does the CASE have to yield 1, or can it yield a measure?
    Any expression works. `SUM(CASE WHEN status = 'shipped' THEN amount ELSE 0 END)` gives shipped revenue, and `MAX(CASE WHEN status = 'shipped' THEN created_at END)` gives the last shipment date. Yielding 1 is just the special case that turns a sum into a count.
  • What happens to a row whose CASE condition evaluates to unknown because of a NULL?
    A `WHEN` branch fires only on TRUE. Unknown is not TRUE, so the row falls through to `ELSE` and is scored as a non-match — with `ELSE 0` it contributes 0. If NULL should count as a match, test it explicitly with `IS NULL` in the condition.
  • Why can't you divide two conditional sums by referring to their aliases in the same SELECT list?
    SQL does not let one `SELECT`-list expression reference an alias defined beside it; aliases are assigned to the output row, not available as inputs. Either repeat both `SUM(CASE …)` expressions in the division, or compute them in a CTE or derived table and do the arithmetic in the outer query.

It is the tally-sheet trick: instead of sorting the pile of orders into separate stacks and counting each stack, you walk the pile once and put a tick in the 'shipped' column or the 'cancelled' column as each order goes by.

saying these in an interview costs you the question

  • Claims you need one query per status and a merge in code
  • Puts the status filter in WHERE and still expects both columns
  • Thinks CASE runs after grouping, on the collapsed group
  • Assumes a row with a NULL column takes the THEN branch
  • Nests an aggregate inside the CASE condition

context

open as a page

How do SUM(CASE WHEN c THEN 1 ELSE 0 END) and COUNT(CASE WHEN c THEN 1 END) differ?

level: middleimportance: must knowfreq 62%

basics

~20 s

Both return the number of rows where the condition is true. COUNT relies on the missing ELSE producing NULL for non-matching rows, which COUNT ignores. Adding ELSE 0 to the COUNT form breaks it: 0 is not NULL, so it counts every row.

open as a page

How do you pivot rows into one column per status without a PIVOT operator?

level: middleimportance: should knowfreq 55%

basics

~20 s

Group by the row key and write one conditional aggregate per output column: SUM(CASE WHEN status = 'new' THEN 1 ELSE 0 END) AS new_count, one per value. Use MAX(CASE …) when each cell holds a single value rather than a total.

open as a page

In one SQL query, how do you compute each customer's cancellation rate with conditional aggregation?

level: middleimportance: should knowfreq 48%

basics

~20 s

Divide a conditional sum by a total in the same GROUP BY: 100.0 * SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) / COUNT(*). Force numeric arithmetic with the 100.0 literal, and guard any conditional denominator with NULLIF.

open as a page

Why does putting the condition in WHERE instead of inside SUM(CASE …) drop zero-count groups?

level: seniorimportance: should knowfreq 42%

basics

~20 s

WHERE removes rows before grouping, so a customer whose orders were all filtered out has no rows left, forms no group, and produces no output row. A condition inside CASE keeps every row in the group and reports a 0 instead.

open as a page