FILTER keyword
FILTER (WHERE ...) restricts the rows that a single aggregate function sees,
without affecting the other aggregates in the same query. It replaces the
conditional sum(CASE WHEN ... THEN ... END) pattern with the SQL:2003 form used
by PostgreSQL, DuckDB, SQLite and Trino, so queries written elsewhere and
dashboards generated by BI tools work unchanged.
The condition sits next to the aggregate it belongs to, and each column in the select list can carry its own condition over a single scan of the table:
SELECT
symbol,
count(*) FILTER (WHERE quantity >= 250_000) AS large_trades,
avg(price) FILTER (WHERE side = 'buy') AS avg_buy_price
FROM fx_trades;
SELECT
symbol,
count(CASE WHEN quantity >= 250_000 THEN 1 END) AS large_trades,
avg(CASE WHEN side = 'buy' THEN price END) AS avg_buy_price
FROM fx_trades;
FILTER attaches to the aggregate itself, so it works everywhere an aggregate
does: explicit and implicit GROUP BY,
SAMPLE BY buckets,
PIVOT aggregates, and aggregates used in window
position with OVER. It does
not apply to window functions that are not aggregates, such as row_number,
lag or first_value.
Syntax
aggregateFunction ( [ arguments ] )
FILTER ( WHERE condition )
[ OVER ( windowSpecification ) ]
- The clause goes immediately after the aggregate's closing parenthesis. With a
window function,
FILTERcomes first andOVERsecond. conditionis any boolean expression valid in aWHEREclause, includingIN,BETWEEN,IS NULL, casts,CASE, bind variables,DECLAREvariables and scalar sub-queries.- Arguments that configure the call rather than carry row data are left alone,
so
approx_percentile(x, 0.5) FILTER (...)still computes the median. FILTERis not a reserved word. It only starts the clause when a(follows it, so an existing column or alias namedfilterkeeps working.
Examples
Bucketing
Each bucket is one column, and adding a bucket means adding one line:
SELECT
symbol,
count(*) FILTER (WHERE quantity < 50_000) AS small,
count(*) FILTER (WHERE quantity >= 50_000
AND quantity < 250_000) AS medium,
count(*) FILTER (WHERE quantity >= 250_000) AS large
FROM fx_trades
WHERE timestamp IN '$today'
ORDER BY symbol;
SAMPLE BY
SELECT
timestamp,
count(*) FILTER (WHERE side = 'buy') AS buys,
count(*) FILTER (WHERE side = 'sell') AS sells
FROM fx_trades
WHERE symbol = 'EURUSD' AND timestamp IN '$now - 1h..$now'
SAMPLE BY 1m;
ASOF JOIN
The condition can reference both sides of a join:
SELECT
t.symbol,
count(*) FILTER (WHERE t.price > c.ask_price) AS above_ask,
count(*) FILTER (WHERE t.price < c.bid_price) AS below_bid,
count(*) AS total
FROM fx_trades t
ASOF JOIN core_price c ON (symbol)
WHERE t.timestamp IN '$now - 15m..$now'
ORDER BY t.symbol;
Window functions
An aggregate used with OVER accepts FILTER too. Here the running total
advances only on buy trades, while every row of the result is kept:
SELECT
timestamp,
symbol,
side,
quantity,
sum(quantity) FILTER (WHERE side = 'buy')
OVER (PARTITION BY symbol ORDER BY timestamp) AS cumulative_buy
FROM fx_trades
WHERE timestamp IN '$now - 1m..$now';
cumulative_buy is null until the first buy trade appears in each symbol's
partition, since until then the frame holds no matching row.
Naming a condition with DECLARE
SQL has no named-filter construct, but a DECLARE
variable can hold an entire boolean expression, so several aggregates can share
one condition instead of repeating it:
DECLARE @large := (quantity >= 250_000)
SELECT
symbol,
count(*) AS all_fills,
sum(quantity) AS all_volume,
count(*) FILTER (WHERE @large) AS large_fills,
sum(quantity) FILTER (WHERE @large) AS large_volume,
sum(quantity) FILTER (WHERE @large) / sum(quantity) AS large_share
FROM fx_trades
WHERE timestamp IN '$today'
ORDER BY symbol;
| symbol | all_fills | all_volume | large_fills | large_volume | large_share |
|---|---|---|---|---|---|
| AUDCAD | 2270 | 291882172 | 175 | 63298082 | 0.2169 |
| AUDJPY | 2402 | 316135548 | 204 | 74628710 | 0.2361 |
The unfiltered totals are what make FILTER necessary here. If every aggregate
shared the condition, the right move is to drop FILTER and put
quantity >= 250_000 in the WHERE clause instead.
The variable is substituted into each condition before the query is planned, so
this is identical to writing quantity >= 250_000 out three times. Multi-clause
conditions work the same way, and the parentheses are optional:
DECLARE @big_buy := quantity >= 250_000 AND side = 'buy'
The same variable works in window position, where repeating a long condition
across several OVER clauses costs the most.
FILTER or PIVOT
These two produce the same result:
SELECT
symbol,
count(*) FILTER (WHERE side = 'buy') AS buy_trades,
sum(quantity) FILTER (WHERE side = 'buy') AS buy_volume,
count(*) FILTER (WHERE side = 'sell') AS sell_trades,
sum(quantity) FILTER (WHERE side = 'sell') AS sell_volume
FROM fx_trades
WHERE timestamp IN '$today'
ORDER BY symbol;
SELECT * FROM fx_trades
WHERE timestamp IN '$today'
PIVOT (
count(*) AS trades, sum(quantity) AS volume
FOR side IN ('buy', 'sell')
GROUP BY symbol
)
ORDER BY symbol;
PIVOT is the better fit here: every output column
applies the same aggregates to the distinct values of one column, and it names
the columns for you. Reach for FILTER when the conditions are not simply the
distinct values of one column, such as the overlapping ranges in the bucketing
example above, conditions on different columns, or conditions spanning both
sides of a join.
Empty results
When nothing matches, count returns 0 and every other aggregate returns
NULL, the same as it would over an empty table:
SELECT
sum(CASE WHEN price > 10_000 THEN quantity END) AS case_form,
sum(CASE WHEN price > 10_000 THEN quantity ELSE 0 END) AS case_zeroed,
sum(quantity) FILTER (WHERE price > 10_000) AS filter_form,
coalesce(sum(quantity) FILTER (WHERE price > 10_000), 0) AS filter_zeroed,
count(*) FILTER (WHERE price > 10_000) AS count_form
FROM fx_trades
WHERE timestamp IN '$today';
| case_form | case_zeroed | filter_form | filter_zeroed | count_form |
|---|---|---|---|---|
| null | 0.0 | null | 0.0 | 0 |
case_form and filter_form agree. The NULL is ordinary SQL, not something
FILTER introduces: a plain sum(quantity) whose WHERE clause matches nothing
returns NULL too.
The zero came from ELSE 0, which contributed a value for every non-matching
row. FILTER has no ELSE, so when something downstream cannot take a null, a
Grafana panel or a non-nullable target column, wrap the aggregate in
coalesce(). count never needs
it.
Groups are never dropped. A symbol where nothing matched still appears, so a histogram never loses a bucket:
SELECT
symbol,
count(*) FILTER (WHERE price > 10_000) AS matched,
sum(quantity) FILTER (WHERE price > 10_000) AS matched_volume
FROM fx_trades
WHERE timestamp IN '$today'
ORDER BY symbol
LIMIT 2;
| symbol | matched | matched_volume |
|---|---|---|
| AUDCAD | 0 | null |
| AUDJPY | 0 | null |
FILTER narrows which rows the aggregate sees. It does not change how the
aggregate treats NULL in the rows that remain, so count(x) FILTER (WHERE c)
counts rows where c holds and x is not null.
Limitations
Aggregates that do not accept FILTER
| Aggregate | Use instead |
|---|---|
first, last | first_not_null and last_not_null, which accept FILTER |
array_agg, bool_and, bool_or, mode, isOrdered, twap | Filtering the rows first, see below |
FILTER is not supported for 'first', filter rows in a subquery instead
These are rejected because a non-matching row would still reach the aggregate as
a real value rather than a NULL, changing the answer. Narrow the rows in a
CTE or sub-query instead:
WITH buys AS (
SELECT * FROM fx_trades
WHERE side = 'buy' AND timestamp IN '$today'
)
SELECT symbol, first(price)
FROM buys;
Argument types that do not accept FILTER
An argument resolving to BYTE, SHORT, CHAR or BOOLEAN is rejected. Those
types have no distinct NULL, so an excluded row would reach the aggregate as a
genuine 0 or false and be counted, averaged or compared like any other.
fx_trades.passive is a BOOLEAN, so counting it directly is rejected:
SELECT count(passive) FILTER (WHERE side = 'buy') FROM fx_trades;
-- FILTER is not supported for a BOOLEAN argument, whose NULL is
-- indistinguishable from its zero value; cast the argument, for example
-- sum(x::int) FILTER (WHERE ...)
Cast the argument to a type that has a distinct NULL:
SELECT sum(CAST(passive AS INT)) FILTER (WHERE side = 'buy') AS passive_buys
FROM fx_trades
WHERE timestamp IN '$today';
This applies by resolved type, not by function name, so sum over a SHORT is
rejected even though zero would happen to be harmless for it.
Conditions
The condition is evaluated per row, so it may not contain an aggregate, a window
function, or another FILTER:
SELECT sum(quantity) FILTER (WHERE sum(quantity) > 1) FROM fx_trades;
-- aggregate functions are not allowed in FILTER
SELECT sum(quantity) FILTER (WHERE row_number() OVER () > 1) FROM fx_trades;
-- window functions are not allowed in FILTER
SELECT sum(quantity) FILTER (WHERE abs(price) FILTER (WHERE false) > 0)
FROM fx_trades;
-- FILTER is not allowed inside a FILTER condition
A non-deterministic condition such as rnd_boolean() is rejected on aggregates
taking more than one value argument, because each argument would draw its own
verdict. Single-argument aggregates are unaffected, so
sum(quantity) FILTER (WHERE rnd_double() < 0.1) is a valid sampling idiom.
Placement
FILTER only attaches to an aggregate in the select list. Anywhere else it is
rejected with FILTER is supported only for aggregate functions: on a scalar
function such as abs(), on a window function that is not an aggregate, in a
WHERE clause, in a join ON condition, and in SAMPLE BY's FROM, TO,
FILL, timezone and offset expressions.
Inside a window specification the message differs:
SELECT sum(quantity) OVER (PARTITION BY abs(price) FILTER (WHERE false))
FROM fx_trades;
-- FILTER is not supported in a window specification
Performance
FILTER changes how a query is written, not how it runs. It is neither faster
nor slower than the equivalent CASE expression, because it is compiled into
one. EXPLAIN therefore reports the rewritten shape
rather than what was typed:
EXPLAIN SELECT avg(price) FILTER (WHERE quantity > 1000) FROM fx_trades;
Async Group By workers: 8
vectorized: false
values: [avg(case([1000<quantity,price,null]))]
filter: null
PageFrame
Row forward scan
Frame forward scan on: fx_trades
Filtered aggregates run on the parallel Async Group By path, but not on the
SIMD path, which needs a plain column argument and a single key column. The
sum(CASE ...) form does not reach it either, so this is not a regression. The
condition is evaluated once per filtered argument per row, so twice per row for
a two-argument aggregate such as corr.
See also
- Aggregate functions - available aggregates
- GROUP BY - group rows for aggregation
- PIVOT - expand one column's values into columns
- SAMPLE BY - time-series aggregation
- Window functions - calculations across related rows