Tá an t-alt seo i Rúisis faoi láthair — tá an t-aistriúchán Béarla ar siúl.
В отчётах редко бывает достаточно просто посчитать «все строки».
Обычно бизнесу нужны более точные метрики:
- сколько всего заказов;
- сколько заказов оплачено;
- сколько заказов возвращено;
- какая выручка только по успешным платежам;
- сколько пользователей сделали хотя бы один заказ;
- какая доля заказов завершилась оплатой.
Можно написать для каждой метрики отдельный запрос.
Можно собрать несколько подзапросов и потом соединить их через JOIN.
Можно использовать CASE WHEN внутри агрегатов.
Но в PostgreSQL есть более аккуратный и читаемый способ — FILTER (WHERE ...).
Он позволяет сказать:
Посчитай агрегат не по всем строкам, а только по тем, которые подходят под условие.
Например:
COUNT(*) FILTER (WHERE status = 'paid')
означает:
Посчитай количество строк, но только там, где status = 'paid'.
Это очень удобно для отчётов, дашбордов и витрин метрик, где в одной строке нужно показать сразу несколько показателей по разным условиям.
Что такое FILTER простыми словами
FILTER (WHERE ...) добавляется к агрегатной функции.
Например, к таким функциям:
COUNT()
SUM()
AVG()
MIN()
MAX()
COUNT(DISTINCT ...)
Обычный агрегат считает данные по всей группе.
Например:
SELECT COUNT(*) AS orders_total
FROM orders;
Такой запрос считает все заказы.
А если нужно посчитать только оплаченные заказы, можно написать:
SELECT
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders
FROM orders;
Здесь FILTER говорит агрегату:
Бери в расчёт только строки, где статус равен paid.
Важно: FILTER работает не для всего запроса, а только для конкретного агрегата.
То есть в одном SELECT можно написать несколько агрегатов, и у каждого будет своё условие.
Базовый пример
Допустим, есть таблица orders:
id | user_id | amount | status
---+---------+--------+----------
1 | 1 | 1500 | paid
2 | 2 | 2300 | paid
3 | 3 | 900 | refunded
4 | 4 | 700 | failed
5 | 5 | 1200 | paid
Хотим получить сразу несколько метрик:
- всего заказов;
- оплаченных заказов;
- возвращённых заказов;
- неуспешных заказов.
Запрос:
SELECT
COUNT(*) AS orders_total,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_orders,
COUNT(*) FILTER (WHERE status = 'failed') AS failed_orders
FROM orders;
Результат:
orders_total | paid_orders | refunded_orders | failed_orders
-------------+-------------+-----------------+--------------
5 | 3 | 1 | 1
Такой запрос читается почти как описание отчёта:
посчитай все заказы
посчитай оплаченные
посчитай возвращённые
посчитай неуспешные
И всё это делается в одном SELECT.
Чем FILTER отличается от обычного WHERE
Это очень важный момент.
Обычный WHERE фильтрует строки для всего запроса.
Например:
SELECT
COUNT(*) AS orders_total
FROM orders
WHERE status = 'paid';
Такой запрос сначала оставит только оплаченные заказы, а потом посчитает их.
То есть все остальные строки вообще не попадут в расчёт.
А FILTER работает точечно, только для одного агрегата.
SELECT
COUNT(*) AS orders_total,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders
FROM orders;
Здесь:
COUNT(*) AS orders_total
считает все строки.
А:
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders
считает только оплаченные строки.
То есть общий WHERE отвечает за входные данные всего запроса, а FILTER — за условие внутри конкретной метрики.
Можно запомнить так:
WHERE фильтрует строки для всего запроса.
FILTER фильтрует строки только для одного агрегата.
Пример: выручка по статусам
Теперь посчитаем не только количество заказов, но и сумму.
Допустим, в таблице orders есть суммы заказов:
id | amount | status
---+--------+----------
1 | 1500 | paid
2 | 2300 | paid
3 | 900 | refunded
4 | 700 | failed
5 | 1200 | paid
Хотим получить:
- общую сумму всех заказов;
- выручку по оплаченным заказам;
- сумму возвратов;
- сумму неуспешных заказов.
SELECT
SUM(amount) AS amount_total,
SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid,
SUM(amount) FILTER (WHERE status = 'refunded') AS amount_refunded,
SUM(amount) FILTER (WHERE status = 'failed') AS amount_failed
FROM orders;
Результат:
amount_total | revenue_paid | amount_refunded | amount_failed
-------------+--------------+-----------------+--------------
6600 | 5000 | 900 | 700
SUM(amount) считает сумму по всем строкам.
А:
SUM(amount) FILTER (WHERE status = 'paid')
считает сумму только по оплаченным заказам.
Это очень удобно: в одном запросе можно собрать целый блок метрик для отчёта.
Пример: несколько метрик по дням
FILTER особенно полезен вместе с GROUP BY.
Допустим, мы хотим построить отчёт по дням:
- всего заказов за день;
- оплаченных заказов;
- возвращённых заказов;
- выручку по оплаченным заказам.
SELECT
DATE_TRUNC('day', created_at) AS day,
COUNT(*) AS orders_total,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_orders,
SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid
FROM orders
GROUP BY DATE_TRUNC('day', created_at)
ORDER BY day;
Результат может быть таким:
day | orders_total | paid_orders | refunded_orders | revenue_paid
---------------------+--------------+-------------+-----------------+-------------
2026-06-17 00:00:00 | 10 | 7 | 1 | 12500
2026-06-18 00:00:00 | 8 | 5 | 2 | 9300
Здесь каждая строка результата — отдельный день.
А внутри каждого дня агрегаты считают разные сегменты:
COUNT(*) — все заказы;
COUNT(*) FILTER (WHERE status = 'paid') — только оплаченные;
COUNT(*) FILTER (WHERE status = 'refunded') — только возвраты;
SUM(amount) FILTER (WHERE status = 'paid') — выручку только по оплаченным заказам.
Такой запрос хорошо подходит для графиков и дашбордов.
Пример: метрики по странам
Теперь пример посложнее.
Есть таблица users:
id | email | country
---+----------------+---------
1 | anna@mail.com | Vietnam
2 | bob@mail.com | Vietnam
3 | kate@mail.com | Germany
И таблица orders:
id | user_id | amount | status
---+---------+--------+--------
1 | 1 | 1500 | paid
2 | 1 | 2300 | paid
3 | 2 | 900 | failed
Хотим получить отчёт по странам:
- сколько всего пользователей;
- сколько пользователей сделали хотя бы один заказ;
- сколько оплаченных заказов;
- какая оплаченная выручка;
- какой средний оплаченный чек.
Запрос:
SELECT
u.country,
COUNT(DISTINCT u.id) AS users_total,
COUNT(DISTINCT u.id) FILTER (WHERE o.id IS NOT NULL) AS users_with_orders,
COUNT(*) FILTER (WHERE o.status = 'paid') AS paid_orders,
COALESCE(SUM(o.amount) FILTER (WHERE o.status = 'paid'), 0) AS revenue_paid,
AVG(o.amount) FILTER (WHERE o.status = 'paid') AS avg_paid_order
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.country
ORDER BY u.country;
Здесь есть важная тонкость.
После LEFT JOIN один пользователь может превратиться в несколько строк, если у него несколько заказов.
Например, если у пользователя Anna два заказа, после соединения она будет встречаться два раза.
Поэтому для подсчёта пользователей безопаснее писать:
COUNT(DISTINCT u.id)
А не просто:
COUNT(*)
Иначе количество пользователей может случайно раздуться из-за заказов.
Разберём метрики:
COUNT(DISTINCT u.id) AS users_total
считает всех уникальных пользователей в стране.
COUNT(DISTINCT u.id) FILTER (WHERE o.id IS NOT NULL) AS users_with_orders
считает уникальных пользователей, у которых есть хотя бы один заказ.
COUNT(*) FILTER (WHERE o.status = 'paid') AS paid_orders
считает количество оплаченных заказов.
SUM(o.amount) FILTER (WHERE o.status = 'paid') AS revenue_paid
считает сумму только по оплаченным заказам.
AVG(o.amount) FILTER (WHERE o.status = 'paid') AS avg_paid_order
считает средний чек только по оплаченным заказам.
Такой подход позволяет собрать несколько связанных метрик в одном понятном запросе.
FILTER против CASE WHEN
До появления FILTER условные агрегаты часто писали через CASE WHEN.
Например, количество оплаченных заказов:
SELECT
COUNT(CASE WHEN status = 'paid' THEN 1 END) AS paid_orders
FROM orders;
Или выручка по оплаченным заказам:
SELECT
SUM(CASE WHEN status = 'paid' THEN amount END) AS revenue_paid
FROM orders;
Это рабочий вариант.
Но с FILTER тот же смысл читается проще:
SELECT
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid
FROM orders;
В варианте с FILTER сразу видно:
что считаем: COUNT(*) или SUM(amount)
по каким строкам: WHERE status = 'paid'
А в варианте с CASE WHEN нужно внимательнее читать выражение внутри агрегата.
Можно сказать так:
CASE WHEN прячет условие внутри выражения.
FILTER показывает условие рядом с агрегатом.
Для отчётов это обычно приятнее и понятнее.
Эквивалент через SUM(CASE WHEN ...)
Важно понимать, что FILTER не делает что-то принципиально невозможное без него.
Многие запросы можно переписать через CASE.
Например:
SELECT
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders
FROM orders;
Можно записать так:
SELECT
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_orders
FROM orders;
Оба запроса посчитают количество оплаченных заказов.
Выручка через FILTER:
SELECT
SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid
FROM orders;
То же самое через CASE:
SELECT
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS revenue_paid
FROM orders;
CASE полезен, потому что работает почти во всех СУБД.
А FILTER полезен, потому что делает запрос чище, если ваша база его поддерживает.
COUNT и NULL: почему это важно
С агрегатами важно помнить, как они работают с NULL.
COUNT(*) считает строки.
COUNT(*)
А COUNT(column) считает только строки, где column не NULL.
Например:
id | amount
---+--------
1 | 100
2 | NULL
3 | 300
SELECT
COUNT(*) AS rows_count,
COUNT(amount) AS amount_count
FROM orders;
Результат:
rows_count | amount_count
-----------+-------------
3 | 2
Почему amount_count = 2?
Потому что COUNT(amount) не считает строку, где amount равен NULL.
С FILTER это тоже важно.
Например:
COUNT(amount) FILTER (WHERE status = 'paid')
посчитает только оплаченные строки, где amount не NULL.
А:
COUNT(*) FILTER (WHERE status = 'paid')
посчитает все оплаченные строки, даже если в каких-то из них amount равен NULL.
Для количества заказов чаще нужен COUNT(*).
Для количества заполненных значений в колонке — COUNT(column).
Что будет, если под FILTER не попала ни одна строка
У агрегатов разное поведение, когда подходящих строк нет.
COUNT возвращает 0.
Например:
SELECT
COUNT(*) FILTER (WHERE status = 'unknown') AS unknown_orders
FROM orders;
Если таких заказов нет, результат будет:
unknown_orders
--------------
0
А вот SUM, AVG, MIN, MAX обычно вернут NULL.
Например:
SELECT
SUM(amount) FILTER (WHERE status = 'unknown') AS unknown_amount
FROM orders;
Если подходящих строк нет, результат будет:
unknown_amount
--------------
NULL
Для отчётов это часто неудобно. На дашборде обычно хочется видеть 0, а не NULL.
В таком случае используют COALESCE.
SELECT
COALESCE(
SUM(amount) FILTER (WHERE status = 'unknown'),
0
) AS unknown_amount
FROM orders;
Теперь, если строк нет, результат будет 0.
Можно запомнить так:
COUNT при отсутствии строк возвращает 0.
SUM и AVG могут вернуть NULL, поэтому для отчётов часто нужен COALESCE.
Пример: зарплатный отчёт по отделам
Допустим, есть таблица employees:
id | name | dept | salary
---+-------+------+--------
1 | Anna | eng | 180000
2 | Bob | eng | 120000
3 | Kate | hr | 90000
4 | Tom | hr | 160000
Хотим по каждому отделу посчитать:
- всех сотрудников;
- сотрудников с зарплатой выше 150000;
- фонд оплаты таких сотрудников.
SELECT
dept,
COUNT(*) AS employees_total,
COUNT(*) FILTER (WHERE salary > 150000) AS high_salary_employees,
COALESCE(
SUM(salary) FILTER (WHERE salary > 150000),
0
) AS high_salary_payroll
FROM employees
GROUP BY dept
ORDER BY dept;
Результат:
dept | employees_total | high_salary_employees | high_salary_payroll
-----+-----------------+-----------------------+--------------------
eng | 2 | 1 | 180000
hr | 2 | 1 | 160000
Если в каком-то отделе не будет сотрудников с зарплатой выше 150000, COUNT вернёт 0, а SUM без COALESCE вернул бы NULL.
Доля сегмента через FILTER
FILTER удобно использовать для расчёта долей.
Например, нужно посчитать долю оплаченных заказов среди всех заказов.
SELECT
COUNT(*) FILTER (WHERE status = 'paid')::numeric / COUNT(*) AS paid_ratio
FROM orders;
Зачем здесь ::numeric?
Потому что если делить целое число на целое число, можно получить целочисленное деление или потерять дробную часть в некоторых ситуациях. Приведение к numeric явно говорит базе, что нам нужен дробный результат.
Можно округлить долю:
SELECT
ROUND(
COUNT(*) FILTER (WHERE status = 'paid')::numeric / COUNT(*),
2
) AS paid_ratio
FROM orders;
Если из 10 заказов оплачены 7, результат будет:
paid_ratio
----------
0.70
Но здесь есть потенциальная проблема: если в таблице нет строк, COUNT(*) будет равен 0, и делить на ноль нельзя.
Более безопасный вариант:
SELECT
ROUND(
COUNT(*) FILTER (WHERE status = 'paid')::numeric / NULLIF(COUNT(*), 0),
2
) AS paid_ratio
FROM orders;
NULLIF(COUNT(*), 0) превращает ноль в NULL, чтобы избежать ошибки деления на ноль.
Несколько порогов в одном запросе
FILTER хорошо подходит, когда нужно посчитать несколько сегментов рядом.
Например, по сотрудникам:
SELECT
dept,
COUNT(*) AS employees_total,
COUNT(*) FILTER (WHERE salary >= 100000) AS salary_100k_plus,
COUNT(*) FILTER (WHERE salary >= 150000) AS salary_150k_plus,
COUNT(*) FILTER (WHERE salary >= 200000) AS salary_200k_plus
FROM employees
GROUP BY dept
ORDER BY dept;
Так можно быстро построить отчёт по порогам.
Ещё пример по заказам:
SELECT
COUNT(*) AS orders_total,
COUNT(*) FILTER (WHERE amount < 1000) AS small_orders,
COUNT(*) FILTER (WHERE amount >= 1000 AND amount < 5000) AS medium_orders,
COUNT(*) FILTER (WHERE amount >= 5000) AS large_orders
FROM orders;
Каждая колонка — отдельный сегмент.
Запрос при этом остаётся одним и читается довольно прозрачно.
COUNT DISTINCT с FILTER
FILTER можно использовать вместе с DISTINCT.
Например, нужно посчитать:
- всех пользователей, которые делали заказы;
- пользователей с оплаченными заказами;
- пользователей с возвратами.
SELECT
COUNT(DISTINCT user_id) AS users_with_any_orders,
COUNT(DISTINCT user_id) FILTER (WHERE status = 'paid') AS users_with_paid_orders,
COUNT(DISTINCT user_id) FILTER (WHERE status = 'refunded') AS users_with_refunds
FROM orders;
Здесь важно, что FILTER пишется после агрегата:
COUNT(DISTINCT user_id) FILTER (WHERE status = 'paid')
Такой запрос считает уникальных пользователей только среди строк, где статус равен paid.
Это полезно для продуктовой аналитики: можно считать не только количество событий, но и количество уникальных пользователей в каждом сегменте.
FILTER и общий WHERE вместе
WHERE и FILTER можно использовать вместе.
Например, мы хотим построить отчёт только за июнь 2026 года, но внутри этого периода посчитать разные статусы.
SELECT
COUNT(*) AS orders_total,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_orders,
SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid
FROM orders
WHERE created_at >= DATE '2026-06-01'
AND created_at < DATE '2026-07-01';
Здесь общий WHERE ограничивает данные июнем.
А FILTER внутри агрегатов делит июньские заказы на сегменты.
Это очень частый паттерн для отчётов:
сначала выбираем общий период
потом внутри него считаем разные метрики
Так меньше риска, что соседние метрики будут посчитаны по разным наборам данных.
FILTER помогает не рассинхронизировать отчёт
Представим, что нужно посчитать три метрики:
- оплаченные заказы;
- возвраты;
- выручку.
Можно написать три отдельных запроса. Но тогда легко ошибиться:
- в одном запросе забыть фильтр по дате;
- в другом использовать другой статус;
- в третьем случайно взять другую таблицу;
- в четвёртом не учесть тестовые заказы.
FILTER помогает держать метрики рядом.
Например:
SELECT
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_orders,
SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid
FROM orders
WHERE created_at >= DATE '2026-06-01'
AND created_at < DATE '2026-07-01'
AND is_test = false;
Общий фильтр по дате и тестовым заказам применяется ко всем метрикам.
А локальные условия внутри FILTER отвечают только за конкретные колонки отчёта.
Такой запрос проще ревьюить и поддерживать.
Производительность: почему это удобно
Когда несколько метрик считаются в одном запросе, базе не нужно выполнять несколько почти одинаковых запросов отдельно.
Например, вместо трёх запросов:
SELECT COUNT(*) FROM orders WHERE status = 'paid';
SELECT COUNT(*) FROM orders WHERE status = 'refunded';
SELECT SUM(amount) FROM orders WHERE status = 'paid';
можно написать один:
SELECT
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_orders,
SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid
FROM orders;
Это не означает, что любой запрос с FILTER всегда будет идеальным по скорости. План всё равно зависит от таблиц, индексов, условий, объёма данных и группировок.
Но для отчётов с несколькими агрегатами такой подход обычно удобнее:
- один запрос вместо нескольких;
- меньше повторного чтения одних и тех же данных;
- меньше шансов ошибиться в условиях;
- проще смотреть план выполнения;
- проще добавлять новые метрики.
Если запрос тяжёлый, его всё равно нужно проверять через EXPLAIN или EXPLAIN ANALYZE.
Когда FILTER может стать неудобным
FILTER хорош, когда у вас разумное количество метрик.
Например:
5–20 колонок отчёта
Но если вы начинаете писать огромный SELECT на сотню метрик, запрос может стать тяжёлым для чтения.
В таком случае стоит подумать о другом подходе:
- вынести сегменты в справочник;
- подготовить отдельную витрину;
- использовать группировку по статусу вместо отдельных колонок;
- считать часть метрик заранее;
- разбить отчёт на несколько логических блоков.
Например, если статусов много и они часто меняются, иногда лучше сделать так:
SELECT
status,
COUNT(*) AS orders_count,
SUM(amount) AS amount_total
FROM orders
GROUP BY status;
А не писать отдельный FILTER для каждого возможного статуса.
FILTER особенно хорош там, где набор метрик фиксирован и должен быть понятен человеку, который читает SQL.
FILTER и оконные функции
FILTER в первую очередь используется с агрегатными функциями.
В PostgreSQL его можно встретить и вместе с агрегатами, которые используются как оконные функции.
Например:
SELECT
id,
created_at,
status,
COUNT(*) FILTER (WHERE status = 'paid') OVER () AS paid_orders_total
FROM orders;
Но здесь важно не путать обычные агрегаты и оконные функции.
FILTER нельзя механически прикрутить к любой оконной функции. Например, к функциям вроде ROW_NUMBER() он не относится, потому что это не агрегат.
То есть такой стиль нужно использовать аккуратно и проверять поддержку в вашей СУБД.
Для начинающего уровня главное запомнить:
FILTER — это инструмент для агрегатов.
Чаще всего он используется с COUNT, SUM, AVG, MIN, MAX.
Поддержка в разных СУБД
Синтаксис FILTER (WHERE ...) поддерживается не везде.
В PostgreSQL он есть и активно используется.
Пример:
SELECT
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders
FROM orders;
В SQLite новых версий и DuckDB такой синтаксис тоже встречается.
А вот в MySQL и MariaDB обычно используют классический вариант через CASE WHEN.
Например, вместо PostgreSQL-запроса:
SELECT
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid
FROM orders;
в MySQL чаще пишут так:
SELECT
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_orders,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS revenue_paid
FROM orders;
В ClickHouse похожая идея часто выражается через специальные агрегатные функции.
Например:
SELECT
countIf(status = 'paid') AS paid_orders,
sumIf(amount, status = 'paid') AS revenue_paid
FROM orders;
Идея везде одна:
агрегат считает не все строки, а только строки нужного сегмента.
Но синтаксис зависит от СУБД.
Практические шаблоны
Количество заказов по статусам
SELECT
COUNT(*) AS orders_total,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_orders,
COUNT(*) FILTER (WHERE status = 'failed') AS failed_orders
FROM orders;
Выручка только по оплаченным заказам
SELECT
COALESCE(
SUM(amount) FILTER (WHERE status = 'paid'),
0
) AS revenue_paid
FROM orders;
Метрики за конкретный месяц
SELECT
COUNT(*) AS orders_total,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid
FROM orders
WHERE created_at >= DATE '2026-06-01'
AND created_at < DATE '2026-07-01';
Доля оплаченных заказов
SELECT
ROUND(
COUNT(*) FILTER (WHERE status = 'paid')::numeric / NULLIF(COUNT(*), 0),
2
) AS paid_ratio
FROM orders;
Уникальные пользователи по сегментам
SELECT
COUNT(DISTINCT user_id) AS users_total,
COUNT(DISTINCT user_id) FILTER (WHERE status = 'paid') AS users_paid,
COUNT(DISTINCT user_id) FILTER (WHERE status = 'refunded') AS users_refunded
FROM orders;
Метрики по странам
SELECT
u.country,
COUNT(DISTINCT u.id) AS users_total,
COUNT(DISTINCT u.id) FILTER (WHERE o.id IS NOT NULL) AS users_with_orders,
COUNT(*) FILTER (WHERE o.status = 'paid') AS paid_orders,
COALESCE(SUM(o.amount) FILTER (WHERE o.status = 'paid'), 0) AS revenue_paid
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.country
ORDER BY u.country;
Альтернатива для MySQL через CASE
SELECT
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_orders,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS revenue_paid
FROM orders;
Аналог в ClickHouse
SELECT
countIf(status = 'paid') AS paid_orders,
sumIf(amount, status = 'paid') AS revenue_paid
FROM orders;
Что важно запомнить
FILTER (WHERE ...) позволяет добавить условие к конкретному агрегату.
Например:
COUNT(*) FILTER (WHERE status = 'paid')
означает:
посчитать строки только со статусом paid.
Это удобно, когда в одном отчёте нужно получить несколько метрик по разным условиям.
Пример:
SELECT
COUNT(*) AS orders_total,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_orders,
SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid
FROM orders;
Главные правила:
WHERE фильтрует строки для всего запроса;
FILTER фильтрует строки только для одного агрегата;
- у каждого агрегата может быть своё условие;
COUNT при отсутствии строк возвращает 0;
SUM, AVG, MIN, MAX могут вернуть NULL;
- для отчётов часто нужен
COALESCE;
FILTER можно использовать с DISTINCT;
- в MySQL обычно пишут аналог через
SUM(CASE WHEN ...);
- в ClickHouse похожую задачу часто решают через
countIf и sumIf.
Короткий вывод
FILTER (WHERE ...) — это удобный способ считать условные агрегаты в SQL.
Он особенно полезен для отчётов и дашбордов, где нужно в одном запросе показать несколько метрик:
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'paid') AS paid,
COUNT(*) FILTER (WHERE status = 'refunded') AS refunded
FROM orders;
Такой запрос читается проще, чем набор подзапросов или длинные выражения с CASE WHEN.
Главная мысль:
общий WHERE выбирает данные для отчёта,
а FILTER делит эти данные на отдельные метрики.
Если ваша СУБД поддерживает FILTER, используйте его для условных агрегатов. Это делает SQL-запросы короче, понятнее и удобнее для поддержки.
Если FILTER не поддерживается, используйте классический вариант через SUM(CASE WHEN ...).
В отчётах редко бывает достаточно просто посчитать «все строки».
Обычно бизнесу нужны более точные метрики:
Можно написать для каждой метрики отдельный запрос. Можно собрать несколько подзапросов и потом соединить их через
JOIN. Можно использоватьCASE WHENвнутри агрегатов.Но в PostgreSQL есть более аккуратный и читаемый способ —
FILTER (WHERE ...).Он позволяет сказать:
Например:
COUNT(*) FILTER (WHERE status = 'paid')означает:
Это очень удобно для отчётов, дашбордов и витрин метрик, где в одной строке нужно показать сразу несколько показателей по разным условиям.
Что такое FILTER простыми словами
FILTER (WHERE ...)добавляется к агрегатной функции.Например, к таким функциям:
COUNT() SUM() AVG() MIN() MAX() COUNT(DISTINCT ...)Обычный агрегат считает данные по всей группе.
Например:
SELECT COUNT(*) AS orders_total FROM orders;Такой запрос считает все заказы.
А если нужно посчитать только оплаченные заказы, можно написать:
SELECT COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders FROM orders;Здесь
FILTERговорит агрегату:Важно:
FILTERработает не для всего запроса, а только для конкретного агрегата.То есть в одном
SELECTможно написать несколько агрегатов, и у каждого будет своё условие.Базовый пример
Допустим, есть таблица
orders:Хотим получить сразу несколько метрик:
Запрос:
SELECT COUNT(*) AS orders_total, COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders, COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_orders, COUNT(*) FILTER (WHERE status = 'failed') AS failed_orders FROM orders;Результат:
Такой запрос читается почти как описание отчёта:
И всё это делается в одном
SELECT.Чем FILTER отличается от обычного WHERE
Это очень важный момент.
Обычный
WHEREфильтрует строки для всего запроса.Например:
SELECT COUNT(*) AS orders_total FROM orders WHERE status = 'paid';Такой запрос сначала оставит только оплаченные заказы, а потом посчитает их.
То есть все остальные строки вообще не попадут в расчёт.
А
FILTERработает точечно, только для одного агрегата.SELECT COUNT(*) AS orders_total, COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders FROM orders;Здесь:
COUNT(*) AS orders_totalсчитает все строки.
А:
COUNT(*) FILTER (WHERE status = 'paid') AS paid_ordersсчитает только оплаченные строки.
То есть общий
WHEREотвечает за входные данные всего запроса, аFILTER— за условие внутри конкретной метрики.Можно запомнить так:
Пример: выручка по статусам
Теперь посчитаем не только количество заказов, но и сумму.
Допустим, в таблице
ordersесть суммы заказов:Хотим получить:
SELECT SUM(amount) AS amount_total, SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid, SUM(amount) FILTER (WHERE status = 'refunded') AS amount_refunded, SUM(amount) FILTER (WHERE status = 'failed') AS amount_failed FROM orders;Результат:
SUM(amount)считает сумму по всем строкам.А:
SUM(amount) FILTER (WHERE status = 'paid')считает сумму только по оплаченным заказам.
Это очень удобно: в одном запросе можно собрать целый блок метрик для отчёта.
Пример: несколько метрик по дням
FILTERособенно полезен вместе сGROUP BY.Допустим, мы хотим построить отчёт по дням:
SELECT DATE_TRUNC('day', created_at) AS day, COUNT(*) AS orders_total, COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders, COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_orders, SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid FROM orders GROUP BY DATE_TRUNC('day', created_at) ORDER BY day;Результат может быть таким:
Здесь каждая строка результата — отдельный день.
А внутри каждого дня агрегаты считают разные сегменты:
COUNT(*)— все заказы;COUNT(*) FILTER (WHERE status = 'paid')— только оплаченные;COUNT(*) FILTER (WHERE status = 'refunded')— только возвраты;SUM(amount) FILTER (WHERE status = 'paid')— выручку только по оплаченным заказам.Такой запрос хорошо подходит для графиков и дашбордов.
Пример: метрики по странам
Теперь пример посложнее.
Есть таблица
users:И таблица
orders:Хотим получить отчёт по странам:
Запрос:
SELECT u.country, COUNT(DISTINCT u.id) AS users_total, COUNT(DISTINCT u.id) FILTER (WHERE o.id IS NOT NULL) AS users_with_orders, COUNT(*) FILTER (WHERE o.status = 'paid') AS paid_orders, COALESCE(SUM(o.amount) FILTER (WHERE o.status = 'paid'), 0) AS revenue_paid, AVG(o.amount) FILTER (WHERE o.status = 'paid') AS avg_paid_order FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.country ORDER BY u.country;Здесь есть важная тонкость.
После
LEFT JOINодин пользователь может превратиться в несколько строк, если у него несколько заказов.Например, если у пользователя Anna два заказа, после соединения она будет встречаться два раза.
Поэтому для подсчёта пользователей безопаснее писать:
COUNT(DISTINCT u.id)А не просто:
COUNT(*)Иначе количество пользователей может случайно раздуться из-за заказов.
Разберём метрики:
COUNT(DISTINCT u.id) AS users_totalсчитает всех уникальных пользователей в стране.
COUNT(DISTINCT u.id) FILTER (WHERE o.id IS NOT NULL) AS users_with_ordersсчитает уникальных пользователей, у которых есть хотя бы один заказ.
COUNT(*) FILTER (WHERE o.status = 'paid') AS paid_ordersсчитает количество оплаченных заказов.
SUM(o.amount) FILTER (WHERE o.status = 'paid') AS revenue_paidсчитает сумму только по оплаченным заказам.
AVG(o.amount) FILTER (WHERE o.status = 'paid') AS avg_paid_orderсчитает средний чек только по оплаченным заказам.
Такой подход позволяет собрать несколько связанных метрик в одном понятном запросе.
FILTER против CASE WHEN
До появления
FILTERусловные агрегаты часто писали черезCASE WHEN.Например, количество оплаченных заказов:
SELECT COUNT(CASE WHEN status = 'paid' THEN 1 END) AS paid_orders FROM orders;Или выручка по оплаченным заказам:
SELECT SUM(CASE WHEN status = 'paid' THEN amount END) AS revenue_paid FROM orders;Это рабочий вариант.
Но с
FILTERтот же смысл читается проще:SELECT COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders, SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid FROM orders;В варианте с
FILTERсразу видно:А в варианте с
CASE WHENнужно внимательнее читать выражение внутри агрегата.Можно сказать так:
Для отчётов это обычно приятнее и понятнее.
Эквивалент через SUM(CASE WHEN ...)
Важно понимать, что
FILTERне делает что-то принципиально невозможное без него.Многие запросы можно переписать через
CASE.Например:
SELECT COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders FROM orders;Можно записать так:
SELECT SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_orders FROM orders;Оба запроса посчитают количество оплаченных заказов.
Выручка через
FILTER:SELECT SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid FROM orders;То же самое через
CASE:SELECT SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS revenue_paid FROM orders;CASEполезен, потому что работает почти во всех СУБД.А
FILTERполезен, потому что делает запрос чище, если ваша база его поддерживает.COUNT и NULL: почему это важно
С агрегатами важно помнить, как они работают с
NULL.COUNT(*)считает строки.COUNT(*)А
COUNT(column)считает только строки, гдеcolumnнеNULL.Например:
SELECT COUNT(*) AS rows_count, COUNT(amount) AS amount_count FROM orders;Результат:
Почему
amount_count = 2?Потому что
COUNT(amount)не считает строку, гдеamountравенNULL.С
FILTERэто тоже важно.Например:
COUNT(amount) FILTER (WHERE status = 'paid')посчитает только оплаченные строки, где
amountнеNULL.А:
COUNT(*) FILTER (WHERE status = 'paid')посчитает все оплаченные строки, даже если в каких-то из них
amountравенNULL.Для количества заказов чаще нужен
COUNT(*).Для количества заполненных значений в колонке —
COUNT(column).Что будет, если под FILTER не попала ни одна строка
У агрегатов разное поведение, когда подходящих строк нет.
COUNTвозвращает0.Например:
SELECT COUNT(*) FILTER (WHERE status = 'unknown') AS unknown_orders FROM orders;Если таких заказов нет, результат будет:
А вот
SUM,AVG,MIN,MAXобычно вернутNULL.Например:
SELECT SUM(amount) FILTER (WHERE status = 'unknown') AS unknown_amount FROM orders;Если подходящих строк нет, результат будет:
Для отчётов это часто неудобно. На дашборде обычно хочется видеть
0, а неNULL.В таком случае используют
COALESCE.SELECT COALESCE( SUM(amount) FILTER (WHERE status = 'unknown'), 0 ) AS unknown_amount FROM orders;Теперь, если строк нет, результат будет
0.Можно запомнить так:
Пример: зарплатный отчёт по отделам
Допустим, есть таблица
employees:Хотим по каждому отделу посчитать:
SELECT dept, COUNT(*) AS employees_total, COUNT(*) FILTER (WHERE salary > 150000) AS high_salary_employees, COALESCE( SUM(salary) FILTER (WHERE salary > 150000), 0 ) AS high_salary_payroll FROM employees GROUP BY dept ORDER BY dept;Результат:
Если в каком-то отделе не будет сотрудников с зарплатой выше 150000,
COUNTвернёт0, аSUMбезCOALESCEвернул быNULL.Доля сегмента через FILTER
FILTERудобно использовать для расчёта долей.Например, нужно посчитать долю оплаченных заказов среди всех заказов.
SELECT COUNT(*) FILTER (WHERE status = 'paid')::numeric / COUNT(*) AS paid_ratio FROM orders;Зачем здесь
::numeric?Потому что если делить целое число на целое число, можно получить целочисленное деление или потерять дробную часть в некоторых ситуациях. Приведение к
numericявно говорит базе, что нам нужен дробный результат.Можно округлить долю:
SELECT ROUND( COUNT(*) FILTER (WHERE status = 'paid')::numeric / COUNT(*), 2 ) AS paid_ratio FROM orders;Если из 10 заказов оплачены 7, результат будет:
Но здесь есть потенциальная проблема: если в таблице нет строк,
COUNT(*)будет равен0, и делить на ноль нельзя.Более безопасный вариант:
SELECT ROUND( COUNT(*) FILTER (WHERE status = 'paid')::numeric / NULLIF(COUNT(*), 0), 2 ) AS paid_ratio FROM orders;NULLIF(COUNT(*), 0)превращает ноль вNULL, чтобы избежать ошибки деления на ноль.Несколько порогов в одном запросе
FILTERхорошо подходит, когда нужно посчитать несколько сегментов рядом.Например, по сотрудникам:
SELECT dept, COUNT(*) AS employees_total, COUNT(*) FILTER (WHERE salary >= 100000) AS salary_100k_plus, COUNT(*) FILTER (WHERE salary >= 150000) AS salary_150k_plus, COUNT(*) FILTER (WHERE salary >= 200000) AS salary_200k_plus FROM employees GROUP BY dept ORDER BY dept;Так можно быстро построить отчёт по порогам.
Ещё пример по заказам:
SELECT COUNT(*) AS orders_total, COUNT(*) FILTER (WHERE amount < 1000) AS small_orders, COUNT(*) FILTER (WHERE amount >= 1000 AND amount < 5000) AS medium_orders, COUNT(*) FILTER (WHERE amount >= 5000) AS large_orders FROM orders;Каждая колонка — отдельный сегмент.
Запрос при этом остаётся одним и читается довольно прозрачно.
COUNT DISTINCT с FILTER
FILTERможно использовать вместе сDISTINCT.Например, нужно посчитать:
SELECT COUNT(DISTINCT user_id) AS users_with_any_orders, COUNT(DISTINCT user_id) FILTER (WHERE status = 'paid') AS users_with_paid_orders, COUNT(DISTINCT user_id) FILTER (WHERE status = 'refunded') AS users_with_refunds FROM orders;Здесь важно, что
FILTERпишется после агрегата:COUNT(DISTINCT user_id) FILTER (WHERE status = 'paid')Такой запрос считает уникальных пользователей только среди строк, где статус равен
paid.Это полезно для продуктовой аналитики: можно считать не только количество событий, но и количество уникальных пользователей в каждом сегменте.
FILTER и общий WHERE вместе
WHEREиFILTERможно использовать вместе.Например, мы хотим построить отчёт только за июнь 2026 года, но внутри этого периода посчитать разные статусы.
SELECT COUNT(*) AS orders_total, COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders, COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_orders, SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid FROM orders WHERE created_at >= DATE '2026-06-01' AND created_at < DATE '2026-07-01';Здесь общий
WHEREограничивает данные июнем.А
FILTERвнутри агрегатов делит июньские заказы на сегменты.Это очень частый паттерн для отчётов:
Так меньше риска, что соседние метрики будут посчитаны по разным наборам данных.
FILTER помогает не рассинхронизировать отчёт
Представим, что нужно посчитать три метрики:
Можно написать три отдельных запроса. Но тогда легко ошибиться:
FILTERпомогает держать метрики рядом.Например:
SELECT COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders, COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_orders, SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid FROM orders WHERE created_at >= DATE '2026-06-01' AND created_at < DATE '2026-07-01' AND is_test = false;Общий фильтр по дате и тестовым заказам применяется ко всем метрикам.
А локальные условия внутри
FILTERотвечают только за конкретные колонки отчёта.Такой запрос проще ревьюить и поддерживать.
Производительность: почему это удобно
Когда несколько метрик считаются в одном запросе, базе не нужно выполнять несколько почти одинаковых запросов отдельно.
Например, вместо трёх запросов:
SELECT COUNT(*) FROM orders WHERE status = 'paid'; SELECT COUNT(*) FROM orders WHERE status = 'refunded'; SELECT SUM(amount) FROM orders WHERE status = 'paid';можно написать один:
SELECT COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders, COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_orders, SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid FROM orders;Это не означает, что любой запрос с
FILTERвсегда будет идеальным по скорости. План всё равно зависит от таблиц, индексов, условий, объёма данных и группировок.Но для отчётов с несколькими агрегатами такой подход обычно удобнее:
Если запрос тяжёлый, его всё равно нужно проверять через
EXPLAINилиEXPLAIN ANALYZE.Когда FILTER может стать неудобным
FILTERхорош, когда у вас разумное количество метрик.Например:
Но если вы начинаете писать огромный
SELECTна сотню метрик, запрос может стать тяжёлым для чтения.В таком случае стоит подумать о другом подходе:
Например, если статусов много и они часто меняются, иногда лучше сделать так:
SELECT status, COUNT(*) AS orders_count, SUM(amount) AS amount_total FROM orders GROUP BY status;А не писать отдельный
FILTERдля каждого возможного статуса.FILTERособенно хорош там, где набор метрик фиксирован и должен быть понятен человеку, который читает SQL.FILTER и оконные функции
FILTERв первую очередь используется с агрегатными функциями.В PostgreSQL его можно встретить и вместе с агрегатами, которые используются как оконные функции.
Например:
SELECT id, created_at, status, COUNT(*) FILTER (WHERE status = 'paid') OVER () AS paid_orders_total FROM orders;Но здесь важно не путать обычные агрегаты и оконные функции.
FILTERнельзя механически прикрутить к любой оконной функции. Например, к функциям вродеROW_NUMBER()он не относится, потому что это не агрегат.То есть такой стиль нужно использовать аккуратно и проверять поддержку в вашей СУБД.
Для начинающего уровня главное запомнить:
Поддержка в разных СУБД
Синтаксис
FILTER (WHERE ...)поддерживается не везде.В PostgreSQL он есть и активно используется.
Пример:
SELECT COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders FROM orders;В SQLite новых версий и DuckDB такой синтаксис тоже встречается.
А вот в MySQL и MariaDB обычно используют классический вариант через
CASE WHEN.Например, вместо PostgreSQL-запроса:
SELECT COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders, SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid FROM orders;в MySQL чаще пишут так:
SELECT SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_orders, SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS revenue_paid FROM orders;В ClickHouse похожая идея часто выражается через специальные агрегатные функции.
Например:
SELECT countIf(status = 'paid') AS paid_orders, sumIf(amount, status = 'paid') AS revenue_paid FROM orders;Идея везде одна:
Но синтаксис зависит от СУБД.
Практические шаблоны
Количество заказов по статусам
SELECT COUNT(*) AS orders_total, COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders, COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_orders, COUNT(*) FILTER (WHERE status = 'failed') AS failed_orders FROM orders;Выручка только по оплаченным заказам
SELECT COALESCE( SUM(amount) FILTER (WHERE status = 'paid'), 0 ) AS revenue_paid FROM orders;Метрики за конкретный месяц
SELECT COUNT(*) AS orders_total, COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders, SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid FROM orders WHERE created_at >= DATE '2026-06-01' AND created_at < DATE '2026-07-01';Доля оплаченных заказов
SELECT ROUND( COUNT(*) FILTER (WHERE status = 'paid')::numeric / NULLIF(COUNT(*), 0), 2 ) AS paid_ratio FROM orders;Уникальные пользователи по сегментам
SELECT COUNT(DISTINCT user_id) AS users_total, COUNT(DISTINCT user_id) FILTER (WHERE status = 'paid') AS users_paid, COUNT(DISTINCT user_id) FILTER (WHERE status = 'refunded') AS users_refunded FROM orders;Метрики по странам
SELECT u.country, COUNT(DISTINCT u.id) AS users_total, COUNT(DISTINCT u.id) FILTER (WHERE o.id IS NOT NULL) AS users_with_orders, COUNT(*) FILTER (WHERE o.status = 'paid') AS paid_orders, COALESCE(SUM(o.amount) FILTER (WHERE o.status = 'paid'), 0) AS revenue_paid FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.country ORDER BY u.country;Альтернатива для MySQL через CASE
SELECT SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_orders, SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS revenue_paid FROM orders;Аналог в ClickHouse
SELECT countIf(status = 'paid') AS paid_orders, sumIf(amount, status = 'paid') AS revenue_paid FROM orders;Что важно запомнить
FILTER (WHERE ...)позволяет добавить условие к конкретному агрегату.Например:
COUNT(*) FILTER (WHERE status = 'paid')означает:
Это удобно, когда в одном отчёте нужно получить несколько метрик по разным условиям.
Пример:
SELECT COUNT(*) AS orders_total, COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders, COUNT(*) FILTER (WHERE status = 'refunded') AS refunded_orders, SUM(amount) FILTER (WHERE status = 'paid') AS revenue_paid FROM orders;Главные правила:
WHEREфильтрует строки для всего запроса;FILTERфильтрует строки только для одного агрегата;COUNTпри отсутствии строк возвращает0;SUM,AVG,MIN,MAXмогут вернутьNULL;COALESCE;FILTERможно использовать сDISTINCT;SUM(CASE WHEN ...);countIfиsumIf.Короткий вывод
FILTER (WHERE ...)— это удобный способ считать условные агрегаты в SQL.Он особенно полезен для отчётов и дашбордов, где нужно в одном запросе показать несколько метрик:
SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE status = 'paid') AS paid, COUNT(*) FILTER (WHERE status = 'refunded') AS refunded FROM orders;Такой запрос читается проще, чем набор подзапросов или длинные выражения с
CASE WHEN.Главная мысль:
Если ваша СУБД поддерживает
FILTER, используйте его для условных агрегатов. Это делает SQL-запросы короче, понятнее и удобнее для поддержки.Если
FILTERне поддерживается, используйте классический вариант черезSUM(CASE WHEN ...).