PIVOT в SQL: как превратить строки в столбцы

Допустим, в базе хранятся продажи товаров по кварталам.
Стандартный GROUP BY вернёт такой результат:

productquarterrevenue
LaptopQ1100
LaptopQ2150
LaptopQ3130
PhoneQ1200
PhoneQ2120
PhoneQ3180
TabletQ180
TabletQ295
TabletQ3110

А для отчёта нужно вот так, кварталы столбцами:

productQ1Q2Q3
Laptop100150130
Phone200120180
Tablet8095110

Такое преобразование строк в столбцы называется PIVOT. В статье разберём, как его сделать средствами SQL на примере базы данных сервиса доставки.

Проблема: «длинный» формат

В этой базе есть таблица events с действиями пользователей на разных платформах. Посчитаем количество событий по типу и платформе:

MySQL 8.1
SELECT
    event_name,
    platform,
    COUNT(*) AS cnt
FROM events
GROUP BY event_name, platform
ORDER BY event_name, platform;
event_nameplatformcnt
add_to_cartAndroid57
add_to_cartiOS72
add_to_cartWeb76
app_openAndroid130
app_openiOS132
app_openWeb128
purchaseAndroid24
purchaseiOS32
purchaseWeb34
view_itemAndroid94
view_itemiOS103
view_itemWeb99

Это так называемый «длинный» формат. Каждая комбинация «событие + платформа» занимает отдельную строку. Чтобы сравнить iOS с Android для одного события, приходится искать нужные строки глазами.

А хочется видеть данные так:

event_nameiOSAndroidWeb
app_open132130128
view_item1039499
add_to_cart725776
purchase322434

Платформы стали столбцами, а каждый тип события занимает одну строку. Именно такой результат мы хотим получить.

Как построить PIVOT: алгоритм

Прежде чем писать запрос, стоит ответить на четыре вопроса:

  1. Что станет строками? event_name (тип события)
  2. Что станет столбцами? platform (iOS, Android, Web)
  3. Что будет значением в ячейках? COUNT(*) (количество событий)

CASE + GROUP BY: универсальный способ

Подход работает в любой СУБД: MySQL, PostgreSQL, SQLite, SQL Server.

MySQL 8.1
SELECT
    event_name,
    SUM(CASE WHEN platform = 'iOS' THEN 1 ELSE 0 END) AS iOS,
    SUM(CASE WHEN platform = 'Android' THEN 1 ELSE 0 END) AS Android,
    SUM(CASE WHEN platform = 'Web' THEN 1 ELSE 0 END) AS Web
FROM events
GROUP BY event_name
ORDER BY event_name;
event_nameiOSAndroidWeb
add_to_cart725776
app_open132130128
purchase322434
view_item1039499

Но как это работает? Разберём пошагово.

Что происходит внутри

Чтобы понять механику, уберём SUM и GROUP BY из запроса и посмотрим, что CASE вычисляет для каждой отдельной строки:

MySQL 8.1
SELECT
    event_name,
    platform,
    CASE WHEN platform = 'iOS' THEN 1 ELSE 0 END AS "iOS",
    CASE WHEN platform = 'Android' THEN 1 ELSE 0 END AS "Android",
    CASE WHEN platform = 'Web' THEN 1 ELSE 0 END AS "Web"
FROM events
WHERE event_name IN ('app_open', 'purchase')
LIMIT 6;

Каждый CASE проверяет платформу и ставит 1 при совпадении, 0 в остальных случаях. Вот фрагмент результата для наглядности:

event_nameplatformiOSAndroidWeb
app_openiOS100
app_openAndroid010
app_openiOS100
app_openWeb001
purchaseAndroid010
purchaseiOS100

Здесь показана часть строк для двух групп: app_open и purchase. Каждая строка получает 1 ровно в одном столбце, а в остальных 0. Например, строка с platform = 'iOS' получает 1 только в iOS.

Дальше GROUP BY группирует строки по event_name, а SUM складывает значения внутри каждой группы. Сколько единиц набралось в столбце iOS, столько событий пришло с iOS.

Обратите внимание на ELSE 0 в CASE: без него несовпавшие строки получат NULL вместо 0, и результат SUM тоже может оказаться NULL. Подробнее об этом в разделе «Ловушка NULL» ниже.

Почему SUM, а не COUNT? SUM удобнее: достаточно заменить 1 на amount, чтобы считать суммы вместо количеств. Именно это мы сделаем в следующем примере.

FILTER: элегантная альтернатива

PostgreSQL поддерживает конструкцию FILTER, которая делает то же самое, но читается значительно чище:

MySQL 8.1
SELECT
    event_name,
    COUNT(*) FILTER (WHERE platform = 'iOS') AS iOS,
    COUNT(*) FILTER (WHERE platform = 'Android') AS Android,
    COUNT(*) FILTER (WHERE platform = 'Web') AS Web
FROM events
GROUP BY event_name
ORDER BY event_name;

Сравните: вместо SUM(CASE WHEN platform = 'iOS' THEN 1 ELSE 0 END) достаточно написать COUNT(*) FILTER (WHERE platform = 'iOS').

CASE вычисляет значение для каждой строки, и нужен SUM, чтобы сложить единицы. FILTER работает иначе: он отсекает лишние строки до агрегации. Поэтому можно использовать обычный COUNT(*), который посчитает только строки нужной платформы.

FILTER работает с любой агрегатной функцией: COUNT, SUM, AVG, MIN, MAX.

От количеств к суммам

До сих пор мы считали количества событий. А что если нужны суммы денег? Достаточно заменить 1 на реальный столбец, например total_amount. Возьмём другую таблицу из той же базы, orders, и построим отчёт с выручкой по статусам заказов за каждый месяц:

