In one SQL query, how do you compute each customer's cancellation rate with conditional aggregation?
answer
- numerator conditional, denominator total
- both computed in the same GROUP BY
- watch the arithmetic type of the division
- a conditional denominator can reach zero
- NULLIF turns 0 into NULL
basics
~20 sDivide 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.
solid answer
~50 sPut both the numerator and the denominator in the same grouped query: `SELECT customer_id, 100.0 * SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) / COUNT(*) AS cancel_pct FROM orders GROUP BY customer_id`. Two details decide whether it is right. First, arithmetic: some engines truncate integer division, so a 3-in-10 rate comes back as 0 — multiply by `100.0` (or `CAST` an operand to a decimal type) so the division happens in numeric arithmetic. Second, the denominator: `COUNT(*)` is at least 1 inside any group, but a *conditional* denominator can be 0, so wrap it as `NULLIF(SUM(CASE …), 0)` and let the result be NULL instead of raising a division-by-zero error. You cannot reference the numerator's alias in the same `SELECT` list, so either repeat the expressions or compute them in a CTE and divide in the outer query.
code
sql · 7 linesSELECT customer_id,
COUNT(*) AS orders,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled,
ROUND(100.0 * SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END)
/ COUNT(*), 2) AS cancel_pct
FROM orders
GROUP BY customer_id;go deeper
Know the shape: a conditional SUM on top, a COUNT(*) underneath, both in the same GROUP BY, with a decimal literal so the division is not integer division.
Explain both traps precisely — integer truncation and a zero denominator — and show the NULLIF guard plus the CTE rewrite that avoids repeating the expressions.
Demonstrate report judgment: emit the numerator and denominator alongside the rate so a 100% rate over one order cannot be mistaken for a signal, and round only at the edge.
Decide where derived metrics live. Rates computed ad hoc in many queries drift apart in definition; own whether they belong in a shared view, the reporting layer, or a modelled metric.
## The shape of a rate A per-group rate is a conditional aggregate over a total, both computed in the same `GROUP BY`: ```sql SELECT customer_id, COUNT(*) AS orders, SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled, 100.0 * SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) / COUNT(*) AS cancel_pct FROM orders GROUP BY customer_id; ``` Every row still reaches the grouping, so the denominator is the customer's full order count and the numerator measures the subset. Exposing the raw numerator and denominator beside the percentage is a habit worth keeping: a rate of 100% reads very differently when the denominator is 1 than when it is 4,000. ## Trap 1: integer division In several engines — PostgreSQL and SQL Server among them — dividing an integer by an integer performs integer division and truncates toward zero, so `3 / 10` is `0` and the whole report reads 0%. Others promote the operands to a decimal type. Because the behavior differs, write the query so it does not depend on the engine: multiply by a decimal literal (`100.0 *`), or cast explicitly with `CAST(SUM(CASE …) AS decimal(10,2))`. Placing the `100.0` at the front of the expression also fixes the ordering problem — multiplying first keeps precision that dividing first would have thrown away. ## Trap 2: a zero denominator `COUNT(*)` cannot be zero inside a group, because a group exists only if it has at least one row. That safety disappears the moment the denominator is itself conditional: ```sql -- refund rate among *paid* orders only SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) / NULLIF(SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END), 0) ``` A customer with no paid orders would divide by zero, and in most engines that raises an error that kills the entire query rather than one row. `NULLIF(x, 0)` returns NULL when `x` is 0, and dividing by NULL yields NULL — so the offending group reports "undefined" and the rest of the report survives. If a literal 0 is wanted instead, wrap the whole expression in `COALESCE(…, 0)`, but consider whether "no denominator" really means "zero percent" or "not applicable". ## The AVG shortcut Because a rate is the mean of a 0/1 indicator, `AVG` expresses it directly: ```sql AVG(CASE WHEN status = 'cancelled' THEN 1.0 ELSE 0.0 END) AS cancel_rate ``` This returns a fraction between 0 and 1 with no explicit division and no zero-denominator risk. Write the branch values as `1.0`/`0.0` rather than `1`/`0`, so the averaging happens in numeric arithmetic regardless of how the engine types integer aggregates. Note the difference from `AVG(CASE WHEN status = 'cancelled' THEN 1.0 END)`: with no `ELSE`, non-matching rows are NULL, aggregates skip NULL input, and the average of a column of 1.0s is always 1.0 — a constant, not a rate. The `ELSE` is what makes this form work. ## Weighted ratios The same structure answers value-weighted questions by putting a measure in the `THEN` branch: ```sql SUM(CASE WHEN status = 'cancelled' THEN amount ELSE 0 END) / NULLIF(SUM(amount), 0) AS cancelled_value_share ``` Here the denominator is an unconditional `SUM`, which *can* legitimately be 0 (or NULL, if every amount is NULL), so the `NULLIF` guard is not optional. ## Readability: aliases do not travel sideways SQL does not let one `SELECT`-list expression reference an alias defined next to it, so the ratio cannot be written as `cancelled / orders` in the same list. The two options are to repeat both expressions inside the division, or to compute the components once and divide one level up: ```sql WITH per_customer AS ( SELECT customer_id, COUNT(*) AS orders, SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled FROM orders GROUP BY customer_id ) SELECT customer_id, orders, cancelled, 100.0 * cancelled / orders AS cancel_pct FROM per_customer; ``` For two measures, repetition is fine; past that the second form is the one people can actually review. ## Rounding and presentation Round at the edge, not in the middle: `ROUND(100.0 * … / COUNT(*), 2)`. Rounding intermediate values before further arithmetic accumulates error, and rounding a rate to an integer percentage hides the difference between 0.4% and 0%. Whether rates belong in SQL at all is a judgment call — the query is the right place when the consumer needs a sorted or filtered ranking of rates, and the wrong place when a dashboard is going to reformat the number anyway.
- Why does the percentage come back as 0 in some engines but 33.33 in others?Because the operands are integers. PostgreSQL and SQL Server perform integer division and truncate `3 / 10` to 0, while other engines promote to a decimal type. Multiplying by `100.0` or casting an operand to a decimal makes the arithmetic numeric everywhere, so the query is portable.
- When is NULLIF unnecessary in the denominator?When the denominator is `COUNT(*)`, which is at least 1 for any group that exists at all. Any conditional denominator — another `SUM(CASE …)` or a `SUM` of a measure — can evaluate to 0 or NULL, and there `NULLIF(x, 0)` is what keeps a division-by-zero error from aborting the whole query.
- What does AVG(CASE WHEN cond THEN 1.0 END) return, without an ELSE?Always 1.0 for any group with at least one match, and NULL otherwise. Non-matching rows are NULL, aggregates skip NULL, so the average is taken over a column of 1.0s only. The `ELSE 0.0` branch is exactly what turns the expression into a rate.
saying these in an interview costs you the question
- Divides two integers and reports every rate as 0%
- Assumes a division by zero just yields NULL everywhere
- Uses AVG(CASE …) without ELSE and calls it a rate
- References the numerator's alias in the same SELECT list
- Reports a rate without showing the group size behind it