SQLаналитика данныхPostgreSQLметрикиагрегация

Условная агрегация в SQL: SUM(CASE WHEN) и FILTER

2026-07-10 11 мин

Коротко: условная агрегация — это когда внутрь агрегатной функции (SUM, COUNT, AVG) вкладывают условие, чтобы за один проход по таблице посчитать сразу несколько метрик по разным срезам. Вместо трёх подзапросов или пяти JOIN пишете SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) — и получаете выручку только по оплаченным заказам в отдельной колонке. В PostgreSQL для этого есть ещё более чистый синтаксис — предложение FILTER (WHERE ...). Обе техники читают таблицу один раз, поэтому работают быстрее нагромождения самосоединений.

Что такое условная агрегация и зачем она нужна?

Представьте таблицу заказов orders с колонками id, user_id, status, amount, created_at. От вас хотят одну строку с четырьмя числами: всего заказов, из них оплачено, отменено и суммарная выручка по оплаченным. Наивный подход — четыре отдельных запроса с разными WHERE. Условная агрегация складывает всё это в один SELECT.

Идея простая: агрегатная функция игнорирует NULL. Значит, если внутри CASE WHEN для «неподходящих» строк вернуть NULL (или 0 для суммы), они не попадут в подсчёт. Условие вычисляется для каждой строки, а агрегат сворачивает результат.

Где это встречается каждый день:

Если синтаксис CASE и GROUP BY пока плывёт, пройдите базу в SQL с нуля: агрегаты COUNT, SUM, AVG, GROUP BY и в разборе NULL, CAST и CASE. Дальше будет проще.

Как посчитать метрики через SUM(CASE WHEN)?

SUM(CASE WHEN условие THEN значение ELSE 0 END) — рабочая лошадка условной агрегации. Внутри CASE вы описываете, какое значение прибавлять к сумме для каждой строки. Для строк, не прошедших условие, возвращаете 0 (или NULL — на сумму это не влияет).

Посчитаем несколько денежных метрик за один проход:

SELECT
    SUM(amount)                                          AS revenue_total,
    SUM(CASE WHEN status = 'paid'     THEN amount ELSE 0 END) AS revenue_paid,
    SUM(CASE WHEN status = 'refunded' THEN amount ELSE 0 END) AS revenue_refunded,
    SUM(CASE WHEN created_at >= date_trunc('month', now())
             THEN amount ELSE 0 END)                     AS revenue_this_month
FROM orders;

Результат — одна строка:

revenue_totalrevenue_paidrevenue_refundedrevenue_this_month
1 240 0001 180 00042 000310 000

Ключевой момент — ELSE 0. Для SUM можно было бы опустить его (тогда сработает NULL, который сумма пропустит), но явный 0 делает запрос читаемее и защищает от сюрпризов, если кто-то обернёт результат в COALESCE или сравнение. Про то, как NULL ведёт себя в агрегатах, подробно в шпаргалке по SQL для аналитика.

Долю тоже считают одним выражением. Например, доля выручки от вернувшихся клиентов:

SELECT
    ROUND(
        100.0 * SUM(CASE WHEN is_returning THEN amount ELSE 0 END)
              / NULLIF(SUM(amount), 0),
    2) AS returning_revenue_share_pct
FROM orders;
NULLIF(..., 0) в знаменателе спасает от деления на ноль — если заказов нет, вернётся NULL, а не ошибка.

Как считать количество по условию через COUNT(CASE WHEN)?

Здесь есть ловушка, на которой спотыкаются даже опытные. COUNT считает любое не-NULL значение, включая 0 и пустую строку. Поэтому для подсчёта «сколько строк удовлетворяют условию» в ELSE нужно ставить NULL, а не 0:

SELECT
    COUNT(*)                                              AS orders_total,
    COUNT(CASE WHEN status = 'paid' THEN 1 END)           AS orders_paid,
    COUNT(CASE WHEN status = 'paid' THEN 1 ELSE 0 END)    AS orders_wrong
FROM orders;

Колонка orders_paid посчитает только оплаченные (для остальных CASE вернёт NULL, и COUNT их пропустит). А orders_wrong из-за ELSE 0 посчитает вообще все строки — потому что 0 это не-NULL, и COUNT его учитывает. Это одна из 10 частых ошибок в SQL у аналитиков.

Более надёжный приём для подсчёта по условию — SUM(CASE WHEN условие THEN 1 ELSE 0 END). Тут ELSE 0 наоборот правильный, потому что суммирование единиц и есть подсчёт:

SELECT
    SUM(CASE WHEN status = 'paid'     THEN 1 ELSE 0 END) AS orders_paid,
    SUM(CASE WHEN status = 'canceled' THEN 1 ELSE 0 END) AS orders_canceled,
    COUNT(DISTINCT CASE WHEN status = 'paid'
                        THEN user_id END)                AS paying_users
FROM orders;