MySQL 8.1
SELECT
    DATE_FORMAT(order_date, '%Y-%m') AS month,
    SUM(CASE WHEN status = 'delivered' THEN total_amount ELSE 0 END) AS delivered,
    SUM(CASE WHEN status = 'canceled' THEN total_amount ELSE 0 END) AS canceled,
    SUM(CASE WHEN status = 'refunded' THEN total_amount ELSE 0 END) AS refunded
FROM orders
WHERE order_date >= '2024-01-01' AND order_date < '2024-07-01'
GROUP BY month
ORDER BY month;

Здесь удобно применить FILTER, код получается лаконичным:

MySQL 8.1
SELECT
    to_char(order_date, 'YYYY-MM') AS month,
    SUM(total_amount) FILTER (WHERE status = 'delivered') AS delivered,
    SUM(total_amount) FILTER (WHERE status = 'canceled') AS canceled,
    SUM(total_amount) FILTER (WHERE status = 'refunded') AS refunded
FROM orders
WHERE order_date >= '2024-01-01' AND order_date < '2024-07-01'
GROUP BY month
ORDER BY month;
monthdeliveredcanceledrefunded
2024-01251.2883.1626.05
2024-02513.3079.6880.17
2024-03526.8532.4435.94
2024-04453.7562.37190.28
2024-05322.6798.30274.93
2024-06267.3728.2255.40

Принцип тот же: CASE WHEN фильтрует строки по статусу, а SUM считает сумму total_amount вместо единиц. По строкам идут месяцы, по столбцам статусы, а в ячейках выручка.

Принцип тот же: FILTER отсекает строки по статусу, а SUM считает сумму total_amount только для нужных заказов. По строкам идут месяцы, по столбцам статусы, а в ячейках выручка.

На что обратить внимание

Ловушка NULL

В примерах выше мы использовали ELSE 0 в CASE, чтобы гарантировать числовой результат. Но если написать CASE без ELSE:

MySQL 8.1
SUM(CASE WHEN status = 'refunded' THEN total_amount END)

...и в каком-то месяце не окажется ни одного возврата, CASE вернёт NULL для всех строк, а SUM из одних NULL даст NULL. Это критично, если дальше идут вычисления: 100 - NULL = NULL.

Избежать этого можно двумя способами:

  1. Добавить ELSE 0 прямо в CASE (как мы делали в примерах выше):

    MySQL 8.1
    SUM(CASE WHEN status = 'refunded' THEN total_amount ELSE 0 END)
    
  2. Обернуть в COALESCE. Полезно, когда в столбце уже могут встречаться NULL:

    MySQL 8.1
    COALESCE(SUM(CASE WHEN status = 'refunded' THEN total_amount END), 0)
    

Значения столбцов нужно знать заранее

Все подходы требуют явно указать значения, которые станут столбцами ('iOS', 'Android', 'Web'). Если в данных появится новая платформа, запрос нужно обновлять вручную.

Единственный способ обойти это: написать скрипт на стороне приложения (Python, PHP, JS) или использовать динамический SQL (сборку строки запроса через PREPARE / хранимые процедуры). Чистым статичным SQL эта задача не решается.

В большинстве реальных задач набор категорий известен и меняется редко, поэтому хардкодить значения вполне нормально.

crosstab(): встроенный PIVOT в PostgreSQL

PostgreSQL предоставляет функцию crosstab() из расширения tablefunc. Она принимает запрос и автоматически разворачивает строки в столбцы:

MySQL 8.1
CREATE EXTENSION IF NOT EXISTS tablefunc;

SELECT *
FROM crosstab(
    $$
    SELECT event_name, platform, COUNT(*)
    FROM events
    GROUP BY event_name, platform
    ORDER BY event_name, platform
    $$,
    $$ VALUES ('Android'), ('iOS'), ('Web') $$
) AS ct(event_name TEXT, "Android" BIGINT, "iOS" BIGINT, "Web" BIGINT);

Первый аргумент: запрос, который возвращает три столбца (строку, столбец и значение). Второй аргумент: список значений, которые станут столбцами. Результат нужно описать в AS ct(...) с типами.

crosstab() удобен, когда столбцов много и писать десяток CASE WHEN утомительно. Но у него есть ограничения: нужно устанавливать расширение, явно описывать типы результата, и синтаксис менее очевиден для тех, кто читает запрос впервые. Для большинства задач FILTER или CASE + GROUP BY проще и нагляднее.

Подводя итоги

В MySQL CASE + GROUP BY единственный способ создать PIVOT-таблицу чистым SQL. Подход универсален, работает «из коробки» и легко читается. Запрос становится многословным при большом числе столбцов, но в большинстве отчётов категорий не больше десятка.

Краткий алгоритм действий:

  1. Определить, что станет строками, столбцами и значениями.
  2. Написать CASE WHEN для каждого значения-столбца.
  3. Обернуть в SUM (или COUNT) и сгруппировать по строкам.
  4. Не забыть про ELSE 0, чтобы избежать NULL.

В PostgreSQL доступны три подхода:

МетодКогда использовать
FILTERПо умолчанию: чистый синтаксис, покрывает большинство задач
CASE + GROUP BYКогда нужна переносимость на другие СУБД
crosstab()Когда столбцов много и хочется избежать ручного перечисления

Краткий алгоритм действий:

  1. Определить, что станет строками, столбцами и значениями.
  2. Выбрать подход: FILTER для PostgreSQL, CASE + GROUP BY для переносимости.
  3. Не забыть про ELSE 0 (для CASE) или COALESCE, чтобы избежать NULL.