SQL Conditional Aggregation: SUM CASE WHEN and Pivot Tables
Conditional aggregation puts a CASE inside SUM, COUNT or AVG to count several conditions in one pass: pivoting rows into columns, and rates with AVG.
- Course: SQL study plan
- Module: Aggregation
- Kind: Lesson
- Reading time: 14 min
- Runtime: MySQL 8 syntax on SQLite 3.49
What is conditional aggregation in SQL?
Conditional aggregation puts a condition inside an aggregate, usually as SUM(CASE WHEN condition THEN 1 ELSE 0 END) or COUNT(CASE WHEN condition THEN 1 END), so one query counts or totals several subsets at once. It turns rows into columns — paid, pending and refunded orders side by side per month — and keeps groups that a WHERE filter would have removed.
Lesson
WHERE and GROUP BY answer one question per query: how many paid orders per month? Reports usually want several answers side by side — paid, pending and refunded orders per month, in three columns, with a zero where there were none. Running three queries and gluing the results together is slow and clumsy. Conditional aggregation does it in one pass: put the condition inside the aggregate. It is the technique behind pivot tables, rates and percentages, and a large family of interview questions (monthly transaction reports, approval rates, "count by status").
The idea: an aggregate over a CASE
An aggregate runs over the values of an expression. Make the expression 1 when a row qualifies and 0 when it does not, and SUM counts the qualifying rows:
SELECT DATE_FORMAT(created_on, '%Y-%m') AS month,
COUNT(*) AS orders,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) AS refunded
FROM orders
GROUP BY DATE_FORMAT(created_on, '%Y-%m')
ORDER BY month;
| month | orders | paid | pending | refunded |
|---|---|---|---|---|
| 2024-01 | 4 | 3 | 0 | 1 |
| 2024-02 | 3 | 1 | 2 | 0 |
Every row of every month goes through all three CASEs; each row adds 1 to exactly one column. The month with no pending orders shows 0 — it did not disappear, which is what a WHERE status = 'pending' would have done to it.
Three spellings of a conditional count
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) -- standard, everywhere
COUNT(CASE WHEN status = 'paid' THEN 1 END) -- standard: no ELSE → NULL → not counted
SUM(status = 'paid') -- MySQL only: a comparison is 1 or 0
The COUNT form relies on CASE returning NULL when no branch matches and COUNT(expr) skipping NULLs. Writing ELSE 0 there would be a bug — COUNT counts zeros. The MySQL shortcut is compact and common in MySQL code, but PostgreSQL rejects SUM of a boolean and SQL Server has no boolean expressions in that position, so use CASE in anything meant to travel. Note one difference: if status is NULL, SUM(status = 'paid') adds NULL (skipped), while the CASE form adds 0 — the totals are the same.
Conditional sums of amounts
Return the amount instead of 1 and you get conditional totals:
SELECT customer_id,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS paid_amount,
SUM(CASE WHEN status = 'refunded' THEN amount ELSE 0 END) AS refunded_amount,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END)
- SUM(CASE WHEN status = 'refunded' THEN amount ELSE 0 END) AS net
FROM orders
GROUP BY customer_id
ORDER BY customer_id;
Use ELSE 0 here rather than leaving it out: without it a customer with no refunds gets NULL for refunded_amount, and net becomes NULL too, because any arithmetic with NULL is NULL.
Pivoting rows into columns
Turning the values of one column into separate output columns is called a pivot. Quarterly sales stored one row per sale become one row per product with a column per quarter:
SELECT product,
SUM(CASE WHEN QUARTER(sold_on) = 1 THEN amount ELSE 0 END) AS q1,
SUM(CASE WHEN QUARTER(sold_on) = 2 THEN amount ELSE 0 END) AS q2,
SUM(CASE WHEN QUARTER(sold_on) = 3 THEN amount ELSE 0 END) AS q3,
SUM(CASE WHEN QUARTER(sold_on) = 4 THEN amount ELSE 0 END) AS q4
FROM sales
WHERE sold_on >= '2024-01-01' AND sold_on < '2025-01-01'
GROUP BY product
ORDER BY product;
The rows of the output are the GROUP BY; the columns are the CASEs. MySQL has no PIVOT keyword (SQL Server does, and PostgreSQL has crosstab in an extension), so this pattern is how MySQL pivots — and it works on every database. Its limitation is that the columns must be known when the query is written; a pivot over values that change needs the query built by application code.
MAX(CASE WHEN … THEN value END) is the pivot form for non-numeric values — for example turning key/value rows (attribute = 'color', value = 'red') into one row per item with a column per attribute. Each group has at most one matching row, and MAX just picks it out of the NULLs.
Rates and percentages
A rate is a conditional count divided by a count:
SELECT country,
COUNT(*) AS trans_count,
SUM(CASE WHEN state = 'approved' THEN 1 ELSE 0 END) AS approved_count,
ROUND(100 * SUM(CASE WHEN state = 'approved' THEN 1 ELSE 0 END) / COUNT(*), 1) AS approval_pct
FROM transactions
GROUP BY country
ORDER BY country;
AVG does the division for you: the average of a column of 1s and 0s is the fraction of 1s.
ROUND(100 * AVG(CASE WHEN state = 'approved' THEN 1 ELSE 0 END), 1) AS approval_pct
ROUND(AVG(rating >= 4), 2) -- MySQL: share of good ratings
Two cautions. In PostgreSQL and SQL Server integer division turns SUM(…) / COUNT(*) into 0 — write 100.0 *. And AVG(CASE WHEN … THEN 1 END) without ELSE 0 averages only the 1s and always returns 1; the ELSE 0 is what puts the non-matching rows in the denominator.
Conditional MIN, MAX and DISTINCT counts
Any aggregate takes a CASE:
SELECT customer_id,
MIN(CASE WHEN status = 'paid' THEN created_on END) AS first_paid,
MAX(CASE WHEN status = 'refunded' THEN created_on END) AS last_refund,
COUNT(DISTINCT CASE WHEN status = 'paid' THEN product_id END) AS products_bought
FROM orders
GROUP BY customer_id
ORDER BY customer_id;
Here the absence of ELSE is deliberate: non-matching rows give NULL, and MIN, MAX and COUNT(DISTINCT …) skip NULLs. A customer with no refunds gets NULL for last_refund, which is the honest answer.
Filtering on conditional aggregates
Conditional aggregates work in HAVING like any other:
SELECT customer_id
FROM orders
GROUP BY customer_id
HAVING SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) = 0 -- never refunded
AND COUNT(*) >= 2
ORDER BY customer_id;
"Customers who bought A but never B" is the same shape: HAVING SUM(CASE WHEN product = 'A' THEN 1 ELSE 0 END) > 0 AND SUM(CASE WHEN product = 'B' THEN 1 ELSE 0 END) = 0.
Standard SQL also defines a FILTER clause — COUNT(*) FILTER (WHERE status = 'paid') — that PostgreSQL and SQLite support. MySQL does not, so in MySQL the CASE form is the way.
Pitfalls
COUNT(CASE WHEN … THEN 1 ELSE 0 END)counts every row — drop theELSEor useSUM.AVG(CASE WHEN … THEN 1 END)withoutELSE 0is always 1.- Conditional sums without
ELSE 0turning totals into NULL. - Integer division in rates outside MySQL.
- Using
WHEREfor one condition when every group should stay.
Key takeaways
SUM(CASE WHEN cond THEN 1 ELSE 0 END)counts matching rows inside each group.- Several conditional aggregates side by side pivot rows into columns.
AVGover a 1/0CASEis a rate; multiply by 100 for a percentage.MIN/MAX/COUNT(DISTINCT)over aCASEwith noELSEpick from the matching rows only.- MySQL's
SUM(cond)shortcut is not portable;CASEis.
Common questions
How do I count rows that match a condition in SQL?
Use SUM(CASE WHEN condition THEN 1 ELSE 0 END) or COUNT(CASE WHEN condition THEN 1 END); the second works because CASE without ELSE gives NULL, which COUNT skips. In MySQL a comparison is already 1 or 0, so SUM(status = 'paid') is a shorter version that does not work in PostgreSQL or SQL Server.
How do I pivot rows into columns in MySQL?
MySQL has no PIVOT keyword, so write one conditional aggregate per output column: SUM(CASE WHEN quarter = 1 THEN amount ELSE 0 END) AS q1, then the same for q2, q3 and q4, grouped by the row label. SQL Server has a PIVOT operator and PostgreSQL a crosstab function, but the CASE form works everywhere.
How do I calculate a percentage or rate with GROUP BY?
Divide a conditional count by the group's count: ROUND(100 * SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) / COUNT(*), 1). AVG(CASE WHEN … THEN 1 ELSE 0 END) gives the same fraction directly. In PostgreSQL and SQL Server multiply by 100.0, or integer division turns every rate into 0.
Why use SUM CASE instead of WHERE with GROUP BY?
WHERE removes rows before grouping, so a group with no matching rows disappears and only one condition can be counted per query. SUM(CASE …) keeps every group, shows a zero where nothing matched, and can count many conditions side by side in a single pass over the table.
Exercises
Orders by status, per month
Pivot orders into one row per month with a column per status.
| id | created_on | status | amount |
|---|---|---|---|
| 1 | 2024-01-03 | paid | 10 |
| 2 | 2024-01-09 | paid | 20 |
| 3 | 2024-01-15 | refunded | 30 |
| 4 | 2024-01-30 | paid | 40 |
| 5 | 2024-02-02 | pending | 50 |
| 6 | 2024-02-11 | paid | 60 |
| 7 | 2024-02-28 | pending | 70 |
| 8 | 2024-03-01 | refunded | 80 |
Return month (2024-01), orders (all orders that month), paid, pending and refunded (the count of each status, 0 when there are none). Any other status counts only in orders. Order by month.
month | orders | paid | pending | refunded
2024-01 | 4 | 3 | 0 | 1
2024-02 | 3 | 1 | 2 | 0
2024-03 | 1 | 0 | 0 | 1Approval rate by country
Each transaction is approved or declined.
| id | country | state | amount |
|---|---|---|---|
| 1 | IN | approved | 100 |
| 2 | IN | declined | 50 |
| 3 | IN | approved | 300 |
| 4 | US | declined | 20 |
| 5 | US | approved | 80 |
| 6 | US | declined | 40 |
| 7 | DE | declined | 10 |
Return country, trans_count, approved_pct — the percentage of transactions approved, rounded to 1 decimal — and avg_approved, the average amount of the approved transactions only (rounded to 2 decimals, NULL when there are none). Order by approved_pct descending, then country.
country | trans_count | approved_pct | avg_approved
IN | 3 | 66.7 | 200
US | 3 | 33.3 | 80
DE | 1 | 0 | NULLIn this module: Aggregation
- Aggregate functions — COUNT, SUM, AVG, MIN and MAX
- GROUP BY — one summary row per group
- HAVING — filtering groups
- Conditional aggregation — SUM(CASE …) and pivots (this lesson)
- GROUP_CONCAT — a group's values as one string
- Grouping pitfalls — ONLY_FULL_GROUP_BY, COUNT and averages
- Checkpoint — Aggregation
← HAVING — filtering groups · GROUP_CONCAT — a group's values as one string →