SQL: оконные функции¶
Агрегатные функции через GROUP BY «схлопывают» группу строк в одну. Но часто нужно и посчитать агрегат, и сохранить каждую строку. Именно для этого существуют оконные функции — они выполняют вычисления по набору строк, связанных с текущей, но не уменьшают количество строк в результате.
В этой статье ты пройдёшь от базового ранжирования до скользящих средних и процентильного анализа. Все примеры на схеме etl_kitchen_db.
Синтаксис¶
функция(...) OVER (
PARTITION BY ... -- на какие группы делить (необязательно)
ORDER BY ... -- порядок внутри группы (необязательно)
ROWS BETWEEN ... -- фрейм: какие строки включить (необязательно)
)
| Компонент | Что делает | Если опущен |
|---|---|---|
PARTITION BY |
Делит строки на независимые группы | Все строки = одна группа |
ORDER BY |
Задаёт порядок внутри раздела | Порядок не определён |
ROWS BETWEEN |
Границы «окна» для текущей строки | Зависит от контекста |
-- Пример: каждый заказ + общее количество заказов клиента
SELECT
o.order_id,
c.name,
o.total_amount,
COUNT(*) OVER (PARTITION BY o.customer_id) AS customer_total_orders
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
ORDER BY c.name, o.order_date;
| order_id | name | total_amount | customer_total_orders |
|---|---|---|---|
| 1 | Алексей Смирнов | 89990.00 | 5 |
| 18 | Алексей Смирнов | 26980.00 | 5 |
| ... | ... | ... | ... |
OVER() без параметров
SUM(total_amount) OVER() — окно = все строки результата. Полезно для вычисления процентов от общей суммы.
Ранжирование: ROW_NUMBER, RANK, DENSE_RANK, NTILE¶
Три вида рангов¶
| Функция | Поведение при дубликатах | Пример (цены: 100, 100, 80, 60) |
|---|---|---|
ROW_NUMBER() |
Всегда уникальный номер | 1, 2, 3, 4 |
RANK() |
Одинаковый ранг + пропуск | 1, 1, 3, 4 |
DENSE_RANK() |
Одинаковый ранг, без пропуска | 1, 1, 2, 3 |
-- Ранжирование товаров по цене в каждой категории
SELECT
p.name,
cat.name AS category,
p.price,
ROW_NUMBER() OVER w AS rn,
RANK() OVER w AS rk,
DENSE_RANK() OVER w AS dr
FROM products p
JOIN categories cat ON p.category_id = cat.category_id
WINDOW w AS (PARTITION BY cat.name ORDER BY p.price DESC)
ORDER BY cat.name, p.price DESC;
| name | category | price | rn | rk | dr |
|---|---|---|---|---|---|
| Ноутбук MacBook Pro | Электроника | 89990.00 | 1 | 1 | 1 |
| Телевизор Samsung 55" | Электроника | 74990.00 | 2 | 2 | 2 |
| Смартфон Galaxy S24 | Электроника | 54990.00 | 3 | 3 | 3 |
| Кофеварка DeLonghi | Бытовая техника | 34990.00 | 1 | 1 | 1 |
| ... | ... | ... | ... | ... | ... |
WINDOW-алиас
WINDOW w AS (...) в конце запроса позволяет переиспользовать одно окно в нескольких функциях. Это чище, чем копировать OVER(PARTITION BY ... ORDER BY ...) три раза.
NTILE: разделение на группы¶
NTILE(n) делит строки раздела на n примерно равных групп:
-- Разбить клиентов на 4 квартиля по сумме покупок
SELECT
c.name,
SUM(o.total_amount) AS total_spent,
NTILE(4) OVER (ORDER BY SUM(o.total_amount) DESC) AS quartile
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.status = 'delivered'
GROUP BY c.customer_id, c.name
ORDER BY total_spent DESC;
| name | total_spent | quartile |
|---|---|---|
| Борис Кузнецов | 139980.00 | 1 |
| Виктория Морозова | 156970.00 | 1 |
| ... | ... | 2 |
| ... | ... | 3 |
| ... | ... | 4 |
Топ-N в каждой группе¶
Одна из самых частых аналитических задач — выбрать лучшие/последние записи для каждой группы.
Последний заказ каждого клиента¶
WITH ranked AS (
SELECT
o.*,
c.name,
ROW_NUMBER() OVER (
PARTITION BY o.customer_id
ORDER BY o.order_date DESC
) AS rn
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
)
SELECT order_id, name, order_date, total_amount, status
FROM ranked
WHERE rn = 1
ORDER BY order_date DESC;
Топ-3 самых дорогих товара в каждой категории¶
WITH ranked AS (
SELECT
p.name AS product,
cat.name AS category,
p.price,
ROW_NUMBER() OVER (
PARTITION BY p.category_id
ORDER BY p.price DESC
) AS rn
FROM products p
JOIN categories cat ON p.category_id = cat.category_id
)
SELECT category, product, price
FROM ranked
WHERE rn <= 3
ORDER BY category, price DESC;
Оконные функции вычисляются ПОСЛЕ WHERE
Нельзя написать WHERE ROW_NUMBER() OVER(...) = 1 в основном запросе. Порядок:
FROM → WHERE → GROUP BY → HAVING → оконные функции → SELECT → ORDER BY.
Для фильтрации по окну — оберни в CTE или подзапрос.
LAG и LEAD: доступ к соседним строкам¶
LAG(col, n, default) — значение из предыдущей строки (n шагов назад).
LEAD(col, n, default) — значение из следующей строки (n шагов вперёд).
Дни между заказами клиента¶
SELECT
c.name,
o.order_date,
o.total_amount,
LAG(o.order_date) OVER w AS prev_order_date,
o.order_date - LAG(o.order_date) OVER w AS days_between
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WINDOW w AS (PARTITION BY o.customer_id ORDER BY o.order_date)
ORDER BY c.name, o.order_date;
| name | order_date | total_amount | prev_order_date | days_between |
|---|---|---|---|---|
| Алексей Смирнов | 2024-06-15 | 89990.00 | NULL | NULL |
| Алексей Смирнов | 2024-08-20 | 26980.00 | 2024-06-15 | 66 |
| Алексей Смирнов | 2024-10-05 | 45990.00 | 2024-08-20 | 46 |
Рост выручки месяц к месяцу¶
WITH monthly AS (
SELECT
DATE_TRUNC('month', order_date) AS month,
SUM(total_amount) AS revenue
FROM orders
WHERE status != 'cancelled'
GROUP BY month
)
SELECT
month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month,
ROUND(
100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
/ NULLIF(LAG(revenue) OVER (ORDER BY month), 0),
1
) AS growth_pct
FROM monthly
ORDER BY month;
| month | revenue | prev_month | growth_pct |
|---|---|---|---|
| 2024-06-01 | 234560.00 | NULL | NULL |
| 2024-07-01 | 312890.00 | 234560.00 | 33.4 |
| 2024-08-01 | 289450.00 | 312890.00 | -7.5 |
FIRST_VALUE, LAST_VALUE, NTH_VALUE¶
| Функция | Возвращает |
|---|---|
FIRST_VALUE(col) |
Первое значение в окне |
LAST_VALUE(col) |
Последнее значение в окне |
NTH_VALUE(col, n) |
N-е значение в окне |
-- Каждый товар + самый дорогой и самый дешёвый в категории
SELECT
p.name,
cat.name AS category,
p.price,
FIRST_VALUE(p.name) OVER w AS most_expensive,
LAST_VALUE(p.name) OVER (
PARTITION BY cat.name ORDER BY p.price DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS cheapest
FROM products p
JOIN categories cat ON p.category_id = cat.category_id
WINDOW w AS (PARTITION BY cat.name ORDER BY p.price DESC)
ORDER BY cat.name, p.price DESC;
Ловушка LAST_VALUE
По умолчанию фрейм = ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Поэтому LAST_VALUE вернёт текущую строку, а не последнюю. Чтобы получить настоящее последнее значение, укажи фрейм ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
Агрегаты как оконные функции¶
Любой агрегат (SUM, AVG, COUNT, MIN, MAX) может работать как оконная функция:
Накопительный итог (Running Total)¶
SELECT
o.order_date,
o.total_amount,
SUM(o.total_amount) OVER (
ORDER BY o.order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders o
WHERE o.status != 'cancelled'
ORDER BY o.order_date
LIMIT 10;
| order_date | total_amount | running_total |
|---|---|---|
| 2024-06-15 | 89990.00 | 89990.00 |
| 2024-06-18 | 26980.00 | 116970.00 |
| 2024-06-22 | 156970.00 | 273940.00 |
Процент от общей суммы¶
SELECT
cat.name AS category,
SUM(oi.quantity * oi.unit_price) AS revenue,
ROUND(
100.0 * SUM(oi.quantity * oi.unit_price)
/ SUM(SUM(oi.quantity * oi.unit_price)) OVER (),
1
) AS pct_of_total
FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
JOIN categories cat ON p.category_id = cat.category_id
GROUP BY cat.name
ORDER BY revenue DESC;
| category | revenue | pct_of_total |
|---|---|---|
| Электроника | 890000.00 | 62.5 |
| Бытовая техника | 310000.00 | 21.8 |
| Продукты питания | 145000.00 | 10.2 |
| ... | ... | ... |
SUM() OVER() после GROUP BY
SUM(SUM(...)) OVER() — не ошибка! Внутренний SUM — агрегат GROUP BY, внешний SUM OVER() — оконная функция по результатам группировки.
Накопительный процент (для ABC-анализа)¶
WITH product_revenue AS (
SELECT
p.name,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
GROUP BY p.product_id, p.name
)
SELECT
name,
revenue,
ROUND(
100.0 * SUM(revenue) OVER (ORDER BY revenue DESC)
/ SUM(revenue) OVER (),
1
) AS cumulative_pct,
CASE
WHEN SUM(revenue) OVER (ORDER BY revenue DESC) * 1.0
/ SUM(revenue) OVER () <= 0.8 THEN 'A'
WHEN SUM(revenue) OVER (ORDER BY revenue DESC) * 1.0
/ SUM(revenue) OVER () <= 0.95 THEN 'B'
ELSE 'C'
END AS abc_class
FROM product_revenue
ORDER BY revenue DESC;
Фреймы: ROWS, RANGE, GROUPS¶
Фрейм определяет, какие строки входят в «окно» для текущей строки.
ROWS — физические строки¶
| Фрейм | Что включает |
|---|---|
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW |
От начала раздела до текущей |
ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING |
5 строк: 2 до + текущая + 2 после |
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING |
Все строки раздела |
Скользящее среднее за 3 месяца¶
WITH monthly AS (
SELECT
DATE_TRUNC('month', order_date) AS month,
SUM(total_amount) AS revenue
FROM orders
WHERE status != 'cancelled'
GROUP BY month
)
SELECT
month,
revenue,
ROUND(AVG(revenue) OVER (
ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 2) AS moving_avg_3m
FROM monthly
ORDER BY month;
ROWS vs RANGE vs GROUPS¶
| Тип | Считает по | Когда использовать |
|---|---|---|
ROWS |
Физическим строкам | Всегда по умолчанию — предсказуемо |
RANGE |
Значениям (логические диапазоны) | Даты, числа — объединяет дубликаты |
GROUPS |
Группам одинаковых значений (PG 11+) | Редко, для специфичных задач |
Начинай с ROWS
ROWS считает ровно столько строк, сколько ты указал. RANGE может захватить больше, если есть дубликаты значений — это часто неожиданно.
EXCLUDE (PostgreSQL 11+)¶
Фрейм можно дополнить EXCLUDE для исключения определённых строк:
-- Среднее по окну, исключая текущую строку
SELECT
name, price,
ROUND(AVG(price) OVER (
ORDER BY price
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
EXCLUDE CURRENT ROW
), 2) AS avg_neighbors
FROM products;
| Опция | Что исключает |
|---|---|
EXCLUDE CURRENT ROW |
Текущую строку |
EXCLUDE TIES |
Строки с тем же значением ORDER BY |
EXCLUDE GROUP |
Текущую строку + все ties |
EXCLUDE NO OTHERS |
Ничего (по умолчанию) |
Порядок вычисления в SQL¶
Оконные функции вычисляются на предпоследнем шаге — после WHERE, GROUP BY, HAVING, но до ORDER BY и LIMIT:
Это означает:
- Нельзя использовать оконную функцию в
WHEREилиHAVING - Можно использовать результат GROUP BY в оконной функции
- Для фильтрации по окну — оберни в CTE/подзапрос
Типичные ошибки¶
1. Фильтрация по оконной функции в WHERE¶
-- ОШИБКА: оконные функции нельзя в WHERE
SELECT * FROM orders
WHERE ROW_NUMBER() OVER (ORDER BY order_date) <= 10;
-- Правильно: подзапрос
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (ORDER BY order_date) AS rn
FROM orders
) sub WHERE rn <= 10;
2. LAST_VALUE без правильного фрейма¶
-- Неожиданный результат: вернёт текущую строку
LAST_VALUE(price) OVER (ORDER BY price)
-- Правильно: расширь фрейм до конца раздела
LAST_VALUE(price) OVER (
ORDER BY price
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)
3. Недетерминированный ROW_NUMBER¶
-- Если несколько строк с одинаковой order_date —
-- ROW_NUMBER() присвоит им номера случайно
ROW_NUMBER() OVER (ORDER BY order_date)
-- Добавь вторичную сортировку для стабильности:
ROW_NUMBER() OVER (ORDER BY order_date, order_id)
4. Копирование OVER() вместо WINDOW-алиаса¶
-- Плохо: три копии одного и того же окна
SELECT
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date),
LAG(total_amount) OVER (PARTITION BY customer_id ORDER BY order_date),
SUM(total_amount) OVER (PARTITION BY customer_id ORDER BY order_date)
FROM orders;
-- Хорошо: WINDOW-алиас
SELECT
ROW_NUMBER() OVER w,
LAG(total_amount) OVER w,
SUM(total_amount) OVER w
FROM orders
WINDOW w AS (PARTITION BY customer_id ORDER BY order_date);
Шпаргалка¶
| Задача | Функция |
|---|---|
| Уникальный номер строки | ROW_NUMBER() |
| Ранг с пропусками | RANK() |
| Ранг без пропусков | DENSE_RANK() |
| Разбить на N равных групп | NTILE(n) |
| Предыдущее/следующее значение | LAG() / LEAD() |
| Первое/последнее в окне | FIRST_VALUE() / LAST_VALUE() |
| Накопительный итог | SUM() OVER (ORDER BY ...) |
| Скользящее среднее | AVG() OVER (... ROWS BETWEEN n PRECEDING AND CURRENT ROW) |
| Процент от общего | SUM(x) / SUM(x) OVER () |
| Топ-N в каждой группе | CTE + ROW_NUMBER() ... WHERE rn <= N |
Что запомнить¶
- Оконные функции не уменьшают количество строк — в отличие от
GROUP BY PARTITION BY= группировка,ORDER BY= порядок внутри,ROWS BETWEEN= фреймROW_NUMBER— уникальный номер,RANK— с пропусками,DENSE_RANK— без пропусковLAG/LEAD— доступ к соседним строкам без self-joinFIRST_VALUEработает из коробки,LAST_VALUEтребует расширения фрейма- Оконные функции вычисляются после WHERE/HAVING — фильтрация через CTE/подзапрос
SUM(SUM(...)) OVER()— корректная конструкция: внутренний SUM = GROUP BY, внешний = окноWINDOW w AS (...)— переиспользование окна, не копируй OVER() рукамиROWS— предсказуемый подсчёт по строкам,RANGE— по значениям- Для стабильного
ROW_NUMBERдобавляй вторичный ключ вORDER BY
Попробуй сам¶
Проверь себя¶
Что дальше?¶
Оконные функции — один из самых мощных инструментов SQL-аналитика. Дальше разберись с операциями над наборами (UNION, INTERSECT, EXCEPT) или изучи продвинутые аналитические паттерны.
Источники¶
- PostgreSQL: Window Functions Tutorial — вводный урок по оконным функциям
- PostgreSQL: Window Function List — полный список:
ROW_NUMBER,RANK,DENSE_RANK,NTILE,LAG,LEAD,FIRST_VALUE,LAST_VALUE,NTH_VALUE - PostgreSQL: Window Function Syntax —
OVER,PARTITION BY,WINDOW, фреймыROWS/RANGE/GROUPS/EXCLUDE - PostgreSQL: Aggregate Functions — агрегаты как оконные функции