24. FILTER and conditional aggregation
The problem: several differently-filtered aggregates, one row per group
The shape of the problem: "total films, and separately, R-rated films, and separately, PG-rated films" — three different filter conditions, one aggregate function each, all in a single row of output. There are two idiomatic ways to write this; this lesson covers both and when to prefer each.
CASE-inside-aggregate (the portable way)
SELECT
store_id,
count(*) AS total_rentals,
count(CASE WHEN return_date IS NULL THEN 1 END) AS outstanding,
sum(CASE WHEN return_date IS NOT NULL THEN 1 ELSE 0 END) AS returned
FROM rental r
JOIN staff s ON s.staff_id = r.staff_id
GROUP BY store_id;
Recall from Module 1's CASE lesson exactly how this works: CASE ... END with no
ELSE produces NULL for non-matching rows, and count() skips
NULLs — so count(CASE WHEN cond THEN 1 END) counts only rows where
cond was true. This pattern is standard SQL, works identically on
every engine, and is worth knowing cold even though the next form is
often cleaner to read.
FILTER (the Postgres/standard-SQL-2003 way)
SELECT
store_id,
count(*) AS total_rentals,
count(*) FILTER (WHERE return_date IS NULL) AS outstanding,
count(*) FILTER (WHERE return_date IS NOT NULL) AS returned
FROM rental r
JOIN staff s ON s.staff_id = r.staff_id
GROUP BY store_id;
agg(...) FILTER (WHERE condition) applies condition only to that
one aggregate — every other aggregate in the same SELECT list still
sees all the group's rows unless it has its own FILTER. This is
directly equivalent to the CASE-inside-COUNT pattern above, but
reads more clearly once you're filtering several different aggregates in
one query, because the condition sits next to the aggregate it applies
to instead of being buried inside a CASE expression.
FILTER is part of the SQL standard (added in SQL:2003) and Postgres
supports it natively — but it's worth knowing MySQL didn't support
FILTER until fairly recently and older codebases/tutorials still lean
on CASE for this reason. Recognize both; this course uses FILTER
going forward when the choice is purely stylistic, since it's the
cleaner spelling in Postgres.
Why this beats separate queries or self-joins
Before either pattern, the naive approach to "several differently
filtered counts, one row" is either N separate queries (N round trips,
N full scans) or joining N differently-filtered subqueries together
(the multi-join fan-out risk from Module 4). FILTER/CASE
conditional aggregation computes all the filtered aggregates in one
pass over the data, which is both simpler to write and meaningfully
cheaper to execute — this is the answer to "how do I get a pivot-style
one-row summary" for the vast majority of real reporting queries, well
before you'd reach for the heavier GROUPING SETS machinery from the
previous lesson.
FILTER with different conditions per aggregate, in one query
The real payoff — five genuinely different conditions, five aggregates, one row per group, one pass over the table:
SELECT
store_id,
count(*) AS total_rentals,
count(*) FILTER (WHERE return_date IS NULL) AS outstanding,
avg(EXTRACT(DAY FROM return_date - rental_date))
FILTER (WHERE return_date IS NOT NULL) AS avg_rental_days,
count(*) FILTER (WHERE rental_date > '2022-08-01') AS recent_rentals,
count(DISTINCT customer_id) FILTER (WHERE return_date IS NULL) AS customers_with_outstanding
FROM rental r
JOIN staff s ON s.staff_id = r.staff_id
GROUP BY store_id;
Each FILTER is completely independent — nothing here requires the
conditions to relate to each other, unlike GROUPING SETS/ROLLUP/CUBE
which are specifically about subtotaling the same measure across
different groupings of the same dimension columns. Reach for
FILTER/CASE conditional aggregation when you want several distinct
measures; reach for ROLLUP/CUBE when you want the same measure at
several levels of grouping.
Check yourself
- Write
count(*) FILTER (WHERE rating = 'R')as an equivalentCASE-inside-count()expression. - Why does
FILTERread more clearly than nestedCASEonce you have three or more differently-filtered aggregates in one query? - When would you reach for
GROUPING SETS/ROLLUPinstead ofFILTER-based conditional aggregation, and vice versa?