Conditional Aggregation: Beginner
When the Output Is a Pivot-Like Summary
Spot questions that need multiple metrics from a single pass: "count of X where status=A, count where status=B."
Here is the question every interview asks once you can do basic GROUP BY: 'show me one row per region, with separate columns for active customer count, inactive customer count, and total revenue, all from the same customers table.' The candidate who writes three separate queries and UNIONs them produces something that works but is unreadable. The candidate who writes 'SELECT region, COUNT(CASE WHEN status='active' THEN 1 END) AS active, ..., SUM(amount) AS revenue FROM customers GROUP BY region' produces a single readable query. The trick is the CASE inside the aggregate. That single pattern is the foundation of every KPI dashboard query in production data engineering.
- ▸"one row per group, with separate columns for each segment"
- ▸"count active customers and total customers in the same row"
- ▸"pivot this long table into a wide one"
- ▸"revenue by region, split into premium and standard tiers"
- ▸"conversion rate per cohort with the numerator and denominator visible"
- ▸Any question where the output has columns that count or sum subsets of the input
What conditional aggregation actually does
Spotting the pattern in the first ten seconds
- ▸"This is conditional aggregation."
- ▸"One GROUP BY with conditional aggregates per slice."
- ▸"SUM(CASE WHEN ... THEN amount END) for sums; COUNT(CASE WHEN ... THEN 1 END) for counts."
- ▸"One row per group with multiple slice columns."
- Writes three separate SELECT queries (active, inactive, revenue) and UNIONs them
- Returns three rows per region instead of one
- Has to be corrected when the interviewer asks for one row per group
- Wastes time recovering from a wrong query shape
- Says "this is conditional aggregation, one GROUP BY with multiple CASE-wrapped aggregates"
- Writes a single query with SUM(CASE...) and COUNT(CASE...) expressions
- Produces one row per group with multiple slice columns
- Has working SQL on the page within ninety seconds
Why companies care
SUM(CASE WHEN ... THEN 1 ELSE 0 END)
Write conditional aggregation patterns for counts, sums, averages, and ratios in a single GROUP BY.
- sum of values from matching rows. Non-matching contribute 0.
- same result; non-matching return NULL which SUM ignores. Either ELSE or no ELSE works.
- the unsliced total in the same row; lets the consumer reconcile the slices against the whole.
The canonical query
Reading the pattern
The ELSE 0 vs ELSE NULL choice
Walk through a small example
| region | premium_revenue | standard_revenue | total_revenue | order_count |
|---|---|---|---|---|
| North | 300 | 50 | 350 | 3 |
| South | 150 | 700 | 850 | 3 |
Multiple conditions in one CASE
Counting Subsets Without Extra Queries
Use the FILTER clause (Postgres/DuckDB) as a cleaner alternative and know which dialects support it.
- ▸COUNT(*) counts every row in the group
- ▸COUNT(column) skips rows where column is NULL
- ▸COUNT(CASE WHEN cond THEN 1 END) skips non-matching rows (they return NULL)
- ▸Adding ELSE 0 to the COUNT CASE would count every row; convention is no ELSE
The canonical COUNT pattern
Why no ELSE in the COUNT version
COUNT DISTINCT with CASE for filtered distinct counts
The conversion-rate pattern
- Run one query for total signups
- Run another query for converters
- Compute the ratio in application code
- Two queries, two round trips, two places where the join logic can drift
- One query, one round trip, both numbers in the same row
- Ratio is computed in SQL alongside the absolute values
- Numerator and denominator are visible to the consumer for sanity-checking
- If the join logic changes, both numbers change together; no drift
Sum-of-1 vs Count: equivalent but different intent
The 1 in COUNT(CASE WHEN ... THEN 1 END) is not magic; it is a non-NULL sentinel. You could write COUNT(CASE WHEN ... THEN order_id END) and it would count the same rows (assuming order_id is never NULL for the matching rows). The 1 is the convention because it is short and unambiguous: 'count this row if the condition is true.'
One Pass Replaces Many WHERE Queries
Handle conditional aggregation with multiple dimensions and nested CASE logic without losing readability.
- supported on Postgres, SQLite, DuckDB. Reads as English: SUM(amount) FILTER (WHERE tier = 'premium').
- supported on every engine. Portable for cross-engine code; the lowest common denominator.
- FILTER for codebases targeting one engine; CASE for portable code or when the engine doesn't support FILTER.
The FILTER clause syntax
Why the FILTER form is preferred where supported
When the FILTER form is wrong to reach for
- The engine supports it (Postgres, SQLite, DuckDB)
- Readability is the primary concern
- The codebase already uses FILTER consistently
- The team wants to avoid the ELSE 0 vs ELSE NULL question
- The engine does not support FILTER (Snowflake, BigQuery, SQL Server, MySQL, Redshift)
- The query has to be portable across engines
- The team's house style is CASE
- The condition is complex and would be hard to read in a WHERE clause
Other engine-specific shortcuts
The composability bonus
Reading a CASE-Inside-SUM Out Loud
Explain to the interviewer why conditional aggregation outperforms multiple filtered subqueries joined together.
WHERE filters the rows; CASE filters the contribution
The two operations look similar but produce different results. WHERE tier = 'premium' removes non-premium rows from the source before aggregation; the resulting groups have only premium rows. SUM(CASE WHEN tier = 'premium' THEN amount END) keeps all rows in the source but only sums premium amounts; the resulting groups include non-premium rows in COUNT(*) and in other unconditional aggregates. The difference matters when you need both filtered and unfiltered metrics in the same row.
The NULL-in-aggregate gotcha
Divide-by-zero defense for ratios
Boolean shortcuts on engines that support them
| Situation | Phrasing that flatlines | Phrasing that lands |
|---|---|---|
| You see 'one row per region with metrics by segment' | "I'll write three queries and UNION them." | "This is conditional aggregation. One GROUP BY with SUM(CASE WHEN segment = 'X' THEN amount END) for each slice. One query, one row per group." |
| The interviewer asks 'why CASE instead of WHERE' | "Same thing." | "WHERE filters the source rows before aggregation; CASE filters the contribution inside the aggregate. If the output needs both the slice metric and the total, WHERE would remove the non-slice rows and the total would equal the slice." |
| The data has NULLs in the slice column | "I'll ignore them." | "NULL rows are excluded from every slice's conditional aggregate but included in COUNT(*). The sums won't add up to the total; I'd add an 'unclassified' slice for NULL rows so the math reconciles." |
| The output is a ratio | "I'll divide." | "NULLIF on the denominator. A group with zero matching rows produces a zero denominator; NULLIF converts that to NULL so the ratio is NULL instead of a divide-by-zero error." |
| The engine is Postgres | "I'll use CASE." | "FILTER clause: SUM(amount) FILTER (WHERE tier = 'premium'). Reads cleaner than CASE; supported on Postgres, SQLite, DuckDB. Falls back to CASE on Snowflake, BigQuery, MySQL." |
The closing summary
> You are in a data engineering phone screen at an e-commerce company. The interviewer asks: 'Show me one row per region with active customer count, churned customer count, premium revenue, standard revenue, and total revenue, all from the customers and orders tables.'
GROUP BY pass with one aggregate column per slice, not one query per slice.WHERE removes rows from the group; CASE inside the aggregate removes only that row's contribution. That is why SUM(amount) under a WHERE filter equals the filtered slice instead of the true total.SUM(CASE WHEN cond THEN amount ELSE 0 END) for sums and COUNT(CASE WHEN cond THEN 1 END) with no ELSE for counts, since COUNT counts a 0 but skips a NULL.COUNT(DISTINCT CASE WHEN status = 'active' THEN user_id END), because non-matching rows evaluate to NULL and drop out of the distinct set.NULLIF(COUNT(*), 0) and multiply the numerator by 1.0: that avoids a divide-by-zero error on empty groups and integer truncation on engines that floor integer division.FILTER clause, as in SUM(amount) FILTER (WHERE tier = 'premium'), is cleaner but only runs on Postgres, SQLite, and DuckDB. CASE is the portable form for cross-engine code.CASE inside SUM replaces ten separate queries with one
- Category
- SQL
- Difficulty
- beginner
- Duration
- 25 minutes
- Challenges
- 0 hands-on challenges
Topics covered: When the Output Is a Pivot-Like Summary, SUM(CASE WHEN ... THEN 1 ELSE 0 END), Counting Subsets Without Extra Queries, One Pass Replaces Many WHERE Queries, Reading a CASE-Inside-SUM Out Loud
Lesson Sections
- When the Output Is a Pivot-Like Summary (concepts: sqlConditionalAgg)
What conditional aggregation actually does A normal aggregate (SUM, COUNT, AVG) operates on every row in the group. A conditional aggregate operates on every row in the group but only counts the ones matching the CASE condition. SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) counts active rows. SUM(CASE WHEN status='active' THEN amount ELSE 0 END) sums amounts from active rows. The CASE inside the aggregate filters what contributes to the sum, while the row itself stays in the group. This is w
- SUM(CASE WHEN ... THEN 1 ELSE 0 END) (concepts: sqlConditionalAgg)
The fundamental form is SUM(CASE WHEN condition THEN value ELSE 0 END). The CASE returns the value when the condition is TRUE, and 0 otherwise. SUM adds them all up. The result is the sum of values from matching rows; non-matching rows contribute 0 and do not affect the total. The pattern works because SUM ignores NULL and treats 0 as additive identity; both routes (NULL or 0) give the right answer. The canonical query Reading the pattern GROUP BY region collapses the orders to one row per regio
- Counting Subsets Without Extra Queries (concepts: sqlConditionalAgg)
COUNT has a quirk that makes the conditional pattern slightly different from SUM. COUNT(*) counts every row in the group. COUNT(column) counts rows where column is not NULL. COUNT(CASE WHEN condition THEN 1 END) counts rows where the condition is TRUE (because non-matching rows return NULL, which COUNT skips). The 1 in the THEN is a sentinel; any non-NULL value works (COUNT does not care about the value, only that it is non-NULL). The canonical COUNT pattern Why no ELSE in the COUNT version In C
- One Pass Replaces Many WHERE Queries (concepts: sqlConditionalAgg)
Modern SQL has a cleaner syntax for conditional aggregation: the FILTER clause. SUM(amount) FILTER (WHERE tier = 'premium') is equivalent to SUM(CASE WHEN tier = 'premium' THEN amount END), but it reads as English and separates the aggregate from the filter visually. The FILTER clause is in the SQL standard since 2003 and supported on Postgres, SQLite, and DuckDB. It is not supported on MySQL, SQL Server, BigQuery, Snowflake, or Redshift. State the engine when you reach for it: 'on Postgres I wo
- Reading a CASE-Inside-SUM Out Loud (concepts: sqlConditionalAgg)
Past correctness, the interviewer probes whether you understand the difference between filtering with WHERE and filtering inside an aggregate. The two produce different shapes; mixing them up is a common bug. This section covers the WHERE vs CASE distinction, the NULL-in-aggregate gotcha, and the divide-by-zero defense for ratios. WHERE filters the rows; CASE filters the contribution The CASE form is the right pattern when the output needs metrics across multiple slices. The WHERE form is the ri