Все материалы
Продуктовая аналитикагайдстарт

CASE WHEN в SQL: сегменты, условные метрики и порядок условий

CASE WHEN в SQL на учебной базе продукта: синтаксис, сегменты пользователей, почему порядок WHEN меняет ответ, ELSE и NULL, CASE внутри COUNT, в GROUP BY и ORDER BY.

КПКейсПрактика23 сентября 2026 г.14 мин

Продакт-менеджер просит разделить новых пользователей на три группы по первой неделе: заглянул один раз, вернулся, пользуется регулярно. В таблице такой колонки нет, её нужно вычислить из числа активных дней. Для этого в SQL есть CASE WHEN: он проверяет условия по очереди и возвращает значение первого совпавшего. Конструкция простая, но две её особенности регулярно портят отчёты: порядок условий и поведение без ELSE. На учебной базе SQL-курса неверный порядок веток отправляет 1 576 регулярных пользователей в группу «вернулся», и сегмент исчезает из отчёта без единой ошибки. Ниже — синтаксис, сегменты, условные метрики в колонках, сортировка и случаи, когда CASE пора заменить таблицей. Все запросы выполняются в песочнице курса и дают те же числа.

Коротко

CASE — это выражение, а не отдельная команда. Он стоит там же, где могла бы стоять колонка: в SELECT, внутри агрегата, в GROUP BY, ORDER BY и WHERE.

  • Ветки проверяются сверху вниз, выигрывает первая совпавшая. Широкое условие наверху забирает строки у всех следующих.
  • Без ELSE строка, не попавшая ни в одну ветку, получает NULL и в отчёте становится пустой группой.
  • CASE country WHEN NULL не срабатывает никогда: NULL проверяют только в поисковой форме, через WHEN country IS NULL.
  • count(CASE WHEN … THEN 1 ELSE 0 END) считает все строки. Для счёта уберите ELSE, для суммы используйте sum.
  • Все ветки должны возвращать один тип. Текст и число в одном CASE PostgreSQL и DuckDB отказываются смешивать.
  • Если CASE переписывает справочник, например каналы в группы каналов, ему место в таблице соответствий, а не в каждом отчёте.

Как записать CASE WHEN: простая и поисковая форма

У CASE две формы записи. Простая сравнивает одно выражение со списком значений: CASE channel WHEN 'organic' THEN … END. Поисковая проверяет произвольные условия: CASE WHEN active_days >= 4 THEN … END. Внутри поисковой формы можно сравнивать разные колонки, использовать диапазоны, IN, LIKE и IS NULL.

Обе формы заканчиваются словом END, и почти всегда за ним стоит алиас через AS. Без алиаса PostgreSQL назовёт колонку просто case, а DuckDB — текстом всего выражения, и сослаться на неё дальше будет неудобно.

На практике поисковая форма нужна в девяти случаях из десяти. Простая короче, когда вы переводите коды в названия, но у неё есть ловушка с NULL, о которой ниже.

Две формы CASE
ФормаКак выглядитКогда подходит
ПростаяCASE channel WHEN 'organic' THEN 'бесплатный' … ENDперевести код в название, одно выражение и точные значения
ПоисковаяCASE WHEN active_days >= 4 THEN 'regular' … ENDдиапазоны, несколько колонок, IS NULL, LIKE
Одна и та же разметка в двух формах
SELECT
  channel,
  CASE channel
    WHEN 'organic'  THEN 'бесплатный'
    WHEN 'referral' THEN 'бесплатный'
    ELSE 'платный'
  END AS by_value,
  CASE
    WHEN channel IN ('organic', 'referral') THEN 'бесплатный'
    ELSE 'платный'
  END AS by_condition
FROM users
LIMIT 5;

Как разбить пользователей на сегменты через CASE

Вернёмся к задаче из начала. Активный день — это календарный день, в который у пользователя было хотя бы одно событие. Считаем их за первую неделю: день регистрации и шесть следующих. Чтобы неделя была полной у всех, берём регистрации по 23 августа включительно: выгрузка учебной базы закрыта 30 августа в 13:00, и у более поздних пользователей первая неделя ещё не закончилась. Таких пользователей 4 142.

