Перейти к содержанию

SQL: оконные функции

Агрегатные функции через GROUP BY «схлопывают» группу строк в одну. Но часто нужно и посчитать агрегат, и сохранить каждую строку. Именно для этого существуют оконные функции — они выполняют вычисления по набору строк, связанных с текущей, но не уменьшают количество строк в результате.

В этой статье ты пройдёшь от базового ранжирования до скользящих средних и процентильного анализа. Все примеры на схеме etl_kitchen_db.


Синтаксис

SQL
функция(...) OVER (
    PARTITION BY ...     -- на какие группы делить (необязательно)
    ORDER BY ...         -- порядок внутри группы (необязательно)
    ROWS BETWEEN ...     -- фрейм: какие строки включить (необязательно)
)
Компонент Что делает Если опущен
PARTITION BY Делит строки на независимые группы Все строки = одна группа
ORDER BY Задаёт порядок внутри раздела Порядок не определён
ROWS BETWEEN Границы «окна» для текущей строки Зависит от контекста
SQL
-- Пример: каждый заказ + общее количество заказов клиента
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
SQL
-- Ранжирование товаров по цене в каждой категории
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 примерно равных групп:

SQL
-- Разбить клиентов на 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 в каждой группе

Одна из самых частых аналитических задач — выбрать лучшие/последние записи для каждой группы.

Последний заказ каждого клиента

SQL
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 самых дорогих товара в каждой категории

SQL
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 шагов вперёд).

Дни между заказами клиента

SQL
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

Рост выручки месяц к месяцу

SQL
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-е значение в окне
SQL
-- Каждый товар + самый дорогой и самый дешёвый в категории
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)

SQL
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

Процент от общей суммы

SQL
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-анализа)

SQL
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 месяца

SQL
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 для исключения определённых строк:

SQL
-- Среднее по окну, исключая текущую строку
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:

Text Only
FROM → WHERE → GROUP BY → HAVING → оконные функции → SELECT → DISTINCT → ORDER BY → LIMIT

Это означает:

  1. Нельзя использовать оконную функцию в WHERE или HAVING
  2. Можно использовать результат GROUP BY в оконной функции
  3. Для фильтрации по окну — оберни в CTE/подзапрос

Типичные ошибки

1. Фильтрация по оконной функции в WHERE

SQL
-- ОШИБКА: оконные функции нельзя в 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 без правильного фрейма

SQL
-- Неожиданный результат: вернёт текущую строку
LAST_VALUE(price) OVER (ORDER BY price)

-- Правильно: расширь фрейм до конца раздела
LAST_VALUE(price) OVER (
    ORDER BY price
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)

3. Недетерминированный ROW_NUMBER

SQL
-- Если несколько строк с одинаковой order_date —
-- ROW_NUMBER() присвоит им номера случайно
ROW_NUMBER() OVER (ORDER BY order_date)

-- Добавь вторичную сортировку для стабильности:
ROW_NUMBER() OVER (ORDER BY order_date, order_id)

4. Копирование OVER() вместо WINDOW-алиаса

SQL
-- Плохо: три копии одного и того же окна
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-join
  • FIRST_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) или изучи продвинутые аналитические паттерны.


Источники