Коротко: PIVOT в SQL — это разворот строк в столбцы, когда значения одного поля (например, месяцы) становятся заголовками колонок, а в ячейках лежит агрегат (сумма, количество). В PostgreSQL и ClickHouse нативного оператора PIVOT нет, поэтому сводную собирают вручную: SUM(CASE WHEN month = 'янв' THEN revenue END) для каждой колонки, либо через более чистый SUM(revenue) FILTER (WHERE month = 'янв') в PostgreSQL. Обратная операция (UNPIVOT — столбцы обратно в строки) делается через UNION ALL или LATERAL.
Зачем аналитику превращать строки в столбцы?
Базы данных хранят данные в «длинном» формате (long / narrow): одна строка — один факт. Продажа категории «Электроника» в марте — это одна строка (category, month, revenue). Это удобно для записи, индексов и агрегаций, но нечитаемо для человека, который хочет увидеть картину «категории по вертикали, месяцы по горизонтали».
Сводная таблица (pivot, cross-tab) переводит данные в «широкий» формат (wide), где каждое уникальное значение измерения становится отдельной колонкой. Именно так выглядит отчёт в Excel-сводной или в BI-дашборде.
Типичные задачи, где нужен разворот:
- Выручка по категориям товаров × месяцам — классический управленческий отчёт.
- Количество заказов по регионам × дням недели — поиск сезонности.
- Конверсия по каналам × шагам воронки — если строишь воронку конверсии в SQL.
- Retention-матрица: когорты × недели жизни, основа когортного анализа.
На собеседованиях аналитика задача «разверни строки в столбцы» встречается регулярно — это проверка на знание агрегатов с условием. Если готовишься, загляни в подборку топ-50 SQL-вопросов на собеседовании.
Почему в PostgreSQL и ClickHouse нет оператора PIVOT?
Оператор PIVOT есть в SQL Server, Oracle и некоторых версиях диалектов, но это не часть стандарта ANSI SQL. PostgreSQL и ClickHouse решили не тащить его в ядро по простой причине: PIVOT требует, чтобы список колонок был известен заранее, а SQL по своей природе работает с фиксированной схемой результата. Движок должен знать имена и типы всех колонок до выполнения запроса. Если ты пивотишь по месяцам, а в данных появился новый месяц — запрос про него не узнает, пока ты руками не допишешь колонку.
Поэтому «настоящий» динамический PIVOT (когда колонки берутся из данных) всегда упирается либо в генерацию SQL строкой, либо в расширения:
- PostgreSQL предлагает функцию
crosstab()из расширенияtablefunc. Это рабочий, но неуклюжий инструмент: нужно явно описывать имена и типы выходных колонок, а синтаксис тяжело читается. - ClickHouse не имеет ни
PIVOT, ниcrosstab, но даёт мощные функции-массивы (groupArray,sumMap) и-If-комбинаторы агрегатов, которые фактически заменяют разворот.
Хорошая новость: в 90% реальных отчётов список колонок известен (12 месяцев, 7 дней недели, 4 квартала), и статический pivot через CASE/FILTER полностью решает задачу — переносимо между всеми диалектами. Именно на нём и сосредоточимся.
Как собрать сводную через CASE WHEN + SUM?
Базовый приём: для каждой будущей колонки пишем агрегат с условием внутри. CASE WHEN возвращает значение только для нужного месяца, иначе NULL, а SUM игнорирует NULL — так суммируется только «своя» ячейка.
Пусть есть таблица продаж в длинном формате:
-- sales: (category TEXT, sale_month TEXT, revenue NUMERIC)
-- 'Электроника' | '2026-01' | 120000
-- 'Электроника' | '2026-02' | 145000
-- 'Одежда' | '2026-01' | 80000
-- ...
Разворачиваем месяцы в колонки:
SELECT
category,
SUM(CASE WHEN sale_month = '2026-01' THEN revenue ELSE 0 END) AS jan,
SUM(CASE WHEN sale_month = '2026-02' THEN revenue ELSE 0 END) AS feb,
SUM(CASE WHEN sale_month = '2026-03' THEN revenue ELSE 0 END) AS mar,
SUM(revenue) AS total
FROM sales
WHERE sale_month BETWEEN '2026-01' AND '2026-03'
GROUP BY category
ORDER BY total DESC;
Результат:
| category | jan | feb | mar | total |
|---|---|---|---|---|
| Электроника | 120000 | 145000 | 160000 | 425000 |
| Одежда | 80000 | 92000 | 88000 | 260000 |
| Книги | 15000 | 14000 | 19000 | 48000 |
Что здесь важно понять:
GROUP BY categoryзадаёт строки итоговой таблицы — это «ось Y» сводной.- Каждый
SUM(CASE ...)— это отдельная колонка, «ось X». Список колонок ты пишешь руками. ELSE 0противELSE NULL— вопрос вкуса. СNULLпустые ячейки останутся пустыми (иногда это честнее — «данных не было»), с0таблица визуально плотнее. Если хочешь считать среднее по колонке потом,NULLкорректнее, потому чтоAVGне учтёт пропуски.
Тот же приём работает для COUNT (сколько заказов), AVG (средний чек) или MAX. Например, количество заказов вместо выручки — просто COUNT(CASE WHEN sale_month = '2026-01' THEN 1 END).
Хочешь освежить, как работают GROUP BY и агрегаты под капотом — есть разбор GROUP BY и HAVING: порядок выполнения и базовая часть про агрегаты в курсе SQL с нуля.
Чем FILTER (WHERE ...) удобнее CASE в PostgreSQL?
PostgreSQL (и ClickHouse, и SQLite новых версий) поддерживает стандартный ANSI-синтаксис FILTER, который делает то же самое, но читается заметно чище:
SELECT
category,
SUM(revenue) FILTER (WHERE sale_month = '2026-01') AS jan,
SUM(revenue) FILTER (WHERE sale_month = '2026-02') AS feb,
SUM(revenue) FILTER (WHERE sale_month = '2026-03') AS mar,
SUM(revenue) AS total
FROM sales
WHERE sale_month BETWEEN '2026-01' AND '2026-03'
GROUP BY category
ORDER BY total DESC;
Три преимущества FILTER перед CASE:
- Читаемость. Условие вынесено вправо и не ломает выражение агрегата. Сразу видно: «сумма revenue, но только для января».
- Меньше ошибок с
ELSE. ВCASEбез явногоELSEвозвращаетсяNULL— это ок дляSUM/COUNT, но новички часто пишутELSE 0и портятAVG. ВFILTERэтой ловушки нет: строки, не прошедшие условие, просто не попадают в агрегат. - **Работает с
COUNT(*).**COUNT(*) FILTER (WHERE status = 'paid')— элегантный способ посчитать долю оплаченных, что черезCASEвыглядит громоздче.
Важная деталь: CASE WHEN переносим между ВСЕМИ диалектами SQL без исключений, а FILTER поддерживается в PostgreSQL, ClickHouse, SQLite ≥ 3.30 и в стандарте, но, например, в MySQL до недавних версий его не было. Если пишешь запрос, который должен работать где угодно, — бери CASE. Если работаешь в Postgres или ClickHouse — FILTER предпочтительнее.
В ClickHouse есть ещё третий, самый короткий вариант — комбинатор -If:
SELECT
category,
sumIf(revenue, sale_month = '2026-01') AS jan,
sumIf(revenue, sale_month = '2026-02') AS feb,
sumIf(revenue, sale_month = '2026-03') AS mar
FROM sales
GROUP BY category;
sumIf, countIf, avgIf — идиоматичный ClickHouse-способ. Подробнее про особенности движка — в практическом гайде по ClickHouse.
Как построить выручку по категориям × месяцам одним запросом?
Соберём отчёт целиком, как его ждёт финансовый менеджер: категории по строкам, месяцы по колонкам, плюс итоговая колонка и доля категории в общей выручке. Возьмём полный год, чтобы было наглядно, и покажем компактную версию на четыре квартала.
WITH monthly AS (
SELECT
category,
EXTRACT(QUARTER FROM sale_date)::int AS q,
revenue
FROM sales
WHERE sale_date >= DATE '2026-01-01'
AND sale_date < DATE '2027-01-01'
)
SELECT
category,
SUM(revenue) FILTER (WHERE q = 1) AS q1,
SUM(revenue) FILTER (WHERE q = 2) AS q2,
SUM(revenue) FILTER (WHERE q = 3) AS q3,
SUM(revenue) FILTER (WHERE q = 4) AS q4,
SUM(revenue) AS total_year,
ROUND(
100.0 * SUM(revenue) / SUM(SUM(revenue)) OVER (),
1
) AS share_pct
FROM monthly
GROUP BY category
ORDER BY total_year DESC;
Разбор нетривиальных мест:
- CTE
monthlyсначала вытаскивает квартал из даты черезEXTRACT. Если работа с датами буксует — есть отдельная часть курса про date_trunc, extract и interval. SUM(SUM(revenue)) OVER ()— двухуровневая агрегация. ВнутреннийSUM(revenue)считает выручку категории (в рамкахGROUP BY), внешнийSUM(...) OVER ()— оконная функция, суммирующая эти значения по всем строкам, то есть даёт общую выручку. Деление одного на другое — доля категории. Если оконные функции в новинку, начни с полного гайда по window functions.ROUND(..., 1)округляет процент до десятых.
Пример результата:
| category | q1 | q2 | q3 | q4 | total_year | share_pct |
|---|---|---|---|---|---|---|
| Электроника | 425000 | 480000 | 510000 | 690000 | 2105000 | 47.8 |
| Одежда | 260000 | 310000 | 290000 | 420000 | 1280000 | 29.1 |
| Книги | 48000 | 52000 | 61000 | 95000 | 256000 | 5.8 |
Такой отчёт связывается с продуктовыми метриками напрямую: total_year по категориям складывается в общий GMV маркетплейса, а средняя ячейка по строке — это грубая прокси среднего чека AOV в динамике. Про разбор такого кейса целиком — маркетплейс: GMV и take rate.
Хочешь потрогать этот запрос на живых данных — открой SQL-тренажёр: там настоящий PostgreSQL 16 в браузере, можно менять FILTER на CASE и сравнивать план выполнения.
Как выполнить обратную операцию UNPIVOT?
UNPIVOT — разворот в обратную сторону: широкая таблица (месяцы-колонки) снова превращается в длинную (одна строка на месяц). Это нужно, когда данные пришли уже сводными (выгрузка из Excel, витрина с колонками jan, feb, mar), а тебе для анализа, джойнов или загрузки в BI требуется нормальный long-формат.
Нативного UNPIVOT в PostgreSQL тоже нет. Два рабочих способа.
Способ 1 — UNION ALL. Прямолинейно и переносимо в любой диалект:
SELECT category, '2026-01' AS sale_month, jan AS revenue FROM wide_sales
UNION ALL
SELECT category, '2026-02', feb FROM wide_sales
UNION ALL
SELECT category, '2026-03', mar FROM wide_sales;
Минус очевиден: таблица wide_sales сканируется столько раз, сколько колонок разворачиваешь. Для трёх месяцев ок, для 12 — уже расточительно.
Способ 2 — LATERAL + VALUES (PostgreSQL). Таблица сканируется один раз, а каждая строка «размножается» на нужное число:
SELECT
w.category,
v.sale_month,
v.revenue
FROM wide_sales w
CROSS JOIN LATERAL (
VALUES
('2026-01', w.jan),
('2026-02', w.feb),
('2026-03', w.mar)
) AS v(sale_month, revenue)
WHERE v.revenue IS NOT NULL;
В ClickHouse обратный разворот делают через arrayJoin: складываешь значения в массив и «взрываешь» его в строки — тоже один проход по таблице.
Результат UNPIVOT для обоих способов одинаковый:
| category | sale_month | revenue |
|---|---|---|
| Электроника | 2026-01 | 120000 |
| Электроника | 2026-02 | 145000 |
| Электроника | 2026-03 | 160000 |
Длинный формат после UNPIVOT удобнее для дальнейшей аналитики: по нему легко считать оконные функции, join'ить с другими таблицами и строить нарастающий итог.
Какие ошибки чаще всего ломают SQL-сводную?
Разворот строк в столбцы выглядит просто, но именно на нём новички теряют баллы на собеседовании и время в бою. Вот типичные грабли, на которые лучше наступить заранее, читая статью, чем вживую на демонстрации результата заказчику.
- Забытый
GROUP BY. Если написатьSUM(CASE ...)без группировки по «оси Y», получишь одну строку-итог вместо таблицы по категориям. Движок либо вернёт агрегат по всей таблице, либо в строгом режиме выругается, чтоcategoryне вGROUP BY. - Условие в
WHEREвместоCASE. Если отфильтроватьWHERE sale_month = '2026-01'на весь запрос, колонкиfebиmarокажутся пустыми — фильтр отрежет их строки ещё до агрегации. Условие месяца должно жить внутриCASEилиFILTER, а внешнийWHEREотвечает только за общие границы периода. ELSE 0там, где нуженAVG. Как уже разобрали, нули искусственно занижают среднее. ДляSUMиCOUNTэто безопасно, дляAVG— нет.- Дубли из-за
JOINдо агрегации. Если пивотишь по таблице, размноженной джойном (например, заказ × позиции заказа), выручка удвоится. Сначала сверни данные до нужной гранулярности в CTE, потом разворачивай. - Строковое сравнение дат.
sale_month = '2026-1'не совпадёт с'2026-01'— ведущий ноль важен. Либо храни месяц как дату и режь черезdate_trunc, либо строго придерживайся одного формата.
Хорошая привычка — сначала написать длинный SELECT с GROUP BY, глазами убедиться, что цифры сходятся, и только потом разворачивать его в широкую таблицу. Отладить агрегат проще на длинном формате, чем ловить, почему одна из двенадцати колонок врёт.
Когда лучше не пивотить в SQL, а отдать это BI или pandas?
SQL-pivot через CASE/FILTER хорош, когда колонок мало и они известны заранее. Но у него есть предел, за которым инструмент выбран неправильно.
Не пивоть в SQL, а передай задачу дальше, если:
- Колонки динамические. Если завтра появится новый месяц или новая категория и запрос должен подхватить их сам — статический SQL не сможет. Динамический pivot требует генерации SQL строкой на бэкенде, а это хрупко. Проще отдать сырой long-формат в BI.
- Колонок десятки. 30
FILTER-выражений — нечитаемый запрос, который тяжело поддерживать. BI-инструменты (DataLens, Superset, Metabase, Power BI) разворачивают в один клик мышкой в интерфейсе. - Финальный результат — визуализация. Дашборд сам построит сводную из длинных данных. Пивотить в SQL, чтобы потом BI это разбирал обратно, — лишняя работа.
- Нужна гибкая аналитика поверх. Тут выигрывает pandas:
df.pivot_table(index='category', columns='month', values='revenue', aggfunc='sum')— одна строка, колонки берутся из данных автоматически, а сверху доступны проценты, ranking, стилизация. Разбор — в части курса про groupby, agg и pivot_table.
Простое правило выбора инструмента:
| Ситуация | Инструмент |
|---|---|
| Мало фиксированных колонок, результат — таблица/выгрузка | SQL: CASE / FILTER |
| Динамические колонки, финал — дашборд | BI (разворот в интерфейсе) |
| Нужны проценты, ranking, гибкая обработка | pandas pivot_table |
| Пришли широкие данные, нужен long для анализа | SQL UNPIVOT (UNION ALL / LATERAL) |
Мой практический совет: держи данные в длинном формате как можно дольше по пайплайну, а разворачивай в широкий только на самом последнем шаге — там, где их читает человек. Так проще джойнить, агрегировать и тестировать. Пивот — это про презентацию, а не про хранение.
Что запомнить
- PIVOT в SQL = агрегат с условием на каждую колонку:
SUM(CASE WHEN ...)везде илиSUM(x) FILTER (WHERE ...)в PostgreSQL и ClickHouse. - Нативного
PIVOTв Postgres/ClickHouse нет, потому что список колонок должен быть известен до выполнения запроса; для динамики естьcrosstab(), но чаще проще фиксированный набор. FILTERчитается чище и не имеет ловушки сELSE 0, ноCASEпереносим абсолютно везде.- UNPIVOT (обратно в строки) —
UNION ALL(просто, но много сканов) илиLATERAL VALUES/arrayJoin(один проход). - Если колонок много или они меняются — отдай пивот в BI или в pandas
pivot_table, а SQL пусть отдаёт чистый long-формат.
Разворот строк в столбцы — из тех приёмов, которые нужно набить руками, чтобы они всплывали на собеседовании автоматически. В SQL-тренажёре собраны задачи именно на сводные, воронки и когорты: первые пять решаешь бесплатно и без регистрации, дальше — по подписке. Хочешь проверить себя в формате диалога — прогони AI-мок-собеседование или отработай смежные темы в банке вопросов для аналитика. А если тянет к Python — те же данные можно развернуть в Python-тренажёре через pivot_table. Начать системно с азов удобнее с курса SQL с нуля.