Порог выбран по распределению. Один активный день у 295 человек, два — у 908, три — у 1 363, четыре и больше — у 1 576. Три группы: one_day — один день, returned — два или три дня, regular — четыре и больше.

Запрос делает два шага. В CTE считается число дней на пользователя, снаружи CASE превращает число в сегмент, а GROUP BY считает людей в каждом.

Сегменты новых пользователей, регистрации 1 июня – 23 августа
СегментПравилоПользователей
regular4–7 активных дней1 576
returned2–3 активных дня2 271
one_day1 активный день295
Всего4 142
Сегменты по активности в первую неделю: 1 576, 2 271 и 295
WITH first_week AS (
  SELECT
    u.user_id,
    count(DISTINCT CAST(e.event_time AS DATE)) AS active_days
  FROM users u
  LEFT JOIN events e
    ON e.user_id = u.user_id
   AND e.event_time < u.signup_date + INTERVAL '7 days'
  WHERE u.signup_date <= DATE '2026-08-23'
  GROUP BY u.user_id
)
SELECT
  CASE
    WHEN active_days >= 4 THEN 'regular'
    WHEN active_days >= 2 THEN 'returned'
    ELSE 'one_day'
  END AS segment,
  count(*) AS users
FROM first_week
GROUP BY segment
ORDER BY users DESC;

-- returned 2271, regular 1576, one_day 295

Почему порядок WHEN меняет результат

CASE не ищет лучшую ветку, он берёт первую подходящую и дальше не смотрит. Поменяйте местами две строки в запросе выше: сначала WHEN active_days >= 2 THEN 'returned', потом WHEN active_days >= 4 THEN 'regular'. Пользователь с пятью активными днями удовлетворяет первому условию, получает returned, и до второй ветки дело не доходит.

Результат: returned — 3 847, one_day — 295, а regular в выдаче нет вовсе. Запрос не упал, сумма по сегментам сходится с 4 142, и в отчёте две строки вместо трёх. Если в дашборде сегмент выбирают фильтром, пропавшего значения там просто не будет, и заметить его отсутствие можно только по памяти.

Правил два. Если условия вложены друг в друга, как >= 4 внутри >= 2, ставьте самое узкое первым. Если хотите, чтобы порядок не имел значения, пишите обе границы: WHEN active_days BETWEEN 2 AND 3. Второй вариант длиннее, зато при правке одной ветки не ломает соседние. Проверка простая: после группировки посмотрите, все ли сегменты на месте и сходится ли сумма с числом пользователей.

Одни и те же 4 142 пользователя при двух порядках веток

При неверном порядке сегмент regular пустой: все 1 576 человек ушли в returned. Учебная база SQL-курса, первая неделя после регистрации.

Сначала >= 4Сначала >= 2

Что вернёт CASE без ELSE и почему WHEN NULL не срабатывает

Если ни одна ветка не подошла и ELSE не написан, CASE возвращает NULL. Так ведут себя PostgreSQL, DuckDB, MySQL и остальные базы: это правило стандарта. В учебной базе у 2 715 пользователей страна RU. Запрос CASE WHEN country = 'RU' THEN 'Россия' END даст две группы: «Россия» и NULL на 1 898 человек. В сводной таблице BI вторая строка часто подписана как пустая или «(null)», и читатель отчёта не понимает, кто в ней.

Вторая ловушка — простая форма. CASE country WHEN NULL THEN 'не указана' сравнивает country = NULL, а такое сравнение никогда не бывает истинным, даже если country пустая. У 55 пользователей страна не указана, но в группу «не указана» не попадает ни один: все 55 уезжают в ELSE. Проверять NULL можно только в поисковой форме: WHEN country IS NULL.

Подробнее о том, почему сравнение с NULL не бывает ни истинным, ни ложным, и как COALESCE превращает пропуск в значение, — в отдельной статье про NULL и COALESCE. Здесь достаточно правила: пишите ELSE всегда, даже если уверены, что все случаи перечислены. Лучше явная группа «другое», чем безымянный NULL.

