sqlpostgresqlaggregationfilter

COUNT(*) FILTER (WHERE ...): Conditional Aggregates in One Pass

How the FILTER clause computes several segmented metrics in a single pass and replaces clunky CASE-inside-aggregate.

10 min čitanjaReferencesql · postgresql · aggregation · filter · analytics
Ovaj članak trenutno je na ruskom — engleski prijevod je u izradi.

В отчётах редко бывает достаточно просто посчитать «все строки».

Обычно бизнесу нужны более точные метрики:

  • сколько всего заказов;
  • сколько заказов оплачено;
  • сколько заказов возвращено;
  • какая выручка только по успешным платежам;
  • сколько пользователей сделали хотя бы один заказ;
  • какая доля заказов завершилась оплатой.

Можно написать для каждой метрики отдельный запрос. Можно собрать несколько подзапросов и потом соединить их через 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 ...).

Vježbaj na stvarnim zadacima

Rješavaj zadatke u SQL treneru uz trenutno ocjenjivanje i savjete.

Otvori trener