Последняя колонка показывает, что CASE дружит и с COUNT(DISTINCT ...) — так считают уникальных платящих пользователей за один проход. Запомните правило: для COUNT(CASE ...)ELSE NULL (или без ELSE), для SUM(CASE ...)ELSE 0.

Чем предложение FILTER удобнее CASE WHEN в Postgres?

PostgreSQL поддерживает синтаксис из стандарта SQL — предложение FILTER (WHERE ...) после агрегатной функции. Оно делает ровно то же, что CASE WHEN, но читается заметно чище:

SELECT
    COUNT(*)                              AS orders_total,
    COUNT(*) FILTER (WHERE status = 'paid')     AS orders_paid,
    COUNT(*) FILTER (WHERE status = 'canceled') AS orders_canceled,
    SUM(amount) FILTER (WHERE status = 'paid')  AS revenue_paid,
    AVG(amount) FILTER (WHERE status = 'paid')  AS avg_check_paid
FROM orders;

Сравните с эквивалентом на CASE WHENFILTER короче и не требует ручного выбора ELSE 0 vs ELSE NULL, потому что просто отбрасывает строки, не прошедшие WHERE. С COUNT, AVG, MIN, MAX это особенно приятно: не нужно думать, чем заполнять «пустые» строки.

Разница между двумя подходами:

КритерийSUM(CASE WHEN)FILTER (WHERE)
Где работаетвезде (Postgres, MySQL, SQLite, ClickHouse-аналоги)PostgreSQL, SQLite 3.30+, часть СУБД
Читаемостьсреднее, много скобоквысокая
Риск ошибки ELSEесть (0 vs NULL)нет
Работа с AVG/MIN/MAXнужен ELSE NULLиз коробки корректно

Практический вывод: если пишете под PostgreSQL — используйте FILTER, код будет чище. Если запрос должен переноситься между разными СУБД (например, часть аналитиков работает на MySQL), берите CASE WHEN — он портируемее. Попробовать оба варианта на живой базе можно в SQL-тренажёре: там доступны и PostgreSQL 16, и SQLite прямо в браузере.

Как собрать воронку конверсий одним запросом?

Классическая задача с собеседований: есть таблица событий events с колонками user_id, event (view, add_to_cart, checkout, purchase) и ts. Нужна воронка — сколько уникальных пользователей дошло до каждого этапа. Через самосоединения это превращается в кошмар, а условная агрегация решает задачу в одну строку:

SELECT
    COUNT(DISTINCT user_id) FILTER (WHERE event = 'view')        AS step_view,
    COUNT(DISTINCT user_id) FILTER (WHERE event = 'add_to_cart') AS step_cart,
    COUNT(DISTINCT user_id) FILTER (WHERE event = 'checkout')    AS step_checkout,
    COUNT(DISTINCT user_id) FILTER (WHERE event = 'purchase')    AS step_purchase
FROM events
WHERE ts >= now() - interval '30 days';

Получаем этапы воронки в соседних колонках. Дальше конверсию между этапами считают простым делением. Например, конверсия из корзины в покупку = step_purchase / step_cart * 100%. Формула сквозной конверсии: step_purchase / step_view * 100%.

На SQLite (или для переносимости) тот же запрос через CASE:

SELECT
    COUNT(DISTINCT CASE WHEN event = 'view'        THEN user_id END) AS step_view,
    COUNT(DISTINCT CASE WHEN event = 'add_to_cart' THEN user_id END) AS step_cart,
    COUNT(DISTINCT CASE WHEN event = 'checkout'    THEN user_id END) AS step_checkout,
    COUNT(DISTINCT CASE WHEN event = 'purchase'    THEN user_id END) AS step_purchase
FROM events
WHERE ts >= datetime('now', '-30 days');

Пример результата и посчитанных конверсий:

ЭтапПользователейКонверсия от предыдущего
view50 000
add_to_cart12 50025.0%
checkout6 00048.0%
purchase4 20070.0%

Сквозная конверсия view → purchase = 4200 / 50000 = 8.4%. Более развёрнутый разбор с процентами прямо в SQL — в статье воронка конверсии в SQL и в гайде про расчёт conversion rate. Сам показатель конверсии удобно раскладывать по когортам — тем же приёмом считается и retention D7.

Как посчитать выручку по сегментам в одной строке?

Условная агрегация блистает, когда нужно разложить выручку по нескольким срезам одновременно — по типу клиента, платформе и статусу сразу. Каждый срез — отдельная колонка:

SELECT
    SUM(amount) FILTER (WHERE segment = 'new')        AS revenue_new,
    SUM(amount) FILTER (WHERE segment = 'returning')  AS revenue_returning,
    SUM(amount) FILTER (WHERE platform = 'mobile')    AS revenue_mobile,
    SUM(amount) FILTER (WHERE platform = 'web')       AS revenue_web,
    ROUND(AVG(amount) FILTER (WHERE segment = 'new'), 2)       AS aov_new,
    ROUND(AVG(amount) FILTER (WHERE segment = 'returning'), 2) AS aov_returning