Три записи разметки стран и что они дают
ЗапросРоссияНе указанаДругиеNULL
CASE country WHEN 'RU' … WHEN NULL … ELSE 'другие'2 71501 898
CASE WHEN country = 'RU' … WHEN country IS NULL … ELSE 'другие'2 715551 843
CASE WHEN country = 'RU' THEN 'Россия' END2 7151 898
Страна с явной группой для пропусков
SELECT
  CASE
    WHEN country = 'RU'  THEN 'Россия'
    WHEN country IS NULL THEN 'не указана'
    ELSE 'другие'
  END AS country_group,
  count(*) AS users
FROM users
GROUP BY country_group
ORDER BY users DESC;

-- Россия 2715, другие 1843, не указана 55

CASE внутри COUNT и SUM: метрики в колонках

Самое полезное место для CASE в аналитике — внутри агрегата. Так одна строка результата получает несколько метрик: сколько пользователей в канале и сколько из них купили каждый тариф. Приём называют условной агрегацией. Он заменяет PIVOT: в PostgreSQL такого оператора нет, а условная агрегация работает в любой базе.

Работает это так. Для каждой строки CASE возвращает 1, если условие выполнено, и NULL, если нет. count считает только не-NULL значения, поэтому в колонке pro окажутся только строки с тарифом pro. Платежи присоединены через LEFT JOIN с условием на первый платёж, чтобы продления не удвоили людей.

В таблице результата видно то, ради чего запрос писался: paid_search приводит почти столько же пользователей, сколько referral, но платят из него 8,5% против 26,8%.

Результат запроса
channelusersbasicproteampaying_pct
organic1 7472191444723,5
referral1 037162833326,8
paid_search1 015483088,5
partner81485391417,0
Пользователи и тарифы первой оплаты по каналам
SELECT
  u.channel,
  count(*)                                     AS users,
  count(CASE WHEN p.plan = 'basic' THEN 1 END) AS basic,
  count(CASE WHEN p.plan = 'pro'   THEN 1 END) AS pro,
  count(CASE WHEN p.plan = 'team'  THEN 1 END) AS team,
  round(100.0 * count(p.user_id) / count(*), 1) AS paying_pct
FROM users u
LEFT JOIN payments p
  ON p.user_id = u.user_id
 AND p.payment_type = 'first'
GROUP BY u.channel
ORDER BY users DESC;

Ловушка ELSE 0 внутри COUNT

Частая ошибка — дописать ELSE 0 внутри count «для аккуратности»: count(CASE WHEN p.plan = 'pro' THEN 1 ELSE 0 END). Ноль — это не NULL, count его посчитает. Вместо 296 пользователей с тарифом pro запрос вернёт 4 613, то есть всех.

Выбор функции зависит от того, что возвращают ветки. С единицей и NULL подходит count. С единицей и нулём — sum: sum(CASE WHEN … THEN 1 ELSE 0 END) тоже даст 296. Для доли удобнее avg с единицей и нулём: avg(CASE WHEN p.user_id IS NOT NULL THEN 1 ELSE 0 END) — это 19,77% плательщиков. Уберите ELSE 0, и avg посчитает среднее только по единицам: получится 100%.

В PostgreSQL и DuckDB есть более короткая запись того же — FILTER: count(*) FILTER (WHERE p.plan = 'pro') возвращает 296. Она читается легче, но MySQL, SQL Server и ClickHouse её не поддерживают, так что для переносимого запроса CASE надёжнее. Разбор FILTER и широких отчётов вместо PIVOT — в статье про агрегацию.

Одно условие, четыре записи
ВыражениеРезультатЧто считает
count(CASE WHEN plan = 'pro' THEN 1 END)296строки с pro
count(CASE WHEN plan = 'pro' THEN 1 ELSE 0 END)4 613все строки
sum(CASE WHEN plan = 'pro' THEN 1 ELSE 0 END)296строки с pro
count(*) FILTER (WHERE plan = 'pro')296строки с pro, только PostgreSQL и DuckDB

CASE в GROUP BY и ORDER BY

