CASE WHEN в SQL: сегменты, условные метрики и порядок условий
CASE WHEN в SQL на учебной базе продукта: синтаксис, сегменты пользователей, почему порядок WHEN меняет ответ, ELSE и NULL, CASE внутри COUNT, в GROUP BY и ORDER BY.
Содержание статьи
Продакт-менеджер просит разделить новых пользователей на три группы по первой неделе: заглянул один раз, вернулся, пользуется регулярно. В таблице такой колонки нет, её нужно вычислить из числа активных дней. Для этого в 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 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 считает людей в каждом.
| Сегмент | Правило | Пользователей |
|---|---|---|
| regular | 4–7 активных дней | 1 576 |
| returned | 2–3 активных дня | 2 271 |
| one_day | 1 активный день | 295 |
| Всего | 4 142 |
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. Второй вариант длиннее, зато при правке одной ветки не ломает соседние. Проверка простая: после группировки посмотрите, все ли сегменты на месте и сходится ли сумма с числом пользователей.
При неверном порядке сегмент regular пустой: все 1 576 человек ушли в returned. Учебная база SQL-курса, первая неделя после регистрации.
Что вернёт 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 715 | 0 | 1 898 | — |
| CASE WHEN country = 'RU' … WHEN country IS NULL … ELSE 'другие' | 2 715 | 55 | 1 843 | — |
| CASE WHEN country = 'RU' THEN 'Россия' END | 2 715 | — | — | 1 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, не указана 55CASE внутри COUNT и SUM: метрики в колонках
Самое полезное место для CASE в аналитике — внутри агрегата. Так одна строка результата получает несколько метрик: сколько пользователей в канале и сколько из них купили каждый тариф. Приём называют условной агрегацией. Он заменяет PIVOT: в PostgreSQL такого оператора нет, а условная агрегация работает в любой базе.
Работает это так. Для каждой строки CASE возвращает 1, если условие выполнено, и NULL, если нет. count считает только не-NULL значения, поэтому в колонке pro окажутся только строки с тарифом pro. Платежи присоединены через LEFT JOIN с условием на первый платёж, чтобы продления не удвоили людей.
В таблице результата видно то, ради чего запрос писался: paid_search приводит почти столько же пользователей, сколько referral, но платят из него 8,5% против 26,8%.
| channel | users | basic | pro | team | paying_pct |
|---|---|---|---|---|---|
| organic | 1 747 | 219 | 144 | 47 | 23,5 |
| referral | 1 037 | 162 | 83 | 33 | 26,8 |
| paid_search | 1 015 | 48 | 30 | 8 | 8,5 |
| partner | 814 | 85 | 39 | 14 | 17,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, платный 1829CASE, IF, IIF и multiIf: что есть в других базах
CASE входит в стандарт SQL и работает везде. Короткие функции-замены у каждой базы свои, и переносимость они ломают.
| База | Кроме CASE | Что важно знать |
|---|---|---|
| PostgreSQL, DuckDB | FILTER в агрегатах | count(*) FILTER (WHERE …) заменяет CASE внутри count |
| MySQL | IF(условие, да, нет) | возвращает третий аргумент и тогда, когда условие NULL |
| SQL Server | IIF(условие, да, нет) | по документации это сокращение CASE, вложенность ограничена 10 уровнями |
| ClickHouse | if и multiIf | CASE 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 собраны в пятой главе: там из них складывается финальная выгрузка первой части курса с автоматической проверкой. Первые главы курса открыты бесплатно.
Материалы по теме
Функции даты в SQL: текущая дата, разница дат, EXTRACT и формат
Справочник функций даты в SQL на учебной базе: текущая дата, часть даты, date_trunc, разница дат в днях, часах и месяцах, интервалы, формат вывода и отличия СУБД.

SQL CASE, COALESCE и NULL: как не сломать сегменты и метрики
Разбираем NULL, CASE и COALESCE на задачах аналитика: как группировать пустые значения, создавать сегменты и показывать ноль вместо пропущенных данных.
Что такое SQL простыми словами: реляционная база, таблицы и запросы
Что такое SQL и реляционная база данных: таблица, строка и колонка, первый запрос с результатом, JOIN, команды языка, СУБД и диалекты, что SQL умеет и чего не умеет.