Aggregation
Conditional aggregation with FILTER and CASE
So far, every aggregate in a query has operated on the same set of rows.
For example:
12345SELECT COUNT(*) AS order_count, SUM(total_amount) AS total_amountFROM ordersWHERE status = 'delivered';The WHERE clause keeps only delivered orders.
Both aggregates therefore receive the same rows.
But imagine an API needs to return this dashboard:
123456Total orders 100000Delivered 70000Shipped 15000Processing 8000Cancelled 5000Pending 2000We could execute a separate query for each status.
But PostgreSQL can calculate all of these values in one query.
This is called conditional aggregation.
Filtering one aggregate
PostgreSQL aggregate functions support a FILTER clause.
The syntax is:
12aggregate_function(...)FILTER (WHERE condition)For example:
12345SELECT COUNT(*) FILTER ( WHERE status = 'delivered' ) AS delivered_ordersFROM orders;You should see:
123 delivered_orders------------------ 70000The important part is:
123FILTER ( WHERE status = 'delivered')Only rows satisfying that condition are passed to this particular COUNT().
WHERE and FILTER are different
Consider:
1234SELECT COUNT(*) AS order_countFROM ordersWHERE status = 'delivered';WHERE removes rows from the query before aggregation.
Conceptually:
12345678910all orders │ ▼WHERE status = 'delivered' │ ▼delivered orders │ ▼COUNT(*)Now consider:
123456SELECT COUNT(*) AS total_orders, COUNT(*) FILTER ( WHERE status = 'delivered' ) AS delivered_ordersFROM orders;You should see:
123 total_orders | delivered_orders--------------+------------------ 100000 | 70000The query still has access to all orders.
Only the second aggregate has an additional filter.
Conceptually:
1234567891011121314all orders │ ├──────────────▶ COUNT(*) │ │ │ ▼ │ 100000 │ └── FILTER delivered │ ▼ COUNT(*) │ ▼ 70000A useful distinction is:
12345WHERE→ decides which rows are available to the queryFILTER→ decides which of those rows are supplied to one particular aggregateSeveral conditional aggregates in one query
This becomes especially useful when different aggregates need different subsets of the same data.
Run:
1234567891011121314151617181920212223SELECT COUNT(*) AS total_orders,
COUNT(*) FILTER ( WHERE status = 'delivered' ) AS delivered_orders,
COUNT(*) FILTER ( WHERE status = 'shipped' ) AS shipped_orders,
COUNT(*) FILTER ( WHERE status = 'processing' ) AS processing_orders,
COUNT(*) FILTER ( WHERE status = 'cancelled' ) AS cancelled_orders,
COUNT(*) FILTER ( WHERE status = 'pending' ) AS pending_ordersFROM orders;You should see:
123 total_orders | delivered_orders | shipped_orders | processing_orders | cancelled_orders | pending_orders--------------+------------------+----------------+-------------------+------------------+---------------- 100000 | 70000 | 15000 | 8000 | 5000 | 2000One query has produced several different summaries.
123456789101112 all orders │ ┌─────────────────┼─────────────────┐ │ │ │ ▼ ▼ ▼ delivered shipped processing │ │ │ ▼ ▼ ▼ COUNT COUNT COUNT │ │ │ ▼ ▼ ▼ 70000 15000 8000This pattern is common in:
12345dashboard APIsreporting queriesadmin pagesanalytics endpointssummary cardsFILTER works with more than COUNT
FILTER can be attached to other aggregate functions too.
For example:
123456789SELECT SUM(total_amount) FILTER ( WHERE status = 'delivered' ) AS delivered_amount,
SUM(total_amount) FILTER ( WHERE status = 'cancelled' ) AS cancelled_amountFROM orders;You should see:
123 delivered_amount | cancelled_amount------------------+------------------ 28836260.00 | 2062750.00The first SUM() receives only delivered rows.
The second receives only cancelled rows.
The same pattern can be used with aggregates such as:
12345SUM()AVG()MIN()MAX()COUNT()Combining FILTER with GROUP BY
Conditional aggregation becomes even more useful when combined with grouping.
Imagine we want, for each year:
123total ordersdelivered orderscancelled ordersRun:
123456789101112SELECT EXTRACT(YEAR FROM placed_at)::int AS placed_year, COUNT(*) AS total_orders, COUNT(*) FILTER ( WHERE status = 'delivered' ) AS delivered_orders, COUNT(*) FILTER ( WHERE status = 'cancelled' ) AS cancelled_ordersFROM ordersGROUP BY placed_yearORDER BY placed_year;You should see:
123456 placed_year | total_orders | delivered_orders | cancelled_orders-------------+--------------+------------------+------------------ 2021 | 25184 | 17674 | 1240 2022 | 25176 | 17636 | 1245 2023 | 24820 | 17335 | 1253 2024 | 24820 | 17355 | 1262PostgreSQL first creates one group for each year.
Then, inside every year group:
1COUNT(*)counts all orders,
while:
123COUNT(*) FILTER ( WHERE status = 'delivered')counts only delivered orders in that group.
Conceptually:
12345678910112021 │ ├── all orders ─────────▶ COUNT ├── delivered only ─────▶ COUNT └── cancelled only ─────▶ COUNT2022 │ ├── all orders ─────────▶ COUNT ├── delivered only ─────▶ COUNT └── cancelled only ─────▶ COUNTGROUP BY determines the year group.
FILTER determines which rows inside that group participate in each aggregate.
Conditional aggregation with CASE
Conditional aggregation can also be written using a CASE expression.
For example:
1234567SELECT COUNT( CASE WHEN status = 'delivered' THEN 1 END ) AS delivered_ordersFROM orders;This also returns:
170000To understand why, remember how CASE works.
For a delivered row:
123CASE WHEN status = 'delivered' THEN 1ENDreturns:
11For any other status, there is no ELSE, so the expression returns:
1NULLThe aggregate therefore receives values resembling:
12345611NULL1NULL...And because:
1COUNT(expression)counts non-NULL values, only delivered rows are counted.
Be careful with COUNT and ELSE 0
This version looks reasonable:
123456COUNT( CASE WHEN status = 'delivered' THEN 1 ELSE 0 END)but it does not count only delivered rows. It returns:
1100000Why?
For non-delivered rows, the CASE expression returns:
10But 0 is not NULL.
COUNT(expression) counts both:
1210because both are non-NULL values.
So this expression counts every row.
If you use COUNT(CASE ...), the nonmatching rows should normally produce NULL:
12345COUNT( CASE WHEN status = 'delivered' THEN 1 END)For this simple counting case, FILTER is usually clearer:
123COUNT(*) FILTER ( WHERE status = 'delivered')SUM with CASE
CASE is also commonly used with SUM().
For example:
12345678SELECT SUM( CASE WHEN status = 'delivered' THEN 1 ELSE 0 END ) AS delivered_ordersFROM orders;This returns:
123 delivered_orders------------------ 70000Here, ELSE 0 is appropriate.
Each delivered order contributes:
11and every other order contributes:
10So the sum is the number of delivered orders.
Conceptually:
12345678delivered → 1delivered → 1cancelled → 0processing → 0delivered → 1 │ ▼ SUMCASE can change the value being aggregated
CASE is useful when the condition should determine what value participates in the calculation.
For example:
123456789SELECT SUM( CASE WHEN status = 'delivered' THEN total_amount ELSE 0 END ) AS delivered_amountFROM orders;For a delivered row:
1use total_amountFor another status:
1use 0The same result can often be written more directly using FILTER:
12345SELECT SUM(total_amount) FILTER ( WHERE status = 'delivered' ) AS delivered_amountFROM orders;Both return 28836260.00.
The two approaches express the problem differently:
12345CASE→ conditionally choose the value supplied to the aggregateFILTER→ conditionally choose which rows reach the aggregateFILTER is often clearer when you are filtering rows
Compare:
12345COUNT( CASE WHEN status = 'delivered' THEN 1 END)with:
123COUNT(*) FILTER ( WHERE status = 'delivered')The second version states the intent directly:
1Count rows where status is delivered.Similarly:
123SUM(total_amount) FILTER ( WHERE status = 'delivered')reads naturally as:
1Sum total_amount for delivered rows.For straightforward conditional aggregation in PostgreSQL, FILTER is therefore often easier to read.
This does not mean you should assume that FILTER is always faster than an equivalent CASE expression.
If performance matters for a particular query, inspect the execution plan and measure it rather than relying on a general rule.
When CASE is more useful
CASE remains useful when the output expression itself changes based on multiple conditions.
For example:
123456789SELECT SUM( CASE WHEN status = 'delivered' THEN total_amount WHEN status = 'shipped' THEN total_amount * 0.5 ELSE 0 END ) AS weighted_amountFROM orders;Here, we are doing more than simply deciding whether a row participates.
Different rows contribute different calculated values.
That is naturally expressed with CASE.
Use WHERE when every aggregate uses the same rows
Suppose the query is:
1234567891011SELECT COUNT(*) FILTER ( WHERE status = 'delivered' ), SUM(total_amount) FILTER ( WHERE status = 'delivered' ), AVG(total_amount) FILTER ( WHERE status = 'delivered' )FROM orders;Every aggregate uses exactly the same condition.
In that case, the query is usually clearer as:
123456SELECT COUNT(*), SUM(total_amount), AVG(total_amount)FROM ordersWHERE status = 'delivered';There is no need for a separate FILTER on every aggregate.
Use FILTER when different aggregates need different subsets of the rows.
For example:
123456789SELECT COUNT(*) AS total_orders, COUNT(*) FILTER ( WHERE status = 'delivered' ) AS delivered_orders, COUNT(*) FILTER ( WHERE status = 'cancelled' ) AS cancelled_ordersFROM orders;Here, a single global WHERE clause cannot express what we need because each aggregate intentionally sees a different set of rows.
The core mental model
Think of the three tools separately:
12345678WHERE→ Which rows should enter the aggregation query?GROUP BY→ Which groups should those rows be divided into?FILTER→ Which rows inside each group should a particular aggregate receive?And when using CASE:
12CASE→ What value should this row contribute to the aggregate?These ideas can be combined in one query:
12345678910111213SELECT EXTRACT(YEAR FROM placed_at)::int AS placed_year, COUNT(*) AS total_orders, COUNT(*) FILTER ( WHERE status = 'delivered' ) AS delivered_orders, SUM(total_amount) FILTER ( WHERE status = 'delivered' ) AS delivered_amountFROM ordersWHERE status <> 'pending'GROUP BY placed_yearORDER BY placed_year;You should see:
123456 placed_year | total_orders | delivered_orders | delivered_amount-------------+--------------+------------------+------------------ 2021 | 24688 | 17674 | 7162531.50 2022 | 24678 | 17636 | 7215837.45 2023 | 24330 | 17335 | 7217993.45 2024 | 24304 | 17355 | 7239897.60WHERE removed the pending orders, GROUP BY split the rest by year, and each FILTER narrowed one aggregate further.
The important point is:
Conditional aggregation lets different aggregate functions calculate summaries from different subsets of the same rows, often allowing one query to produce an entire dashboard or API summary.