Сегмент из CASE группируют так же, как обычную колонку. В GROUP BY можно повторить выражение целиком, сослаться на алиас (GROUP BY segment) или на номер колонки (GROUP BY 1). Алиас в GROUP BY понимают и PostgreSQL, и DuckDB.

В ORDER BY CASE задаёт порядок, которого нет в алфавите. Сегменты логично показывать от слабого к сильному: one_day, returned, regular. По алфавиту regular встанет раньше returned. Решение — отсортировать по номеру, который возвращает CASE.

Здесь PostgreSQL строже DuckDB. Алиас из SELECT он разрешает в ORDER BY только как отдельное имя. Внутри выражения — нет: ORDER BY CASE ch WHEN 'partner' THEN 1 ELSE 2 END, где ch — алиас колонки, падает с ошибкой column "ch" does not exist. DuckDB тот же запрос выполняет. Надёжный вариант для обеих баз — сортировать по настоящей колонке из CTE, как в запросе ниже.

Заодно запрос отвечает на продуктовый вопрос: чем активнее первая неделя, тем выше доля плательщиков. Из one_day платят 15,3%, из returned — 20,4%, из regular — 24,9%. Это корреляция, а не доказательство, что активность приводит к оплате, но сегмент уже можно использовать для приоритета онбординга.

Сегменты в логическом порядке и доля плательщиков
WITH first_week AS (
  SELECT
    u.user_id,
    count(DISTINCT CAST(e.event_time AS DATE)) AS active_days
  FROM users u
  LEFT JOIN events e
    ON e.user_id = u.user_id
   AND e.event_time < u.signup_date + INTERVAL '7 days'
  WHERE u.signup_date <= DATE '2026-08-23'
  GROUP BY u.user_id
),
segmented AS (
  SELECT
    user_id,
    CASE
      WHEN active_days >= 4 THEN 'regular'
      WHEN active_days >= 2 THEN 'returned'
      ELSE 'one_day'
    END AS segment
  FROM first_week
)
SELECT
  s.segment,
  count(*)         AS users,
  count(p.user_id) AS payers,
  round(100.0 * count(p.user_id) / count(*), 1) AS paying_pct
FROM segmented s
LEFT JOIN payments p
  ON p.user_id = s.user_id
 AND p.payment_type = 'first'
GROUP BY s.segment
ORDER BY CASE s.segment
  WHEN 'one_day'  THEN 1
  WHEN 'returned' THEN 2
  WHEN 'regular'  THEN 3
END;

-- one_day 295 / 45 / 15.3; returned 2271 / 464 / 20.4; regular 1576 / 393 / 24.9

Какой тип возвращает CASE и почему смешивать типы нельзя

У результата CASE один тип на все строки. База выбирает его по веткам THEN и ELSE и требует, чтобы они сводились к одному. Попытка вернуть текст в одной ветке и число в другой заканчивается ошибкой.

Если текстовая ветка — колонка, PostgreSQL 16 отвечает CASE types integer and character varying cannot be matched, DuckDB — Cannot mix values of type INTEGER_LITERAL and VARCHAR in CASE expression - an explicit cast is required. Если текстовая ветка — литерал, как в THEN 'big' ELSE 0, ошибка выглядит иначе и сбивает с толку: PostgreSQL пытается прочитать 'big' как целое и пишет invalid input syntax for type integer: "big", DuckDB — Could not convert string 'big' to INT32.

Исправление одно: привести ветки к общему типу. Для меток это текст во всех ветках, ELSE '0' вместо ELSE 0. Для чисел — одинаковая точность, например THEN amount ELSE 0.0. Остальные сообщения, которые встречаются в запросах с CASE и GROUP BY, собраны в справочнике ошибок SQL.

Когда CASE стоит заменить справочной таблицей

CASE хорош, пока правило живёт в одном запросе. Когда та же разметка каналов на «платный» и «бесплатный» повторяется в пяти отчётах, она начинает расходиться: кто-то добавил новый канал в один отчёт и забыл про остальные. Хуже всего то, что новый канал молча попадает в ELSE и маскируется под существующую группу.

