Este artículo está actualmente en ruso — la traducción está en curso.
PARTITION BY — это часть оконных функций в SQL. Она делит строки на группы, но не склеивает их в одну строку.
Звучит похоже на GROUP BY, но это совсем другой инструмент.
Главная разница такая:
GROUP BY собирает много строк в одну итоговую строку;
PARTITION BY оставляет все строки на месте, но позволяет посчитать что-то внутри группы.
Представь таблицу заказов. Ты хочешь видеть каждый заказ отдельно, но рядом с ним — общую сумму заказов этого клиента. Обычный GROUP BY здесь не подойдёт: он схлопнет все заказы клиента в одну строку. А PARTITION BY как раз позволит сохранить каждый заказ и добавить к нему расчёт по клиенту.
Это одна из самых полезных идей в оконных функциях: строка остаётся строкой, но начинает «знать» что-то о своей группе.
Где используется PARTITION BY
PARTITION BY пишется внутри OVER.
Оконные функции выглядят примерно так:
function_name(...) OVER (
PARTITION BY column_name
ORDER BY another_column
)
Например:
SUM(amount) OVER (PARTITION BY customer_id)
Такой код означает:
Посчитай сумму amount отдельно для каждого customer_id, но не удаляй исходные строки.
PARTITION BY отвечает за то, на какие группы делить строки перед расчётом оконной функции.
Если сказать проще:
PARTITION BY customer_id — отдельное окно для каждого клиента;
PARTITION BY category_id — отдельное окно для каждой категории;
PARTITION BY department — отдельное окно для каждого отдела;
PARTITION BY country, city — отдельное окно для каждой пары страна плюс город.
Почему PARTITION BY так полезен
В реальных отчётах редко достаточно просто получить итоговую сумму.
Обычно хочется увидеть и детали, и общий контекст.
Например:
- каждый заказ и общую сумму заказов клиента;
- каждого сотрудника и среднюю зарплату по его отделу;
- каждый товар и среднюю цену по его категории;
- каждую покупку и её долю в покупках клиента;
- каждое событие пользователя и номер этого события в его истории;
- последние 3 заказа каждого клиента;
- самый дорогой товар в каждой категории.
Через GROUP BY такие задачи часто превращаются в подзапросы и JOIN. А оконные функции с PARTITION BY позволяют написать короче и понятнее.
GROUP BY и PARTITION BY на одних данных
Допустим, есть таблица orders:
| id |
customer_id |
amount |
| 1 |
1 |
100 |
| 2 |
1 |
250 |
| 3 |
2 |
80 |
| 4 |
1 |
150 |
| 5 |
2 |
300 |
Сначала посмотрим на GROUP BY.
SELECT
customer_id,
SUM(amount) AS total_amount
FROM orders
GROUP BY customer_id;
Результат:
| customer_id |
total_amount |
| 1 |
500 |
| 2 |
380 |
Что произошло?
Было 5 заказов, стало 2 строки. По одной строке на клиента. Это нормально: GROUP BY именно так и работает. Он собирает строки в группы и возвращает итог по каждой группе.
Но если нам нужно видеть каждый заказ, такой результат уже не подходит.
Теперь сделаем то же самое через оконную функцию:
SELECT
id,
customer_id,
amount,
SUM(amount) OVER (PARTITION BY customer_id) AS customer_total
FROM orders;
Результат:
| id |
customer_id |
amount |
customer_total |
| 1 |
1 |
100 |
500 |
| 2 |
1 |
250 |
500 |
| 3 |
2 |
80 |
380 |
| 4 |
1 |
150 |
500 |
| 5 |
2 |
300 |
380 |
Строк осталось 5. Ничего не схлопнулось. Но рядом с каждым заказом появилась общая сумма заказов его клиента.
Вот в этом и сила PARTITION BY.
GROUP BY отвечает на вопрос:
Какой итог по группе?
А PARTITION BY помогает ответить на вопрос:
Что известно о группе для каждой конкретной строки?
Простая аналогия
Представь школьный журнал.
GROUP BY class покажет одну строку на каждый класс:
| class_name |
avg_score |
| 5A |
4.4 |
| 5B |
4.1 |
Это полезно, если нужен только итог по классу.
А PARTITION BY class позволит показать каждого ученика и рядом средний балл его класса:
| student_name |
class_name |
score |
class_avg_score |
| Anna |
5A |
5 |
4.4 |
| Boris |
5A |
4 |
4.4 |
| Vera |
5B |
3 |
4.1 |
| Denis |
5B |
5 |
4.1 |
Каждый ученик остался в таблице. Но теперь мы можем сравнить его оценку со средним значением по классу.
Именно для таких задач PARTITION BY подходит идеально.
Синтаксис PARTITION BY
Общий вид:
window_function(...) OVER (
PARTITION BY column1, column2
ORDER BY column3
)
Внутри OVER обычно встречаются две важные части:
PARTITION BY — делит строки на группы;
ORDER BY — задаёт порядок строк внутри каждой группы.
PARTITION BY не всегда обязателен. ORDER BY тоже не всегда обязателен. Всё зависит от задачи и оконной функции.
Например, для суммы по клиенту порядок не нужен:
SUM(amount) OVER (PARTITION BY customer_id)
А для номера заказа в истории клиента порядок уже нужен:
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at
)
Потому что нельзя назначить «первый», «второй» и «третий» заказ, не договорившись, по какому признаку их упорядочивать.
Пример: сумма заказов клиента в каждой строке
Возьмём таблицу orders:
| id |
customer_id |
amount |
created_at |
| 1 |
1 |
100 |
2024-03-01 |
| 2 |
1 |
250 |
2024-03-05 |
| 3 |
2 |
80 |
2024-03-07 |
| 4 |
1 |
150 |
2024-03-10 |
| 5 |
2 |
300 |
2024-03-12 |
Хотим показать каждый заказ и общую сумму заказов клиента:
SELECT
id,
customer_id,
amount,
SUM(amount) OVER (PARTITION BY customer_id) AS customer_total
FROM orders;
Результат:
| id |
customer_id |
amount |
customer_total |
| 1 |
1 |
100 |
500 |
| 2 |
1 |
250 |
500 |
| 3 |
2 |
80 |
380 |
| 4 |
1 |
150 |
500 |
| 5 |
2 |
300 |
380 |
Теперь можно сразу видеть и конкретный заказ, и масштаб клиента.
Например, заказ на 250 у клиента 1 — это часть общей суммы 500.
Доля строки внутри группы
Теперь посчитаем, какую долю каждый заказ занимает в общей сумме заказов клиента.
SELECT
id,
customer_id,
amount,
SUM(amount) OVER (PARTITION BY customer_id) AS customer_total,
ROUND(
100.0 * amount / SUM(amount) OVER (PARTITION BY customer_id),
1
) AS percent_of_customer_total
FROM orders;
Результат:
| id |
customer_id |
amount |
customer_total |
percent_of_customer_total |
| 1 |
1 |
100 |
500 |
20.0 |
| 2 |
1 |
250 |
500 |
50.0 |
| 3 |
2 |
80 |
380 |
21.1 |
| 4 |
1 |
150 |
500 |
30.0 |
| 5 |
2 |
300 |
380 |
78.9 |
Вот это уже настоящая аналитика.
Мы не просто получили сумму. Мы поняли, какой вклад каждый заказ внёс в сумму своего клиента.
Для бизнеса это очень полезный паттерн:
- доля заказа в выручке клиента;
- доля товара в продажах категории;
- доля города в продажах региона;
- доля сотрудника в результате отдела.
Несколько оконных агрегатов сразу
С PARTITION BY можно добавить в каждую строку сразу несколько показателей по группе.
Например:
SELECT
id,
customer_id,
amount,
COUNT(*) OVER (PARTITION BY customer_id) AS customer_orders_count,
SUM(amount) OVER (PARTITION BY customer_id) AS customer_total_amount,
AVG(amount) OVER (PARTITION BY customer_id) AS customer_avg_amount,
MAX(amount) OVER (PARTITION BY customer_id) AS customer_max_amount
FROM orders;
Так в каждой строке заказа будет видно:
- сколько всего заказов сделал клиент;
- сколько денег он принёс;
- какой у него средний заказ;
- какой у него самый крупный заказ.
Обычный GROUP BY тоже может посчитать эти значения, но он вернёт одну строку на клиента. А здесь мы сохраняем каждый заказ.
Это удобно, когда нужно не потерять детали.
Сравнение строки со средней по группе
Допустим, есть таблица products:
| id |
name |
category |
price |
| 1 |
Keyboard |
Electronics |
80 |
| 2 |
Mouse |
Electronics |
40 |
| 3 |
Monitor |
Electronics |
300 |
| 4 |
Chair |
Furniture |
150 |
| 5 |
Desk |
Furniture |
250 |
Хотим показать каждый товар и среднюю цену в его категории.
SELECT
id,
name,
category,
price,
AVG(price) OVER (PARTITION BY category) AS category_avg_price
FROM products;
Результат:
| id |
name |
category |
price |
category_avg_price |
| 1 |
Keyboard |
Electronics |
80 |
140.0000 |
| 2 |
Mouse |
Electronics |
40 |
140.0000 |
| 3 |
Monitor |
Electronics |
300 |
140.0000 |
| 4 |
Chair |
Furniture |
150 |
200.0000 |
| 5 |
Desk |
Furniture |
250 |
200.0000 |
Теперь можно сравнить цену товара со средней ценой в категории:
SELECT
id,
name,
category,
price,
AVG(price) OVER (PARTITION BY category) AS category_avg_price,
price - AVG(price) OVER (PARTITION BY category) AS diff_from_category_avg
FROM products;
Результат:
| id |
name |
category |
price |
category_avg_price |
diff_from_category_avg |
| 1 |
Keyboard |
Electronics |
80 |
140.0000 |
-60.0000 |
| 2 |
Mouse |
Electronics |
40 |
140.0000 |
-100.0000 |
| 3 |
Monitor |
Electronics |
300 |
140.0000 |
160.0000 |
| 4 |
Chair |
Furniture |
150 |
200.0000 |
-50.0000 |
| 5 |
Desk |
Furniture |
250 |
200.0000 |
50.0000 |
Теперь сразу видно, какой товар дороже среднего, а какой дешевле.
Это очень частый аналитический приём: сравнить строку не со всей таблицей, а именно со своей группой.
ROW_NUMBER с PARTITION BY
PARTITION BY особенно часто используют с функцией ROW_NUMBER.
ROW_NUMBER нумерует строки. А PARTITION BY говорит: нумеруй не всю таблицу подряд, а отдельно внутри каждой группы.
Например, нужно пронумеровать заказы каждого клиента от первого к последнему:
SELECT
id,
customer_id,
amount,
created_at,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at
) AS order_number
FROM orders;
Результат:
| id |
customer_id |
amount |
created_at |
order_number |
| 1 |
1 |
100 |
2024-03-01 |
1 |
| 2 |
1 |
250 |
2024-03-05 |
2 |
| 4 |
1 |
150 |
2024-03-10 |
3 |
| 3 |
2 |
80 |
2024-03-07 |
1 |
| 5 |
2 |
300 |
2024-03-12 |
2 |
У клиента 1 своя нумерация: 1, 2, 3.
У клиента 2 своя нумерация: 1, 2.
Именно это даёт PARTITION BY customer_id.
Топ-N в каждой группе
Один из самых популярных сценариев: получить не общий топ, а топ внутри каждой группы.
Например:
Последние 3 заказа каждого клиента.
Для этого сначала нумеруем заказы внутри каждого клиента, а потом оставляем только первые три.
WITH ranked_orders AS (
SELECT
id,
customer_id,
amount,
created_at,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC, id DESC
) AS row_num
FROM orders
)
SELECT
id,
customer_id,
amount,
created_at
FROM ranked_orders
WHERE row_num <= 3;
Что здесь происходит:
PARTITION BY customer_id делит заказы по клиентам.
ORDER BY created_at DESC, id DESC ставит новые заказы выше старых внутри каждого клиента.
ROW_NUMBER нумерует заказы каждого клиента.
- Внешний запрос оставляет только строки с номером
1, 2 или 3.
Так можно делать:
- топ-5 товаров в каждой категории;
- последние 10 событий каждого пользователя;
- самый дорогой заказ каждого клиента;
- первого сотрудника по зарплате в каждом отделе;
- последнюю оплату по каждому аккаунту.
Почему оконную функцию нельзя сразу использовать в WHERE
Новички часто пытаются написать так:
SELECT
id,
customer_id,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
) AS row_num
FROM orders
WHERE row_num = 1;
Такой запрос не сработает.
Причина в порядке выполнения SQL-запроса. WHERE фильтрует строки раньше, чем вычисляются оконные функции. В момент работы WHERE колонки row_num ещё не существует.
Поэтому нужен подзапрос или CTE:
WITH ranked_orders AS (
SELECT
id,
customer_id,
amount,
created_at,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
) AS row_num
FROM orders
)
SELECT
id,
customer_id,
amount,
created_at
FROM ranked_orders
WHERE row_num = 1;
Так мы сначала создаём результат с row_num, а потом уже фильтруем его во внешнем запросе.
PARTITION BY по нескольким колонкам
В PARTITION BY можно указать не одну колонку, а несколько.
Например:
ROW_NUMBER() OVER (
PARTITION BY customer_id, EXTRACT(YEAR FROM created_at)
ORDER BY amount DESC
)
Здесь группа — это не просто клиент. Группа — это пара:
- конкретный клиент;
- конкретный год.
То есть нумерация начнётся заново для каждого клиента в каждом году.
Полный пример:
SELECT
id,
customer_id,
amount,
created_at,
ROW_NUMBER() OVER (
PARTITION BY customer_id, EXTRACT(YEAR FROM created_at)
ORDER BY amount DESC, id DESC
) AS order_rank_in_year
FROM orders;
Так можно найти, например, самый крупный заказ каждого клиента в каждом году.
WITH ranked_orders AS (
SELECT
id,
customer_id,
amount,
created_at,
ROW_NUMBER() OVER (
PARTITION BY customer_id, EXTRACT(YEAR FROM created_at)
ORDER BY amount DESC, id DESC
) AS order_rank_in_year
FROM orders
)
SELECT
id,
customer_id,
amount,
created_at
FROM ranked_orders
WHERE order_rank_in_year = 1;
PARTITION BY a, b означает: отдельная группа для каждой уникальной пары значений a и b.
Что будет без PARTITION BY
Если не написать PARTITION BY, оконная функция будет работать по всему набору строк.
Например:
SELECT
id,
customer_id,
amount,
SUM(amount) OVER () AS grand_total
FROM orders;
Результат:
| id |
customer_id |
amount |
grand_total |
| 1 |
1 |
100 |
880 |
| 2 |
1 |
250 |
880 |
| 3 |
2 |
80 |
880 |
| 4 |
1 |
150 |
880 |
| 5 |
2 |
300 |
880 |
SUM(amount) OVER () посчитал сумму по всей таблице и показал её в каждой строке.
Это полезно, когда нужно сравнить строку с общим итогом.
Например, доля каждого заказа в общей выручке:
SELECT
id,
customer_id,
amount,
ROUND(100.0 * amount / SUM(amount) OVER (), 1) AS percent_of_all_orders
FROM orders;
Без PARTITION BY окно — вся выборка.
С PARTITION BY окно — отдельная группа внутри выборки.
PARTITION BY и ORDER BY внутри OVER
Внутри OVER можно писать и PARTITION BY, и ORDER BY.
Они отвечают за разные вещи:
PARTITION BY делит строки на группы;
ORDER BY задаёт порядок строк внутри каждой группы.
Например:
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
)
Это означает:
Для каждого клиента отдельно отсортируй заказы от новых к старым и пронумеруй их.
Если убрать PARTITION BY, получится другая логика:
ROW_NUMBER() OVER (
ORDER BY created_at DESC
)
Теперь будет одна общая нумерация по всем заказам. Это уже не «последний заказ каждого клиента», а «последние заказы во всей таблице».
Если убрать ORDER BY, у ROW_NUMBER не будет понятного порядка:
ROW_NUMBER() OVER (
PARTITION BY customer_id
)
Такой запрос может выполниться, но смысл будет слабым: база пронумерует строки внутри клиента без гарантированного порядка. Для ROW_NUMBER почти всегда нужно явно указывать ORDER BY.
Важный нюанс: SUM с ORDER BY даёт нарастающий итог
Это место часто путают.
Смотри на такой запрос:
SELECT
id,
customer_id,
amount,
created_at,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at
) AS running_total
FROM orders;
Многие ожидают, что SUM посчитает общую сумму клиента. Но из-за ORDER BY внутри OVER результат становится нарастающим итогом.
То есть для каждого клиента сумма будет копиться от первого заказа к текущему.
Пример:
| id |
customer_id |
amount |
created_at |
running_total |
| 1 |
1 |
100 |
2024-03-01 |
100 |
| 2 |
1 |
250 |
2024-03-05 |
350 |
| 4 |
1 |
150 |
2024-03-10 |
500 |
| 3 |
2 |
80 |
2024-03-07 |
80 |
| 5 |
2 |
300 |
2024-03-12 |
380 |
Это называется running total — нарастающий итог.
Если нужна сумма всей группы, пиши без ORDER BY:
SELECT
id,
customer_id,
amount,
SUM(amount) OVER (PARTITION BY customer_id) AS customer_total
FROM orders;
А если нужен именно нарастающий итог, тогда ORDER BY нужен:
SELECT
id,
customer_id,
amount,
created_at,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at
) AS running_total
FROM orders;
Запомни простое правило:
SUM(amount) OVER (PARTITION BY customer_id) — сумма по всему клиенту;
SUM(amount) OVER (PARTITION BY customer_id ORDER BY created_at) — сумма от начала истории клиента до текущей строки.
Нарастающий итог и одинаковые даты
Есть тонкий момент: если в ORDER BY есть одинаковые значения, результат может быть не таким очевидным.
Например, два заказа клиента созданы в одну дату. Тогда база может считать их «соседями» с одинаковым ключом сортировки.
Чтобы порядок был стабильным, добавляй уникальную колонку:
SELECT
id,
customer_id,
amount,
created_at,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, id
) AS running_total
FROM orders;
id помогает базе понять, какой заказ идёт раньше, если даты одинаковые.
Это хорошая привычка для оконных функций: если порядок важен, делай его детерминированным.
LAG и LEAD с PARTITION BY
PARTITION BY полезен не только с агрегатами и ROW_NUMBER, но и с функциями LAG и LEAD.
LAG позволяет посмотреть на предыдущую строку.
LEAD позволяет посмотреть на следующую строку.
Например, хотим для каждого заказа увидеть предыдущий заказ этого же клиента:
SELECT
id,
customer_id,
amount,
created_at,
LAG(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, id
) AS previous_amount
FROM orders;
Что важно: благодаря PARTITION BY customer_id предыдущий заказ берётся только внутри того же клиента.
Без PARTITION BY функция могла бы взять предыдущий заказ другого клиента, и такой результат был бы почти бесполезен для анализа истории конкретного пользователя.
Можно посчитать разницу с предыдущим заказом:
SELECT
id,
customer_id,
amount,
created_at,
amount - LAG(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, id
) AS diff_from_previous_amount
FROM orders;
Так можно анализировать, растёт ли чек клиента со временем.
NULL в PARTITION BY
Если колонка для PARTITION BY содержит NULL, такие строки попадают в одну общую партицию.
Например:
COUNT(*) OVER (PARTITION BY manager_id)
Все строки, где manager_id равен NULL, будут считаться одной группой.
Это не значит, что в SQL NULL = NULL в обычном сравнении. В условиях WHERE manager_id = NULL всё равно будет ошибка по смыслу, потому что NULL проверяют через IS NULL.
Но для группировки и оконных партиций строки с NULL обычно оказываются вместе.
Если строки с пустым значением не нужны в расчёте, отфильтруй их заранее:
SELECT
employee_id,
manager_id,
salary,
AVG(salary) OVER (PARTITION BY manager_id) AS avg_salary_by_manager
FROM employees
WHERE manager_id IS NOT NULL;
PARTITION BY не фильтрует строки
Важно: PARTITION BY не удаляет строки из результата.
Он только говорит оконной функции, как разбить строки на группы для расчёта.
Например:
SELECT
id,
customer_id,
amount,
SUM(amount) OVER (PARTITION BY customer_id) AS customer_total
FROM orders;
Этот запрос не оставит по одной строке на клиента. Он вернёт все заказы.
Если тебе нужна одна строка на клиента, используй GROUP BY:
SELECT
customer_id,
SUM(amount) AS customer_total
FROM orders
GROUP BY customer_id;
А если тебе нужна, например, одна последняя строка на каждого клиента, используй оконную функцию и внешний фильтр:
WITH ranked_orders AS (
SELECT
id,
customer_id,
amount,
created_at,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC, id DESC
) AS row_num
FROM orders
)
SELECT
id,
customer_id,
amount,
created_at
FROM ranked_orders
WHERE row_num = 1;
PARTITION BY сам по себе не уменьшает количество строк.
Когда нужен GROUP BY, а когда PARTITION BY
Выбирай GROUP BY, когда тебе нужен итоговый отчёт без деталей.
Например:
SELECT
customer_id,
SUM(amount) AS total_amount
FROM orders
GROUP BY customer_id;
Результат: одна строка на клиента.
Выбирай PARTITION BY, когда тебе нужны детали и расчёт по группе рядом с каждой строкой.
Например:
SELECT
id,
customer_id,
amount,
SUM(amount) OVER (PARTITION BY customer_id) AS total_amount
FROM orders;
Результат: каждый заказ плюс сумма по клиенту.
Хорошая шпаргалка:
| Задача |
Что использовать |
| Одна строка на клиента с суммой заказов |
GROUP BY |
| Каждый заказ плюс сумма заказов клиента |
PARTITION BY |
| Топ-3 заказа каждого клиента |
PARTITION BY + ROW_NUMBER |
| Средняя зарплата по отделу в каждой строке сотрудника |
PARTITION BY |
| Просто отчёт по отделам без сотрудников |
GROUP BY |
Переиспользование окна через WINDOW
Если в запросе несколько оконных функций с одним и тем же PARTITION BY и ORDER BY, можно вынести окно в отдельное имя через WINDOW.
Например:
SELECT
id,
customer_id,
amount,
created_at,
ROW_NUMBER() OVER customer_window AS row_num,
COUNT(*) OVER customer_window AS orders_count
FROM orders
WINDOW customer_window AS (
PARTITION BY customer_id
ORDER BY created_at, id
);
Такой синтаксис помогает не повторять одно и то же несколько раз.
Но для новичка это не первый инструмент, который нужно запоминать. Сначала важно уверенно понять обычную форму:
SUM(amount) OVER (PARTITION BY customer_id)
А WINDOW пригодится позже, когда запросы станут длиннее.
Частые ошибки с PARTITION BY
Путать PARTITION BY и GROUP BY
GROUP BY уменьшает количество строк.
SELECT
customer_id,
SUM(amount) AS total_amount
FROM orders
GROUP BY customer_id;
PARTITION BY сохраняет строки.
SELECT
id,
customer_id,
amount,
SUM(amount) OVER (PARTITION BY customer_id) AS total_amount
FROM orders;
Если после запроса ты ожидал одну строку на клиента, а получил все заказы, значит, скорее всего, ты использовал оконную функцию там, где нужен был GROUP BY.
Забыть PARTITION BY
Например, нужно найти последний заказ каждого клиента.
Правильно:
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
)
Если забыть PARTITION BY, получится нумерация по всей таблице:
ROW_NUMBER() OVER (
ORDER BY created_at DESC
)
Тогда row_num = 1 даст один самый последний заказ вообще, а не по одному последнему заказу на каждого клиента.
Добавить ORDER BY к SUM и случайно получить running total
Если нужна сумма по всей группе, пиши:
SUM(amount) OVER (PARTITION BY customer_id)
Если написать так:
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at
)
ты получишь нарастающий итог, а не общую сумму клиента в каждой строке.
Пытаться использовать оконную функцию в WHERE
Так нельзя:
SELECT
id,
customer_id,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
) AS row_num
FROM orders
WHERE row_num = 1;
Нужно так:
WITH ranked_orders AS (
SELECT
id,
customer_id,
created_at,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
) AS row_num
FROM orders
)
SELECT
id,
customer_id,
created_at
FROM ranked_orders
WHERE row_num = 1;
Сначала считаем оконную функцию, потом фильтруем.
Не задавать стабильный порядок
Если у нескольких строк одинаковое значение в ORDER BY, порядок между ними может быть непредсказуемым.
Менее надёжно:
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC
)
Надёжнее:
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC, id DESC
)
id делает порядок стабильным.
Думать, что PARTITION BY сортирует результат
PARTITION BY не сортирует итоговую выдачу. Он только делит строки на группы внутри оконной функции.
Например:
SELECT
id,
customer_id,
amount,
SUM(amount) OVER (PARTITION BY customer_id) AS customer_total
FROM orders;
Этот запрос не обязан вывести сначала клиента 1, потом клиента 2.
Если нужен порядок результата, добавь внешний ORDER BY:
SELECT
id,
customer_id,
amount,
SUM(amount) OVER (PARTITION BY customer_id) AS customer_total
FROM orders
ORDER BY customer_id, id;
ORDER BY внутри OVER управляет порядком для оконной функции.
ORDER BY в конце запроса управляет порядком итоговой таблицы.
Это разные вещи.
Главное
PARTITION BY — это способ сказать оконной функции: «считай не по всей таблице, а отдельно внутри каждой группы».
Главная идея:
SUM(amount) OVER (PARTITION BY customer_id)
означает:
Для каждой строки посчитай сумму заказов того клиента, к которому эта строка относится.
Самое важное:
PARTITION BY используется внутри OVER;
- он делит строки на группы для оконной функции;
- он не схлопывает строки, в отличие от
GROUP BY;
- без
PARTITION BY окно обычно работает по всей выборке;
- с
ROW_NUMBER он помогает делать топы внутри каждой группы;
- с
SUM, AVG, COUNT, MIN, MAX он добавляет групповые показатели к каждой строке;
SUM с ORDER BY внутри OVER даёт нарастающий итог;
- оконные функции нельзя фильтровать напрямую в
WHERE, для этого нужен CTE или подзапрос;
- строки с
NULL в колонке партиции попадают в одну группу;
PARTITION BY не сортирует итоговую выдачу, для этого нужен обычный ORDER BY в конце запроса.
Если GROUP BY — это «сделай краткий итог по группам», то PARTITION BY — это «оставь детали, но покажи рядом умный расчёт по группе». Именно поэтому оконные функции так любят в аналитике: они позволяют видеть и конкретную строку, и её контекст одновременно.
PARTITION BY— это часть оконных функций в SQL. Она делит строки на группы, но не склеивает их в одну строку.Звучит похоже на
GROUP BY, но это совсем другой инструмент.Главная разница такая:
GROUP BYсобирает много строк в одну итоговую строку;PARTITION BYоставляет все строки на месте, но позволяет посчитать что-то внутри группы.Представь таблицу заказов. Ты хочешь видеть каждый заказ отдельно, но рядом с ним — общую сумму заказов этого клиента. Обычный
GROUP BYздесь не подойдёт: он схлопнет все заказы клиента в одну строку. АPARTITION BYкак раз позволит сохранить каждый заказ и добавить к нему расчёт по клиенту.Это одна из самых полезных идей в оконных функциях: строка остаётся строкой, но начинает «знать» что-то о своей группе.
Где используется PARTITION BY
PARTITION BYпишется внутриOVER.Оконные функции выглядят примерно так:
function_name(...) OVER ( PARTITION BY column_name ORDER BY another_column )Например:
SUM(amount) OVER (PARTITION BY customer_id)Такой код означает:
PARTITION BYотвечает за то, на какие группы делить строки перед расчётом оконной функции.Если сказать проще:
PARTITION BY customer_id— отдельное окно для каждого клиента;PARTITION BY category_id— отдельное окно для каждой категории;PARTITION BY department— отдельное окно для каждого отдела;PARTITION BY country, city— отдельное окно для каждой пары страна плюс город.Почему PARTITION BY так полезен
В реальных отчётах редко достаточно просто получить итоговую сумму.
Обычно хочется увидеть и детали, и общий контекст.
Например:
Через
GROUP BYтакие задачи часто превращаются в подзапросы иJOIN. А оконные функции сPARTITION BYпозволяют написать короче и понятнее.GROUP BY и PARTITION BY на одних данных
Допустим, есть таблица
orders:Сначала посмотрим на
GROUP BY.SELECT customer_id, SUM(amount) AS total_amount FROM orders GROUP BY customer_id;Результат:
Что произошло?
Было 5 заказов, стало 2 строки. По одной строке на клиента. Это нормально:
GROUP BYименно так и работает. Он собирает строки в группы и возвращает итог по каждой группе.Но если нам нужно видеть каждый заказ, такой результат уже не подходит.
Теперь сделаем то же самое через оконную функцию:
SELECT id, customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id) AS customer_total FROM orders;Результат:
Строк осталось 5. Ничего не схлопнулось. Но рядом с каждым заказом появилась общая сумма заказов его клиента.
Вот в этом и сила
PARTITION BY.GROUP BYотвечает на вопрос:А
PARTITION BYпомогает ответить на вопрос:Простая аналогия
Представь школьный журнал.
GROUP BY classпокажет одну строку на каждый класс:Это полезно, если нужен только итог по классу.
А
PARTITION BY classпозволит показать каждого ученика и рядом средний балл его класса:Каждый ученик остался в таблице. Но теперь мы можем сравнить его оценку со средним значением по классу.
Именно для таких задач
PARTITION BYподходит идеально.Синтаксис PARTITION BY
Общий вид:
window_function(...) OVER ( PARTITION BY column1, column2 ORDER BY column3 )Внутри
OVERобычно встречаются две важные части:PARTITION BY— делит строки на группы;ORDER BY— задаёт порядок строк внутри каждой группы.PARTITION BYне всегда обязателен.ORDER BYтоже не всегда обязателен. Всё зависит от задачи и оконной функции.Например, для суммы по клиенту порядок не нужен:
SUM(amount) OVER (PARTITION BY customer_id)А для номера заказа в истории клиента порядок уже нужен:
ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at )Потому что нельзя назначить «первый», «второй» и «третий» заказ, не договорившись, по какому признаку их упорядочивать.
Пример: сумма заказов клиента в каждой строке
Возьмём таблицу
orders:Хотим показать каждый заказ и общую сумму заказов клиента:
SELECT id, customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id) AS customer_total FROM orders;Результат:
Теперь можно сразу видеть и конкретный заказ, и масштаб клиента.
Например, заказ на 250 у клиента 1 — это часть общей суммы 500.
Доля строки внутри группы
Теперь посчитаем, какую долю каждый заказ занимает в общей сумме заказов клиента.
SELECT id, customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id) AS customer_total, ROUND( 100.0 * amount / SUM(amount) OVER (PARTITION BY customer_id), 1 ) AS percent_of_customer_total FROM orders;Результат:
Вот это уже настоящая аналитика.
Мы не просто получили сумму. Мы поняли, какой вклад каждый заказ внёс в сумму своего клиента.
Для бизнеса это очень полезный паттерн:
Несколько оконных агрегатов сразу
С
PARTITION BYможно добавить в каждую строку сразу несколько показателей по группе.Например:
SELECT id, customer_id, amount, COUNT(*) OVER (PARTITION BY customer_id) AS customer_orders_count, SUM(amount) OVER (PARTITION BY customer_id) AS customer_total_amount, AVG(amount) OVER (PARTITION BY customer_id) AS customer_avg_amount, MAX(amount) OVER (PARTITION BY customer_id) AS customer_max_amount FROM orders;Так в каждой строке заказа будет видно:
Обычный
GROUP BYтоже может посчитать эти значения, но он вернёт одну строку на клиента. А здесь мы сохраняем каждый заказ.Это удобно, когда нужно не потерять детали.
Сравнение строки со средней по группе
Допустим, есть таблица
products:Хотим показать каждый товар и среднюю цену в его категории.
SELECT id, name, category, price, AVG(price) OVER (PARTITION BY category) AS category_avg_price FROM products;Результат:
Теперь можно сравнить цену товара со средней ценой в категории:
SELECT id, name, category, price, AVG(price) OVER (PARTITION BY category) AS category_avg_price, price - AVG(price) OVER (PARTITION BY category) AS diff_from_category_avg FROM products;Результат:
Теперь сразу видно, какой товар дороже среднего, а какой дешевле.
Это очень частый аналитический приём: сравнить строку не со всей таблицей, а именно со своей группой.
ROW_NUMBER с PARTITION BY
PARTITION BYособенно часто используют с функциейROW_NUMBER.ROW_NUMBERнумерует строки. АPARTITION BYговорит: нумеруй не всю таблицу подряд, а отдельно внутри каждой группы.Например, нужно пронумеровать заказы каждого клиента от первого к последнему:
SELECT id, customer_id, amount, created_at, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at ) AS order_number FROM orders;Результат:
У клиента 1 своя нумерация: 1, 2, 3.
У клиента 2 своя нумерация: 1, 2.
Именно это даёт
PARTITION BY customer_id.Топ-N в каждой группе
Один из самых популярных сценариев: получить не общий топ, а топ внутри каждой группы.
Например:
Для этого сначала нумеруем заказы внутри каждого клиента, а потом оставляем только первые три.
WITH ranked_orders AS ( SELECT id, customer_id, amount, created_at, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC, id DESC ) AS row_num FROM orders ) SELECT id, customer_id, amount, created_at FROM ranked_orders WHERE row_num <= 3;Что здесь происходит:
PARTITION BY customer_idделит заказы по клиентам.ORDER BY created_at DESC, id DESCставит новые заказы выше старых внутри каждого клиента.ROW_NUMBERнумерует заказы каждого клиента.1,2или3.Так можно делать:
Почему оконную функцию нельзя сразу использовать в WHERE
Новички часто пытаются написать так:
SELECT id, customer_id, amount, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC ) AS row_num FROM orders WHERE row_num = 1;Такой запрос не сработает.
Причина в порядке выполнения SQL-запроса.
WHEREфильтрует строки раньше, чем вычисляются оконные функции. В момент работыWHEREколонкиrow_numещё не существует.Поэтому нужен подзапрос или CTE:
WITH ranked_orders AS ( SELECT id, customer_id, amount, created_at, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC ) AS row_num FROM orders ) SELECT id, customer_id, amount, created_at FROM ranked_orders WHERE row_num = 1;Так мы сначала создаём результат с
row_num, а потом уже фильтруем его во внешнем запросе.PARTITION BY по нескольким колонкам
В
PARTITION BYможно указать не одну колонку, а несколько.Например:
ROW_NUMBER() OVER ( PARTITION BY customer_id, EXTRACT(YEAR FROM created_at) ORDER BY amount DESC )Здесь группа — это не просто клиент. Группа — это пара:
То есть нумерация начнётся заново для каждого клиента в каждом году.
Полный пример:
SELECT id, customer_id, amount, created_at, ROW_NUMBER() OVER ( PARTITION BY customer_id, EXTRACT(YEAR FROM created_at) ORDER BY amount DESC, id DESC ) AS order_rank_in_year FROM orders;Так можно найти, например, самый крупный заказ каждого клиента в каждом году.
WITH ranked_orders AS ( SELECT id, customer_id, amount, created_at, ROW_NUMBER() OVER ( PARTITION BY customer_id, EXTRACT(YEAR FROM created_at) ORDER BY amount DESC, id DESC ) AS order_rank_in_year FROM orders ) SELECT id, customer_id, amount, created_at FROM ranked_orders WHERE order_rank_in_year = 1;PARTITION BY a, bозначает: отдельная группа для каждой уникальной пары значенийaиb.Что будет без PARTITION BY
Если не написать
PARTITION BY, оконная функция будет работать по всему набору строк.Например:
SELECT id, customer_id, amount, SUM(amount) OVER () AS grand_total FROM orders;Результат:
SUM(amount) OVER ()посчитал сумму по всей таблице и показал её в каждой строке.Это полезно, когда нужно сравнить строку с общим итогом.
Например, доля каждого заказа в общей выручке:
SELECT id, customer_id, amount, ROUND(100.0 * amount / SUM(amount) OVER (), 1) AS percent_of_all_orders FROM orders;Без
PARTITION BYокно — вся выборка.С
PARTITION BYокно — отдельная группа внутри выборки.PARTITION BY и ORDER BY внутри OVER
Внутри
OVERможно писать иPARTITION BY, иORDER BY.Они отвечают за разные вещи:
PARTITION BYделит строки на группы;ORDER BYзадаёт порядок строк внутри каждой группы.Например:
ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC )Это означает:
Если убрать
PARTITION BY, получится другая логика:ROW_NUMBER() OVER ( ORDER BY created_at DESC )Теперь будет одна общая нумерация по всем заказам. Это уже не «последний заказ каждого клиента», а «последние заказы во всей таблице».
Если убрать
ORDER BY, уROW_NUMBERне будет понятного порядка:ROW_NUMBER() OVER ( PARTITION BY customer_id )Такой запрос может выполниться, но смысл будет слабым: база пронумерует строки внутри клиента без гарантированного порядка. Для
ROW_NUMBERпочти всегда нужно явно указыватьORDER BY.Важный нюанс: SUM с ORDER BY даёт нарастающий итог
Это место часто путают.
Смотри на такой запрос:
SELECT id, customer_id, amount, created_at, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY created_at ) AS running_total FROM orders;Многие ожидают, что
SUMпосчитает общую сумму клиента. Но из-заORDER BYвнутриOVERрезультат становится нарастающим итогом.То есть для каждого клиента сумма будет копиться от первого заказа к текущему.
Пример:
Это называется running total — нарастающий итог.
Если нужна сумма всей группы, пиши без
ORDER BY:SELECT id, customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id) AS customer_total FROM orders;А если нужен именно нарастающий итог, тогда
ORDER BYнужен:SELECT id, customer_id, amount, created_at, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY created_at ) AS running_total FROM orders;Запомни простое правило:
SUM(amount) OVER (PARTITION BY customer_id)— сумма по всему клиенту;SUM(amount) OVER (PARTITION BY customer_id ORDER BY created_at)— сумма от начала истории клиента до текущей строки.Нарастающий итог и одинаковые даты
Есть тонкий момент: если в
ORDER BYесть одинаковые значения, результат может быть не таким очевидным.Например, два заказа клиента созданы в одну дату. Тогда база может считать их «соседями» с одинаковым ключом сортировки.
Чтобы порядок был стабильным, добавляй уникальную колонку:
SELECT id, customer_id, amount, created_at, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, id ) AS running_total FROM orders;idпомогает базе понять, какой заказ идёт раньше, если даты одинаковые.Это хорошая привычка для оконных функций: если порядок важен, делай его детерминированным.
LAG и LEAD с PARTITION BY
PARTITION BYполезен не только с агрегатами иROW_NUMBER, но и с функциямиLAGиLEAD.LAGпозволяет посмотреть на предыдущую строку.LEADпозволяет посмотреть на следующую строку.Например, хотим для каждого заказа увидеть предыдущий заказ этого же клиента:
SELECT id, customer_id, amount, created_at, LAG(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, id ) AS previous_amount FROM orders;Что важно: благодаря
PARTITION BY customer_idпредыдущий заказ берётся только внутри того же клиента.Без
PARTITION BYфункция могла бы взять предыдущий заказ другого клиента, и такой результат был бы почти бесполезен для анализа истории конкретного пользователя.Можно посчитать разницу с предыдущим заказом:
SELECT id, customer_id, amount, created_at, amount - LAG(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, id ) AS diff_from_previous_amount FROM orders;Так можно анализировать, растёт ли чек клиента со временем.
NULL в PARTITION BY
Если колонка для
PARTITION BYсодержитNULL, такие строки попадают в одну общую партицию.Например:
COUNT(*) OVER (PARTITION BY manager_id)Все строки, где
manager_idравенNULL, будут считаться одной группой.Это не значит, что в SQL
NULL = NULLв обычном сравнении. В условияхWHERE manager_id = NULLвсё равно будет ошибка по смыслу, потому чтоNULLпроверяют черезIS NULL.Но для группировки и оконных партиций строки с
NULLобычно оказываются вместе.Если строки с пустым значением не нужны в расчёте, отфильтруй их заранее:
SELECT employee_id, manager_id, salary, AVG(salary) OVER (PARTITION BY manager_id) AS avg_salary_by_manager FROM employees WHERE manager_id IS NOT NULL;PARTITION BY не фильтрует строки
Важно:
PARTITION BYне удаляет строки из результата.Он только говорит оконной функции, как разбить строки на группы для расчёта.
Например:
SELECT id, customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id) AS customer_total FROM orders;Этот запрос не оставит по одной строке на клиента. Он вернёт все заказы.
Если тебе нужна одна строка на клиента, используй
GROUP BY:SELECT customer_id, SUM(amount) AS customer_total FROM orders GROUP BY customer_id;А если тебе нужна, например, одна последняя строка на каждого клиента, используй оконную функцию и внешний фильтр:
WITH ranked_orders AS ( SELECT id, customer_id, amount, created_at, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC, id DESC ) AS row_num FROM orders ) SELECT id, customer_id, amount, created_at FROM ranked_orders WHERE row_num = 1;PARTITION BYсам по себе не уменьшает количество строк.Когда нужен GROUP BY, а когда PARTITION BY
Выбирай
GROUP BY, когда тебе нужен итоговый отчёт без деталей.Например:
SELECT customer_id, SUM(amount) AS total_amount FROM orders GROUP BY customer_id;Результат: одна строка на клиента.
Выбирай
PARTITION BY, когда тебе нужны детали и расчёт по группе рядом с каждой строкой.Например:
SELECT id, customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id) AS total_amount FROM orders;Результат: каждый заказ плюс сумма по клиенту.
Хорошая шпаргалка:
GROUP BYPARTITION BYPARTITION BY+ROW_NUMBERPARTITION BYGROUP BYПереиспользование окна через WINDOW
Если в запросе несколько оконных функций с одним и тем же
PARTITION BYиORDER BY, можно вынести окно в отдельное имя черезWINDOW.Например:
SELECT id, customer_id, amount, created_at, ROW_NUMBER() OVER customer_window AS row_num, COUNT(*) OVER customer_window AS orders_count FROM orders WINDOW customer_window AS ( PARTITION BY customer_id ORDER BY created_at, id );Такой синтаксис помогает не повторять одно и то же несколько раз.
Но для новичка это не первый инструмент, который нужно запоминать. Сначала важно уверенно понять обычную форму:
SUM(amount) OVER (PARTITION BY customer_id)А
WINDOWпригодится позже, когда запросы станут длиннее.Частые ошибки с PARTITION BY
Путать PARTITION BY и GROUP BY
GROUP BYуменьшает количество строк.SELECT customer_id, SUM(amount) AS total_amount FROM orders GROUP BY customer_id;PARTITION BYсохраняет строки.SELECT id, customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id) AS total_amount FROM orders;Если после запроса ты ожидал одну строку на клиента, а получил все заказы, значит, скорее всего, ты использовал оконную функцию там, где нужен был
GROUP BY.Забыть PARTITION BY
Например, нужно найти последний заказ каждого клиента.
Правильно:
ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC )Если забыть
PARTITION BY, получится нумерация по всей таблице:ROW_NUMBER() OVER ( ORDER BY created_at DESC )Тогда
row_num = 1даст один самый последний заказ вообще, а не по одному последнему заказу на каждого клиента.Добавить ORDER BY к SUM и случайно получить running total
Если нужна сумма по всей группе, пиши:
SUM(amount) OVER (PARTITION BY customer_id)Если написать так:
SUM(amount) OVER ( PARTITION BY customer_id ORDER BY created_at )ты получишь нарастающий итог, а не общую сумму клиента в каждой строке.
Пытаться использовать оконную функцию в WHERE
Так нельзя:
SELECT id, customer_id, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC ) AS row_num FROM orders WHERE row_num = 1;Нужно так:
WITH ranked_orders AS ( SELECT id, customer_id, created_at, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC ) AS row_num FROM orders ) SELECT id, customer_id, created_at FROM ranked_orders WHERE row_num = 1;Сначала считаем оконную функцию, потом фильтруем.
Не задавать стабильный порядок
Если у нескольких строк одинаковое значение в
ORDER BY, порядок между ними может быть непредсказуемым.Менее надёжно:
ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC )Надёжнее:
ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC, id DESC )idделает порядок стабильным.Думать, что PARTITION BY сортирует результат
PARTITION BYне сортирует итоговую выдачу. Он только делит строки на группы внутри оконной функции.Например:
SELECT id, customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id) AS customer_total FROM orders;Этот запрос не обязан вывести сначала клиента 1, потом клиента 2.
Если нужен порядок результата, добавь внешний
ORDER BY:SELECT id, customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id) AS customer_total FROM orders ORDER BY customer_id, id;ORDER BYвнутриOVERуправляет порядком для оконной функции.ORDER BYв конце запроса управляет порядком итоговой таблицы.Это разные вещи.
Главное
PARTITION BY— это способ сказать оконной функции: «считай не по всей таблице, а отдельно внутри каждой группы».Главная идея:
SUM(amount) OVER (PARTITION BY customer_id)означает:
Самое важное:
PARTITION BYиспользуется внутриOVER;GROUP BY;PARTITION BYокно обычно работает по всей выборке;ROW_NUMBERон помогает делать топы внутри каждой группы;SUM,AVG,COUNT,MIN,MAXон добавляет групповые показатели к каждой строке;SUMсORDER BYвнутриOVERдаёт нарастающий итог;WHERE, для этого нужен CTE или подзапрос;NULLв колонке партиции попадают в одну группу;PARTITION BYне сортирует итоговую выдачу, для этого нужен обычныйORDER BYв конце запроса.Если
GROUP BY— это «сделай краткий итог по группам», тоPARTITION BY— это «оставь детали, но покажи рядом умный расчёт по группе». Именно поэтому оконные функции так любят в аналитике: они позволяют видеть и конкретную строку, и её контекст одновременно.