skip to content

Using FILTER (WHERE …), how do you return total, paid and refunded order counts per customer in one SELECT?

level: juniorimportance: should knowfreq 40%

answer

  1. One grouped pass, several measures
  2. Each aggregate carries its own predicate
  3. COUNT(*) with no filter gives the total
  4. COUNT(*) FILTER (WHERE status = 'paid') per column

basics

~20 s

Group by customer and give each measure its own filter clause: COUNT() for the total, COUNT() FILTER (WHERE status = 'paid') and COUNT(*) FILTER (WHERE status = 'refunded'). One grouped pass produces all three columns.

solid answer

~40 s

Write a single grouped query and attach a filter clause to each measure that needs one: ```sql SELECT customer_id, COUNT(*) AS orders_total, COUNT(*) FILTER (WHERE status = 'paid') AS orders_paid, COUNT(*) FILTER (WHERE status = 'refunded') AS orders_refunded FROM orders GROUP BY customer_id; ``` The rows are grouped once; each aggregate then consumes only the rows of its group that satisfy its own predicate. You can mix aggregate types freely — `SUM(amount) FILTER (WHERE status = 'paid')` sits beside the counts and returns paid revenue in the same row. There is no need for three subqueries, three scans, or a join of three grouped results. Name every column with `AS`, since a filtered aggregate has no useful default name. On engines without `FILTER`, the same shape is written with `CASE` inside each aggregate.

code

sql · 7 lines
sql
SELECT customer_id,
       COUNT(*)                                      AS orders_total,
       COUNT(*)    FILTER (WHERE status = 'paid')     AS orders_paid,
       COUNT(*)    FILTER (WHERE status = 'refunded') AS orders_refunded,
       SUM(amount) FILTER (WHERE status = 'paid')     AS revenue_paid
FROM orders
GROUP BY customer_id;

go deeper

for a junior

Practise typing this shape until it is automatic: GROUP BY the key, one aggregate per measure, each with its own filter clause and an AS alias. That is exactly what a screening exercise asks for.

for a middle

Explain why it is one pass rather than three, and what changes when the measure is SUM instead of COUNT — zero versus NULL for a group with no matching rows.

for a senior

Be ready to say when you would not write it this way: many measures over a huge table may deserve a pre-aggregated table, and on engines lacking FILTER you should reach straight for the CASE form rather than a dialect hunt.

for a principal

The judgment call is where such report logic lives — one reviewed SQL definition per metric versus per-team copies drifting apart. Argue for a single grouped definition with explicitly named measures.

## The shape of the query The task — "one row per customer, several differently-conditioned counts" — is the canonical use of the filter clause. ```sql SELECT customer_id, COUNT(*) AS orders_total, COUNT(*) FILTER (WHERE status = 'paid') AS orders_paid, COUNT(*) FILTER (WHERE status = 'refunded')AS orders_refunded, SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid FROM orders GROUP BY customer_id; ``` Read it left to right: `GROUP BY customer_id` decides the rows of the result, and each aggregate then consumes its group's rows subject to its own condition. `orders_total` has no filter, so it counts all of the customer's orders; `orders_paid` counts only the ones whose status is `'paid'`; `revenue_paid` sums `amount` over the same subset. ## Why not several queries The pre-FILTER alternatives are all heavier to write and read. Three separate grouped queries have to be joined back together on `customer_id`, and the join has to be an outer join or customers missing from one result vanish. Three correlated scalar subqueries in the SELECT list repeat the table reference three times. Conditional aggregation with `CASE` is a single pass like `FILTER`, but each measure becomes a nested expression the reader has to decode. The filter clause states the condition next to the aggregate it belongs to, which is why it reads well in reporting queries with a dozen measures. ## Mixing aggregate kinds Any aggregate can carry a filter clause, and they can differ within one SELECT list: ```sql SELECT COUNT(DISTINCT customer_id) FILTER (WHERE status = 'paid') AS paying_customers, AVG(amount) FILTER (WHERE status = 'paid') AS avg_paid_order, MAX(created_at) FILTER (WHERE status = 'refunded') AS last_refund FROM orders; ``` `DISTINCT` stays inside the parentheses with the argument; the filter clause goes after the closing parenthesis. ## Naming and result columns Always alias a filtered aggregate. Engines derive a default column name from the expression, and for a filtered aggregate that name is unhelpful or engine-specific; downstream code that reads by column name breaks. Aliases also document the condition in the result set, which is the point of the query. ## What each measure returns when nothing matches If a customer has no refunds, their `orders_refunded` is `0` — `COUNT` over an empty input is zero. But `SUM`, `AVG`, `MIN` and `MAX` over an empty input return NULL, so a customer with no paid orders gets `NULL` for `revenue_paid`, not `0`. Wrap it in `COALESCE(SUM(amount) FILTER (WHERE status = 'paid'), 0)` if the consumer wants a number. The customer's row itself is still present either way: grouping was decided before any filter clause ran. ## Watch the predicate's logic The filter predicate is evaluated with the usual three-valued logic; only TRUE admits a row. If `status` can be NULL, neither `status = 'paid'` nor `status <> 'paid'` admits those rows, so `orders_paid + orders_other` may be less than `orders_total`. Use `status IS DISTINCT FROM 'paid'` or an explicit `status IS NULL` measure when NULLs are meaningful. ## Portability `FILTER (WHERE …)` is standard SQL and is accepted by PostgreSQL and SQLite. MySQL, SQL Server and Oracle reject it; there you write the same query with `CASE` inside each aggregate: ```sql SELECT customer_id, COUNT(*) AS orders_total, COUNT(CASE WHEN status = 'paid' THEN 1 END) AS orders_paid, COUNT(CASE WHEN status = 'refunded' THEN 1 END) AS orders_refunded FROM orders GROUP BY customer_id; ``` Same single pass, same results, more punctuation. ## Interview framing Interviewers use this as a keyboard question: they describe a small report — orders by state, users by signup channel, tickets by priority — and watch whether you reach for one grouped query or for three. Producing the single-pass version, aliasing the columns, and noting the 0-versus-NULL difference between the count and the sum is a complete answer.

  • Why alias every filtered aggregate?
    Because the engine's derived column name for an expression like COUNT(*) FILTER (WHERE status = 'paid') is unhelpful and varies between engines, so client code that reads results by name is fragile. An alias also documents the condition in the result set itself.
  • If a customer has no refunds, is that customer missing from the result?
    No. Groups are formed from the rows that survive WHERE, and the filter clause does not remove rows, so the customer appears with orders_refunded = 0. Only a SUM, AVG, MIN or MAX with no matching rows comes back NULL rather than zero.

saying these in an interview costs you the question

  • Writes one query per measure and joins the results
  • Puts the status predicate in WHERE and loses the total
  • Assumes a customer with no refunds drops out of the result
  • Expects SUM with no matching rows to return 0
  • Leaves filtered aggregates unaliased in the SELECT list

context