Разметка, которая описывает справочник, должна жить как таблица соответствий. В запросе её можно задать через VALUES, в хранилище — отдельной таблицей или view. Присоединяется она через LEFT JOIN, и всё, чего в справочнике нет, получает видимую метку «не размечен», а не случайную группу.

В учебной базе четыре канала: бесплатных пользователей 2 784, платных — 1 829. Если завтра появится канал, которого нет в справочнике, он появится в отчёте отдельной строкой, и это заметят. Оставляйте CASE для правил, которые считают что-то по данным, как сегмент по активным дням. Переименование кодов и группировку значений выносите в таблицу.

Группы каналов через таблицу соответствий
SELECT
  coalesce(m.channel_group, 'не размечен') AS channel_group,
  count(*) AS users
FROM users u
LEFT JOIN (VALUES
  ('organic',     'бесплатный'),
  ('referral',    'бесплатный'),
  ('paid_search', 'платный'),
  ('partner',     'платный')
) AS m(channel, channel_group)
  ON m.channel = u.channel
GROUP BY 1
ORDER BY users DESC;

-- бесплатный 2784, платный 1829

CASE, IF, IIF и multiIf: что есть в других базах

CASE входит в стандарт SQL и работает везде. Короткие функции-замены у каждой базы свои, и переносимость они ломают.

Условные выражения в разных СУБД
БазаКроме CASEЧто важно знать
PostgreSQL, DuckDBFILTER в агрегатахcount(*) FILTER (WHERE …) заменяет CASE внутри count
MySQLIF(условие, да, нет)возвращает третий аргумент и тогда, когда условие NULL
SQL ServerIIF(условие, да, нет)по документации это сокращение CASE, вложенность ограничена 10 уровнями
ClickHouseif и multiIfCASE WHEN внутри реализован через multiIf

Частые вопросы

Можно ли использовать CASE в WHERE? Можно: WHERE CASE WHEN … THEN 1 ELSE 0 END = 1. Но почти всегда то же условие короче и понятнее записать через AND и OR. К тому же функция над колонкой в WHERE мешает базе использовать индекс.

Можно ли вкладывать CASE в CASE? Можно, но уже на втором уровне запрос трудно читать. Обычно вложенность заменяется одной поисковой формой с условиями через AND.

Чем CASE отличается от COALESCE? COALESCE возвращает первое не-NULL значение из списка и делает одну работу: подставляет замену для пропуска. CASE проверяет любые условия. coalesce(country, 'не указана') — то же самое, что CASE WHEN country IS NULL THEN 'не указана' ELSE country END, только короче.

Сколько веток можно написать? Упираться вы будете не в базу, а в читаемость. Если веток больше семи-восьми и они переводят коды в названия, это справочник, и ему место в таблице.

Практика на учебной базе

Три задачи. База та же, что в песочнице SQL-курса, и числа должны совпасть до единицы.

Первая. Посчитайте по каждому устройству число пользователей и число тех, кто зарегистрировался в субботу или воскресенье. Подсказка: extract(isodow FROM signup_date) возвращает 6 и 7 для выходных. Ответ: desktop — 2 384 и 332, mobile — 2 229 и 301.

Вторая. Одной строкой выведите участников и конверсии эксперимента по двум вариантам в четырёх колонках. Ответ: checklist — 467 из 1 541, control — 306 из 1 527.

Третья. Разделите выручку на первые платежи и продления в двух колонках. Подсказка: здесь нужен sum с ELSE 0. Ответ: 22 328 и 8 311, всего 30 639.

Итог

CASE WHEN превращает условие в значение, и на этом держатся сегменты, условные метрики и нестандартная сортировка. Ошибки в нём тихие: неверный порядок веток стирает сегмент, отсутствие ELSE создаёт пустую группу, а ELSE 0 внутри count считает все строки. После любого CASE проверьте три вещи: все ли группы на месте, сходится ли сумма по группам с общим числом и нет ли в результате NULL, которого вы не ждали.

В SQL-курсе CASE, NULL и CTE собраны в пятой главе: там из них складывается финальная выгрузка первой части курса с автоматической проверкой. Первые главы курса открыты бесплатно.

Продолжить чтение
Вся библиотека