skip to content

With COUNT(*) FILTER (WHERE status = 'refunded'), what does a customer group with no refunds return?

level: middleimportance: should knowfreq 35%

answer

  1. Grouping is settled before the aggregate runs
  2. The row does not disappear
  3. The answer differs by aggregate kind
  4. Counts floor at zero, sums do not

basics

~20 s

The customer still appears, with 0. Grouping is decided before any filter clause runs, so the group survives; COUNT over no matching rows is 0, while SUM, AVG, MIN and MAX over no matching rows would be NULL.

solid answer

~40 s

The row is still there. Group membership is fixed by the rows that survive `WHERE` and by `GROUP BY`, and the filter clause only trims what one aggregate consumes — so a customer whose orders are all paid still gets a result row, with `orders_refunded = 0`. The measure type decides zero versus NULL: `COUNT` over an empty input is 0, but `SUM(amount) FILTER (WHERE status = 'refunded')` is NULL for that customer, so wrap it in `COALESCE(…, 0)` if the consumer expects a number. This is precisely what you lose by pushing the predicate into `WHERE` instead: there, customers with no refunds produce no rows at all and disappear from the report. `HAVING` is the deliberate way to drop them — for example `HAVING COUNT(*) FILTER (WHERE status = 'refunded') > 0`.

code

sql · 7 lines
sql
-- A refund-free customer returns (4, 0, NULL): present, counted zero, summed NULL
SELECT customer_id,
       COUNT(*)                                      AS orders_total,
       COUNT(*)    FILTER (WHERE status = 'refunded') AS refund_count,
       SUM(amount) FILTER (WHERE status = 'refunded') AS refund_value
FROM orders
GROUP BY customer_id;

go deeper

for a junior

Remember the pair of facts: the group still appears, and COUNT gives 0 while SUM gives NULL when nothing matches the filter.

for a middle

Explain why — grouping is settled before the aggregates run, so a per-aggregate predicate cannot remove a group. Show the COALESCE fix and the HAVING alternative.

for a senior

Recognise this from the symptom side: rows silently vanishing from a report after someone moved a measure's predicate into WHERE, or downstream arithmetic collapsing to NULL. Say which fix belongs where.

for a principal

Treat zero-versus-NULL and empty-group visibility as part of the reporting contract you publish, not as an accident of whoever wrote the query — downstream consumers build alerts on those cells.

## What decides which rows the result has A grouped query builds its result in stages: `FROM`/`JOIN` produces rows, `WHERE` discards some, `GROUP BY` partitions what is left into groups, aggregates are computed per group, `HAVING` discards whole groups, and the SELECT list is projected. The filter clause acts inside step four only — while one aggregate consumes its group's rows. It has no say in which groups exist. So for ```sql SELECT customer_id, COUNT(*) AS orders_total, COUNT(*) FILTER (WHERE status = 'refunded') AS orders_refunded FROM orders GROUP BY customer_id; ``` a customer with four paid orders and no refunds yields `(4, 0)`. The customer is present because four of their rows reached `GROUP BY`; the refund count is zero because none of those four rows satisfied that aggregate's predicate. ## Zero versus NULL The value returned for a measure with no matching rows depends on the aggregate, not on the filter clause: - `COUNT(*)` and `COUNT(expr)` over an empty input return `0`. - `SUM`, `AVG`, `MIN`, `MAX` over an empty input return `NULL`. So in ```sql SELECT customer_id, COUNT(*) FILTER (WHERE status = 'refunded') AS refund_count, SUM(amount) FILTER (WHERE status = 'refunded') AS refund_value FROM orders GROUP BY customer_id; ``` a refund-free customer gets `(0, NULL)` — a mix that surprises report consumers and breaks naive arithmetic downstream, since `refund_value * 2` and `refund_value + 0` are both NULL. Fix it at the source when the contract wants a number: ```sql COALESCE(SUM(amount) FILTER (WHERE status = 'refunded'), 0) AS refund_value ``` ## Contrast with pushing the predicate into WHERE ```sql SELECT customer_id, COUNT(*) AS orders_refunded FROM orders WHERE status = 'refunded' GROUP BY customer_id; ``` This is a different report. Customers with no refunds contribute no surviving rows, form no group, and are absent from the output — there is no `0` row for them, because SQL cannot invent a group for a key it never saw. That is the right query when you want "customers who have refunds", and the wrong query when you want "refunds per customer, including none". Interviewers like this pair because a dashboard built on the second query quietly shows fewer customers each time refunds go to zero. ## When you do want the group dropped Use `HAVING`, which runs after aggregation and can test the filtered measure itself: ```sql SELECT customer_id, COUNT(*) AS orders_total, COUNT(*) FILTER (WHERE status = 'refunded') AS orders_refunded FROM orders GROUP BY customer_id HAVING COUNT(*) FILTER (WHERE status = 'refunded') > 0; ``` Now you keep the honest `orders_total` for the customers you show, and drop only those with no refunds. The `WHERE` version cannot do that: it would have thrown away the paid rows that `orders_total` needs. ## Groups that do not exist at all One limit worth stating plainly: none of these forms can produce a row for a customer who has no orders whatsoever. That customer has no rows in `orders`, so no group exists for them under any filtering scheme. Getting them into the report requires starting from the customer table and outer-joining the orders — a different query shape, and a different problem from the one the filter clause solves. ## Empty table, no GROUP BY One more corner: an aggregate query with no `GROUP BY` always returns exactly one row, even over an empty table. So `SELECT COUNT(*) FILTER (WHERE status = 'refunded'), SUM(amount) FILTER (WHERE status = 'refunded') FROM orders` on an empty table returns one row of `(0, NULL)` — the same zero-versus-NULL split, in the single-group case. ## Interview framing The question is usually posed as a bug report: "the report used to list every customer, now some are missing" or "why is this column NULL instead of 0 for some rows". A complete answer names the stage each filter acts at, states the 0-versus-NULL rule by aggregate, and gives both fixes — `COALESCE` for the NULL, `HAVING` when you actually meant to drop the group.

  • How do you drop the groups where nothing matched, without breaking the unfiltered measures?
    Use HAVING, which runs after aggregation: HAVING COUNT(*) FILTER (WHERE status = 'refunded') > 0. Moving the predicate into WHERE would also drop those groups, but it would throw away the rows the total and other measures need.
  • Can this query return a row for a customer who has never placed an order?
    No. That customer has no rows in the orders table, so GROUP BY forms no group for them regardless of how the aggregates are filtered. Including them means starting from the customer table and outer-joining orders.

saying these in an interview costs you the question

  • Expects the group to disappear when no row matches the filter
  • Assumes SUM returns 0 when nothing matches
  • Thinks COUNT can return NULL for an empty group
  • Uses WHERE to drop empty groups and breaks the totals
  • Believes the filter clause can conjure rows for absent keys

context