Using SUM(CASE WHEN …), how do you count shipped and cancelled orders per customer?
answer
- one query, several different measures
- the condition moves inside the aggregate
- each row contributes 1 or 0
- SUM of ones equals a count
- SUM(CASE WHEN cond THEN 1 ELSE 0 END)
basics
~20 sPut 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 sConditional 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 linesSELECT 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 statusgo deeper
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.
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.
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.
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