FROM orders
WHERE status = 'paid'
  AND created_at >= date_trunc('month', now());

За один проход получаем выручку в четырёх разрезах и средний чек по двум сегментам. Обратите внимание: общий WHERE фильтрует всю таблицу до оплаченных за текущий месяц, а FILTER внутри каждой функции нарезает уже отфильтрованные строки на сегменты. Это два независимых уровня фильтрации — сначала общий, потом попозиционный.

Часто к такому отчёту добавляют группировку по времени, чтобы увидеть динамику:

SELECT
    date_trunc('week', created_at)                    AS week,
    SUM(amount) FILTER (WHERE segment = 'new')        AS revenue_new,
    SUM(amount) FILTER (WHERE segment = 'returning')  AS revenue_returning,
    COUNT(*)    FILTER (WHERE amount > 5000)          AS big_orders
FROM orders
WHERE status = 'paid'
GROUP BY 1
ORDER BY 1;

Теперь каждая строка — неделя, а колонки — сегменты выручки и число крупных заказов. Средний чек тут — это метрика AOV; если сегментируете выручку на пользователя, пригодится и ARPU. Тот же приём лежит в основе RFM-сегментации и когортного анализа retention.

Если предпочитаете считать сегменты в Python поверх выгрузки, аналог SUM(CASE WHEN) — это df.groupby с agg или булевы маски; разбор в pandas с нуля: groupby, agg, pivot_table, а поэкспериментировать можно в Python-тренажёре.

Чем условная агрегация лучше лишних JOIN и подзапросов?

Наивное решение задачи «выручка по статусам» — написать три подзапроса и склеить их:

-- Так делать не надо: три прохода по таблице
SELECT
    (SELECT SUM(amount) FROM orders WHERE status = 'paid')     AS paid,
    (SELECT SUM(amount) FROM orders WHERE status = 'refunded') AS refunded,
    (SELECT SUM(amount) FROM orders WHERE status = 'canceled') AS canceled;

Проблемы такого подхода:

Условная агрегация делает то же за один проход:

SELECT
    SUM(amount) FILTER (WHERE status = 'paid')     AS paid,
    SUM(amount) FILTER (WHERE status = 'refunded') AS refunded,
    SUM(amount) FILTER (WHERE status = 'canceled') AS canceled
FROM orders;

Один SELECT, одно чтение таблицы, общий WHERE для всех метрик. Если хотите убедиться, что план запроса действительно эффективнее — прогоните оба варианта через EXPLAIN ANALYZE; как читать его вывод, разобрано в гайде по EXPLAIN ANALYZE.

Есть нюанс: условная агрегация не заменяет JOIN, когда данные реально лежат в разных таблицах. Она заменяет лишние самосоединения и повторные сканирования одной таблицы. Если нужно связать orders с users, JOIN остаётся — но метрики поверх соединённого набора всё равно считайте через FILTER/CASE, а не плодите подзапросы. Разбор всех типов соединений — в SQL JOIN: все типы с примерами.

Где условная агрегация проигрывает PIVOT?

Условная агрегация — это ручной PIVOT: вы вручную перечисляете каждую колонку-срез. Пока срезов немного и они известны заранее (статусы, платформы, этапы воронки) — это идеально: явно, читаемо, под контролем.

Слабые места начинаются, когда:

В PostgreSQL для настоящего разворота есть функция crosstab из расширения tablefunc, а в BI-инструментах — встроенный pivot. Когда категорий много и они меняются, лучше держать данные «в длину» (long format) и разворачивать на уровне отчёта. Сравнение подходов и синтаксис разворота — в статье SQL PIVOT: строки в столбцы.

Простое правило выбора:

Если хотите закрепить всё это на практике — в SQL-тренажёре есть задачи на воронки, сегментацию выручки и условную агрегацию: 5 задач открыты бесплатно, дальше доступ по Pro. Можно писать запросы на настоящем PostgreSQL 16 в браузере и сразу видеть, сошёлся ли результат. Хочется системно с азов — начните с курса по SQL. А перед собеседованием прогоните себя через реальные SQL-вопросы и AI мок-собеседование, где условная агрегация всплывает почти всегда.

Что запомнить про условную агрегацию

Это один из тех приёмов, что мгновенно отличают джуна от мидла на собеседовании: кандидат, который вместо трёх подзапросов пишет один SELECT с FILTER, сразу выглядит убедительнее. Отработайте паттерн до автоматизма — и «посчитайте конверсию по этапам одним запросом» перестанет быть страшной формулировкой.

Отработай условную агрегацию на реальных задачах
5 SQL-задач бесплатно, дальше Pro. PostgreSQL и SQLite прямо в браузере — пиши SUM(CASE WHEN) и FILTER, проверяй ответ мгновенно.
Открыть тренажёр →