Оконные функции — это один из самых сильных инструментов SQL после JOIN и GROUP BY.
Они нужны там, где хочется посчитать что-то «по соседним строкам» или «внутри группы», но не потерять исходные строки.
Например:
- найти топ-3 продукта в каждой категории;
- пронумеровать заказы внутри каждого клиента;
- посчитать выручку нарастающим итогом;
- сравнить сегодняшнюю выручку со вчерашней;
- посчитать скользящее среднее за 7 дней;
- показать заказ и рядом средний чек по его категории.
Без оконных функций такие задачи быстро превращаются в тяжёлые подзапросы, самоджойны и головную боль. С оконными функциями они становятся аккуратными и читаемыми.
Таблица для примеров
Будем использовать простую таблицу заказов:
CREATE TABLE orders (
id INT PRIMARY KEY,
category VARCHAR(50),
product VARCHAR(100),
amount NUMERIC(10, 2),
created_at DATE
);
В ней есть:
id — идентификатор заказа;
category — категория товара;
product — название продукта;
amount — сумма заказа;
created_at — дата заказа.
На такой таблице удобно разбирать почти все базовые сценарии оконных функций.
Главная идея оконных функций
Оконная функция считает значение не по всей таблице сразу, а по некоторому «окну» строк.
Окно — это набор строк, которые связаны с текущей строкой.
Например:
- все заказы той же категории;
- все заказы того же клиента;
- все дни до текущего дня;
- текущая строка и 6 предыдущих;
- предыдущая или следующая строка по дате.
Самое важное: оконные функции не схлопывают строки.
Они оставляют каждую строку на месте, но добавляют рядом новое вычисленное значение.
GROUP BY схлопывает строки, оконные функции — нет
Это главное отличие.
Когда вы используете GROUP BY, SQL собирает много строк в одну.
Например:
SELECT
category,
AVG(amount) AS avg_amount
FROM orders
GROUP BY category;
Если в таблице было 1000 заказов и 5 категорий, после запроса останется 5 строк: по одной строке на категорию.
Это удобно, когда нужен итоговый отчёт. Но иногда хочется видеть каждый заказ и рядом среднее значение по его категории.
С GROUP BY так просто не получится, потому что детализация уже потеряна.
А оконная функция позволяет сделать именно это:
SELECT
id,
category,
product,
amount,
AVG(amount) OVER (PARTITION BY category) AS category_avg_amount
FROM orders;
Результат будет примерно таким:
id | category | product | amount | category_avg_amount
---+----------+---------+--------+--------------------
1 | books | book_a | 500.00 | 650.00
2 | books | book_b | 800.00 | 650.00
3 | games | game_a | 300.00 | 450.00
4 | games | game_b | 600.00 | 450.00
Каждый заказ остался отдельной строкой. Но рядом появилась средняя сумма по его категории.
Вот в этом и магия оконных функций: они считают агрегат, но не уничтожают детализацию.
Как читать OVER
Оконная функция почти всегда выглядит так:
function_name(...) OVER (...)
Например:
AVG(amount) OVER (PARTITION BY category)
Читать можно так:
«Посчитай среднее значение amount по окну, где строки разделены по category».
Часть OVER (...) говорит SQL, какие строки считать соседями текущей строки.
Внутри OVER чаще всего встречаются три настройки:
PARTITION BY;
ORDER BY;
- frame, то есть рамка окна.
Разберём их по очереди.
PARTITION BY: разделить окно на группы
PARTITION BY делит строки на независимые группы внутри оконной функции.
Похоже на GROUP BY, но есть важное отличие: строки не схлопываются.
Среднее по всей таблице:
SELECT
id,
category,
amount,
AVG(amount) OVER () AS avg_amount_all
FROM orders;
Среднее отдельно по каждой категории:
SELECT
id,
category,
amount,
AVG(amount) OVER (PARTITION BY category) AS avg_amount_by_category
FROM orders;
Если написать OVER (), окно — вся таблица.
Если написать OVER (PARTITION BY category), для каждой строки окном будут только строки с той же категорией.
Можно думать так:
GROUP BY category оставляет одну строку на категорию;
PARTITION BY category считает внутри категории, но сохраняет все строки.
ORDER BY внутри OVER: порядок строк в окне
ORDER BY внутри OVER задаёт порядок строк для оконной функции.
Он нужен, когда важна позиция:
- кто первый;
- кто второй;
- какая строка была предыдущей;
- какая строка будет следующей;
- как считать накопительный итог.
Например, пронумеруем заказы внутри каждой категории по убыванию суммы:
SELECT
id,
category,
product,
amount,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY amount DESC
) AS row_num
FROM orders;
Для каждой категории SQL отдельно отсортирует заказы по amount DESC и проставит номера: 1, 2, 3 и так далее.
Важно: ORDER BY внутри OVER и обычный ORDER BY в конце запроса — это разные вещи.
ORDER BY внутри OVER нужен для расчёта оконной функции.
Обычный ORDER BY в конце нужен для сортировки итогового результата на экране.
SELECT
id,
category,
amount,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY amount DESC
) AS row_num
FROM orders
ORDER BY category, row_num;
Внутренний порядок считает номера, внешний порядок красиво выводит результат.
Frame: какие строки внутри окна участвуют в расчёте
Frame, или рамка окна, отвечает на вопрос: какие именно строки внутри отсортированного окна участвуют в расчёте для текущей строки.
Звучит сложнее, чем есть на самом деле.
Например, накопительная сумма:
SELECT
created_at,
amount,
SUM(amount) OVER (
ORDER BY created_at
) AS running_amount
FROM orders
ORDER BY created_at;
Когда в оконном агрегате есть ORDER BY, сумма считается не по всей таблице сразу, а нарастающим итогом: от начала окна до текущей строки.
Пример:
created_at | amount | running_amount
-----------+--------+---------------
2026-06-01 | 100.00 | 100.00
2026-06-02 | 150.00 | 250.00
2026-06-03 | 200.00 | 450.00
Это частая ловушка новичков.
Вы могли добавить ORDER BY внутри OVER, чтобы «просто упорядочить», а агрегат внезапно стал накопительным.
Если вам нужно среднее по всей категории, порядок не нужен:
AVG(amount) OVER (PARTITION BY category)
Если нужен накопительный итог внутри категории, порядок нужен:
SUM(amount) OVER (
PARTITION BY category
ORDER BY created_at
)
ROW_NUMBER: уникальный номер строки
ROW_NUMBER() присваивает каждой строке уникальный номер внутри окна.
SELECT
id,
category,
product,
amount,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY amount DESC
) AS row_num
FROM orders;
Если в категории несколько заказов, самый дорогой получит row_num = 1, следующий — row_num = 2, и так далее.
Даже если суммы одинаковые, ROW_NUMBER() всё равно поставит разные номера.
Например, для сумм 100, 100, 50 номера будут такими:
amount | row_num
-------+--------
100.00 | 1
100.00 | 2
50.00 | 3
Но здесь есть тонкость: если в ORDER BY есть одинаковые значения, порядок между ними может быть нестабильным. Сегодня один заказ получит номер 1, завтра другой, если база выберет другой порядок чтения.
Поэтому для надёжной сортировки лучше добавлять уникальный столбец в конец ORDER BY.
SELECT
id,
category,
product,
amount,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY amount DESC, id
) AS row_num
FROM orders;
Теперь при равной сумме победит заказ с меньшим id, и результат будет стабильным.
RANK и DENSE_RANK: ранги с учётом одинаковых значений
Кроме ROW_NUMBER(), есть ещё две похожие функции:
Они нужны, когда одинаковые значения должны получить одинаковое место.
Представим суммы:
100, 100, 50
Разные функции пронумеруют их так:
| Функция |
Результат |
ROW_NUMBER() |
1, 2, 3 |
RANK() |
1, 1, 3 |
DENSE_RANK() |
1, 1, 2 |
Разница такая:
ROW_NUMBER() всегда даёт уникальный номер каждой строке.
RANK() даёт одинаковый ранг одинаковым значениям, но оставляет разрыв. Если два заказа поделили первое место, следующий будет третьим.
DENSE_RANK() тоже даёт одинаковый ранг одинаковым значениям, но без разрыва. Если два заказа поделили первое место, следующий будет вторым.
Пример:
SELECT
id,
category,
product,
amount,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY amount DESC, id
) AS row_num,
RANK() OVER (
PARTITION BY category
ORDER BY amount DESC
) AS rank_num,
DENSE_RANK() OVER (
PARTITION BY category
ORDER BY amount DESC
) AS dense_rank_num
FROM orders;
Как выбрать между ROW_NUMBER, RANK и DENSE_RANK
Выбор зависит от бизнес-смысла.
Если нужно строго 3 строки на категорию — берите ROW_NUMBER().
Например: «покажи ровно 3 продукта в каждой категории».
Если нужно показать всех, кто делит место в рейтинге, — берите RANK() или DENSE_RANK().
Например: «покажи всех продуктов, которые попали в топ-3 мест, включая ничьи».
Разница особенно важна на собеседованиях. В задаче «топ-3 в каждой группе» всегда уточняйте: нужно ровно 3 строки или все участники, которые делят третье место.
Кейс: топ-3 продукта в каждой категории
Это одна из самых популярных задач на оконные функции.
Нужно найти три продукта с максимальной выручкой в каждой категории.
Сначала посчитаем выручку по продуктам, а потом пронумеруем продукты внутри каждой категории.
WITH product_revenue AS (
SELECT
category,
product,
SUM(amount) AS total_amount
FROM orders
GROUP BY category, product
),
ranked_products AS (
SELECT
category,
product,
total_amount,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY total_amount DESC, product
) AS row_num
FROM product_revenue
)
SELECT
category,
product,
total_amount
FROM ranked_products
WHERE row_num <= 3
ORDER BY category, row_num;
Что происходит по шагам:
- В
product_revenue мы считаем сумму заказов по каждой паре category и product.
- В
ranked_products нумеруем продукты внутри каждой категории по убыванию выручки.
- Во внешнем запросе оставляем только строки, где
row_num <= 3.
- В конце сортируем результат по категории и номеру строки.
Почему нельзя написать WHERE row_num <= 3 в том же запросе, где создаётся row_num?
Потому что WHERE выполняется раньше, чем оконные функции. На момент фильтрации столбца row_num ещё нет. Поэтому обычно используют CTE или подзапрос.
Вариант с RANK: если нужно учитывать ничьи
Если два продукта делят одно место по выручке, ROW_NUMBER() всё равно выберет порядок между ними.
А если бизнес говорит: «Покажи всех, кто реально попал в топ-3 мест», лучше использовать RANK().
WITH product_revenue AS (
SELECT
category,
product,
SUM(amount) AS total_amount
FROM orders
GROUP BY category, product
),
ranked_products AS (
SELECT
category,
product,
total_amount,
RANK() OVER (
PARTITION BY category
ORDER BY total_amount DESC
) AS rank_num
FROM product_revenue
)
SELECT
category,
product,
total_amount
FROM ranked_products
WHERE rank_num <= 3
ORDER BY category, rank_num, product;
Так в результат могут попасть больше трёх строк на категорию, если есть одинаковые суммы.
Это не ошибка. Это именно смысл ранга.
LAG: взять значение из предыдущей строки
LAG() позволяет посмотреть назад — взять значение из предыдущей строки внутри окна.
Например, есть дневная выручка. Нужно рядом с каждым днём показать выручку предыдущего дня.
WITH daily_revenue AS (
SELECT
created_at AS day,
SUM(amount) AS revenue
FROM orders
GROUP BY created_at
)
SELECT
day,
revenue,
LAG(revenue) OVER (
ORDER BY day
) AS prev_revenue
FROM daily_revenue
ORDER BY day;
Результат:
day | revenue | prev_revenue
-----------+---------+-------------
2026-06-01 | 1000.00 | null
2026-06-02 | 1200.00 | 1000.00
2026-06-03 | 900.00 | 1200.00
У первой строки prev_revenue равен NULL, потому что предыдущей строки нет.
LAG() часто используют для сравнений:
- сегодня против вчера;
- этот месяц против прошлого;
- текущая покупка против предыдущей;
- текущий статус против прошлого;
- текущая цена против предыдущей.
LEAD: взять значение из следующей строки
LEAD() работает в другую сторону: берёт значение из следующей строки.
WITH daily_revenue AS (
SELECT
created_at AS day,
SUM(amount) AS revenue
FROM orders
GROUP BY created_at
)
SELECT
day,
revenue,
LEAD(revenue) OVER (
ORDER BY day
) AS next_revenue
FROM daily_revenue
ORDER BY day;
Результат:
day | revenue | next_revenue
-----------+---------+-------------
2026-06-01 | 1000.00 | 1200.00
2026-06-02 | 1200.00 | 900.00
2026-06-03 | 900.00 | null
У последней строки next_revenue будет NULL, потому что следующей строки нет.
На практике LAG() встречается чаще, потому что аналитика обычно сравнивает текущее значение с прошлым. Но LEAD() полезен, когда нужно посмотреть на следующее событие: следующий статус, следующий визит, следующую покупку.
Кейс: прирост выручки день ко дню
Теперь соберём классический аналитический запрос: процентный прирост выручки относительно предыдущего дня.
Сначала считаем дневную выручку:
WITH daily_revenue AS (
SELECT
created_at AS day,
SUM(amount) AS revenue
FROM orders
GROUP BY created_at
)
SELECT
day,
revenue
FROM daily_revenue
ORDER BY day;
Теперь добавим выручку предыдущего дня через LAG():
WITH daily_revenue AS (
SELECT
created_at AS day,
SUM(amount) AS revenue
FROM orders
GROUP BY created_at
)
SELECT
day,
revenue,
LAG(revenue) OVER (
ORDER BY day
) AS prev_revenue
FROM daily_revenue
ORDER BY day;
Теперь посчитаем процент роста:
WITH daily_revenue AS (
SELECT
created_at AS day,
SUM(amount) AS revenue
FROM orders
GROUP BY created_at
),
daily_with_prev AS (
SELECT
day,
revenue,
LAG(revenue) OVER (
ORDER BY day
) AS prev_revenue
FROM daily_revenue
)
SELECT
day,
revenue,
prev_revenue,
ROUND(
100.0 * (revenue - prev_revenue) / NULLIF(prev_revenue, 0),
2
) AS growth_pct
FROM daily_with_prev
ORDER BY day;
Формула такая:
growth_pct = 100 * (current_value - previous_value) / previous_value
Зачем нужен NULLIF(prev_revenue, 0)?
Чтобы не делить на ноль. Если в предыдущий день выручка была 0, выражение вернёт NULL, а не ошибку.
Этот запрос уже похож на настоящую аналитику из дашборда: текущая выручка, прошлое значение и процент изменения.
LAG с шагом и значением по умолчанию
У LAG() есть дополнительные аргументы.
LAG(value, offset, default_value)
Например:
SELECT
id,
created_at,
amount,
LAG(amount, 1, 0) OVER (
ORDER BY created_at, id
) AS prev_amount
FROM orders;
Здесь:
amount — какое значение берём из прошлой строки;
1 — на сколько строк назад смотрим;
0 — что вернуть, если прошлой строки нет.
Можно смотреть не на одну строку назад, а на две:
SELECT
id,
created_at,
amount,
LAG(amount, 2) OVER (
ORDER BY created_at, id
) AS amount_two_rows_before
FROM orders;
Это полезно, если нужно сравнить значение не с предыдущим событием, а с более ранним.
Накопительная сумма
Накопительная сумма, или running total, показывает итог от начала периода до текущей строки.
Например, посчитаем дневную выручку и накопительный итог:
WITH daily_revenue AS (
SELECT
created_at AS day,
SUM(amount) AS revenue
FROM orders
GROUP BY created_at
)
SELECT
day,
revenue,
SUM(revenue) OVER (
ORDER BY day
) AS cumulative_revenue
FROM daily_revenue
ORDER BY day;
Результат:
day | revenue | cumulative_revenue
-----------+---------+-------------------
2026-06-01 | 1000.00 | 1000.00
2026-06-02 | 1200.00 | 2200.00
2026-06-03 | 900.00 | 3100.00
Каждая строка показывает не только выручку за день, но и сумму с начала истории до этого дня.
Это часто используют для:
- накопительной выручки;
- накопительного числа пользователей;
- прогресса к месячному плану;
- баланса после каждой операции.
Month-to-date: накопление внутри месяца
Если нужно считать накопительный итог заново с начала каждого месяца, добавляем PARTITION BY.
WITH daily_revenue AS (
SELECT
created_at AS day,
SUM(amount) AS revenue
FROM orders
GROUP BY created_at
)
SELECT
day,
revenue,
SUM(revenue) OVER (
PARTITION BY DATE_TRUNC('month', day)
ORDER BY day
) AS month_to_date_revenue
FROM daily_revenue
ORDER BY day;
Теперь окно делится по месяцам.
В начале каждого месяца накопительная сумма начинается заново.
Прочитать можно так:
«Для каждого дня посчитай сумму выручки с начала его месяца до этого дня».
Скользящее среднее за 7 дней
Скользящее среднее помогает сгладить скачки.
Например, в выходные заказов может быть меньше, в понедельник больше, а нам хочется увидеть спокойный тренд без резких зубцов.
Для этого используем frame:
WITH daily_revenue AS (
SELECT
created_at AS day,
SUM(amount) AS revenue
FROM orders
GROUP BY created_at
)
SELECT
day,
revenue,
AVG(revenue) OVER (
ORDER BY day
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS revenue_7d_avg
FROM daily_revenue
ORDER BY day;
Фраза:
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
означает:
«Возьми текущую строку и 6 строк перед ней».
Итого получается окно максимум из 7 строк.
В первые дни истории строк будет меньше. Например, на третий день среднее посчитается только по трём доступным дням. Это нормальное поведение.
ROWS и RANGE: простое предупреждение
В оконных функциях можно встретить разные типы рамок, например ROWS и RANGE.
Для новичка важно запомнить простое правило: если вы хотите считать ровно по количеству строк, используйте ROWS.
Например:
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
Это значит именно 6 предыдущих строк плюс текущая.
RANGE работает не с количеством строк, а с диапазоном значений в ORDER BY. Это полезно в некоторых задачах, но для первых запросов часто только добавляет путаницу.
Для скользящего среднего «7 строк» используйте ROWS.
Оконные функции и WHERE
Оконную функцию нельзя использовать прямо в WHERE того же уровня запроса.
Такой запрос не сработает:
SELECT
id,
category,
amount,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY amount DESC
) AS row_num
FROM orders
WHERE row_num <= 3;
Проблема в порядке выполнения SQL: WHERE отрабатывает раньше, чем создаётся row_num.
Правильный вариант — использовать CTE или подзапрос:
WITH ranked_orders AS (
SELECT
id,
category,
amount,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY amount DESC, id
) AS row_num
FROM orders
)
SELECT
id,
category,
amount
FROM ranked_orders
WHERE row_num <= 3;
Это важный шаблон. Он постоянно встречается в задачах на топ-N, ранжирование и фильтрацию по оконным вычислениям.
Несколько оконных функций в одном запросе
В одном запросе можно использовать несколько оконных функций сразу.
Например, покажем заказ, средний чек по категории, место заказа внутри категории и предыдущую сумму заказа:
SELECT
id,
category,
product,
amount,
AVG(amount) OVER (
PARTITION BY category
) AS category_avg_amount,
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY amount DESC, id
) AS category_row_num,
LAG(amount) OVER (
PARTITION BY category
ORDER BY created_at, id
) AS prev_amount_in_category
FROM orders;
Здесь у каждой функции своё окно:
- среднее считается по категории;
- номер считается по категории и сумме;
- предыдущее значение ищется по категории и дате.
Оконные функции можно комбинировать, но важно не терять смысл: у каждой функции должен быть понятный PARTITION BY и понятный ORDER BY.
Именованные окна
Если несколько функций используют одинаковое окно, его можно вынести в WINDOW.
SELECT
id,
category,
product,
amount,
AVG(amount) OVER category_window AS category_avg_amount,
SUM(amount) OVER category_window AS category_sum_amount
FROM orders
WINDOW category_window AS (
PARTITION BY category
);
Это делает запрос короче и аккуратнее.
Но в учебных задачах чаще пишут окно прямо внутри OVER, чтобы сразу видеть логику рядом с функцией.
Частые ошибки новичков
Думать, что PARTITION BY схлопывает строки
PARTITION BY не схлопывает строки. Он только говорит оконной функции, внутри какой группы считать.
Схлопывает строки GROUP BY.
Добавить ORDER BY в OVER и получить накопительный итог
Если написать:
SUM(amount) OVER (
ORDER BY created_at
)
это будет накопительная сумма, а не сумма по всей таблице в каждой строке.
Для суммы по всей таблице нужен вариант без ORDER BY:
SUM(amount) OVER ()
Использовать ROW_NUMBER без стабильной сортировки
Если в ORDER BY есть одинаковые значения, результат может быть нестабильным.
Лучше добавлять уникальный столбец:
ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY amount DESC, id
)
Пытаться фильтровать оконную функцию в WHERE
Нельзя так:
WHERE row_num <= 3
на том же уровне, где row_num создаётся.
Нужно использовать CTE или подзапрос.
Путать RANK и ROW_NUMBER
ROW_NUMBER() даёт ровно разные номера.
RANK() учитывает ничьи.
Если в задаче сказано «ровно три строки», обычно нужен ROW_NUMBER().
Если сказано «все, кто вошёл в топ-3 мест», может понадобиться RANK().
Мини-шпаргалка по оконным функциям
| Задача |
Что использовать |
| Пронумеровать строки |
ROW_NUMBER() |
| Построить рейтинг с пропусками |
RANK() |
| Построить рейтинг без пропусков |
DENSE_RANK() |
| Взять предыдущее значение |
LAG() |
| Взять следующее значение |
LEAD() |
| Посчитать среднее без схлопывания строк |
AVG(...) OVER (...) |
| Посчитать накопительную сумму |
SUM(...) OVER (ORDER BY ...) |
| Посчитать скользящее среднее |
AVG(...) OVER (ORDER BY ... ROWS BETWEEN ... ) |
| Найти топ-N в каждой группе |
ROW_NUMBER() или RANK() плюс CTE |
Главное из статьи
Оконные функции позволяют считать значения по группе или по соседним строкам, не схлопывая результат.
Базовая форма:
function_name(...) OVER (
PARTITION BY group_column
ORDER BY sort_column
)
Главные элементы:
PARTITION BY делит строки на группы внутри оконной функции;
ORDER BY задаёт порядок строк внутри окна;
- frame уточняет, какие строки вокруг текущей участвуют в расчёте.
Главные функции:
ROW_NUMBER() — уникальный номер строки;
RANK() — рейтинг с одинаковыми местами и пропусками;
DENSE_RANK() — рейтинг с одинаковыми местами без пропусков;
LAG() — значение из предыдущей строки;
LEAD() — значение из следующей строки.
Самые частые практические задачи:
- топ-3 продукта в каждой категории;
- сравнение выручки день ко дню;
- накопительная сумма;
- скользящее среднее;
- среднее по группе рядом с каждой строкой.
Если говорить совсем просто: GROUP BY превращает много строк в одну, а оконные функции оставляют строки на месте и добавляют к ним умные расчёты. Именно поэтому они так полезны в аналитике, отчётах и задачах на собеседованиях.
Оконные функции — это один из самых сильных инструментов SQL после
JOINиGROUP BY.Они нужны там, где хочется посчитать что-то «по соседним строкам» или «внутри группы», но не потерять исходные строки.
Например:
Без оконных функций такие задачи быстро превращаются в тяжёлые подзапросы, самоджойны и головную боль. С оконными функциями они становятся аккуратными и читаемыми.
Таблица для примеров
Будем использовать простую таблицу заказов:
CREATE TABLE orders ( id INT PRIMARY KEY, category VARCHAR(50), product VARCHAR(100), amount NUMERIC(10, 2), created_at DATE );В ней есть:
id— идентификатор заказа;category— категория товара;product— название продукта;amount— сумма заказа;created_at— дата заказа.На такой таблице удобно разбирать почти все базовые сценарии оконных функций.
Главная идея оконных функций
Оконная функция считает значение не по всей таблице сразу, а по некоторому «окну» строк.
Окно — это набор строк, которые связаны с текущей строкой.
Например:
Самое важное: оконные функции не схлопывают строки.
Они оставляют каждую строку на месте, но добавляют рядом новое вычисленное значение.
GROUP BY схлопывает строки, оконные функции — нет
Это главное отличие.
Когда вы используете
GROUP BY, SQL собирает много строк в одну.Например:
SELECT category, AVG(amount) AS avg_amount FROM orders GROUP BY category;Если в таблице было 1000 заказов и 5 категорий, после запроса останется 5 строк: по одной строке на категорию.
Это удобно, когда нужен итоговый отчёт. Но иногда хочется видеть каждый заказ и рядом среднее значение по его категории.
С
GROUP BYтак просто не получится, потому что детализация уже потеряна.А оконная функция позволяет сделать именно это:
SELECT id, category, product, amount, AVG(amount) OVER (PARTITION BY category) AS category_avg_amount FROM orders;Результат будет примерно таким:
Каждый заказ остался отдельной строкой. Но рядом появилась средняя сумма по его категории.
Вот в этом и магия оконных функций: они считают агрегат, но не уничтожают детализацию.
Как читать OVER
Оконная функция почти всегда выглядит так:
function_name(...) OVER (...)Например:
AVG(amount) OVER (PARTITION BY category)Читать можно так:
«Посчитай среднее значение
amountпо окну, где строки разделены поcategory».Часть
OVER (...)говорит SQL, какие строки считать соседями текущей строки.Внутри
OVERчаще всего встречаются три настройки:PARTITION BY;ORDER BY;Разберём их по очереди.
PARTITION BY: разделить окно на группы
PARTITION BYделит строки на независимые группы внутри оконной функции.Похоже на
GROUP BY, но есть важное отличие: строки не схлопываются.Среднее по всей таблице:
SELECT id, category, amount, AVG(amount) OVER () AS avg_amount_all FROM orders;Среднее отдельно по каждой категории:
SELECT id, category, amount, AVG(amount) OVER (PARTITION BY category) AS avg_amount_by_category FROM orders;Если написать
OVER (), окно — вся таблица.Если написать
OVER (PARTITION BY category), для каждой строки окном будут только строки с той же категорией.Можно думать так:
GROUP BY categoryоставляет одну строку на категорию;PARTITION BY categoryсчитает внутри категории, но сохраняет все строки.ORDER BY внутри OVER: порядок строк в окне
ORDER BYвнутриOVERзадаёт порядок строк для оконной функции.Он нужен, когда важна позиция:
Например, пронумеруем заказы внутри каждой категории по убыванию суммы:
SELECT id, category, product, amount, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY amount DESC ) AS row_num FROM orders;Для каждой категории SQL отдельно отсортирует заказы по
amount DESCи проставит номера: 1, 2, 3 и так далее.Важно:
ORDER BYвнутриOVERи обычныйORDER BYв конце запроса — это разные вещи.ORDER BYвнутриOVERнужен для расчёта оконной функции.Обычный
ORDER BYв конце нужен для сортировки итогового результата на экране.SELECT id, category, amount, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY amount DESC ) AS row_num FROM orders ORDER BY category, row_num;Внутренний порядок считает номера, внешний порядок красиво выводит результат.
Frame: какие строки внутри окна участвуют в расчёте
Frame, или рамка окна, отвечает на вопрос: какие именно строки внутри отсортированного окна участвуют в расчёте для текущей строки.
Звучит сложнее, чем есть на самом деле.
Например, накопительная сумма:
SELECT created_at, amount, SUM(amount) OVER ( ORDER BY created_at ) AS running_amount FROM orders ORDER BY created_at;Когда в оконном агрегате есть
ORDER BY, сумма считается не по всей таблице сразу, а нарастающим итогом: от начала окна до текущей строки.Пример:
Это частая ловушка новичков.
Вы могли добавить
ORDER BYвнутриOVER, чтобы «просто упорядочить», а агрегат внезапно стал накопительным.Если вам нужно среднее по всей категории, порядок не нужен:
AVG(amount) OVER (PARTITION BY category)Если нужен накопительный итог внутри категории, порядок нужен:
SUM(amount) OVER ( PARTITION BY category ORDER BY created_at )ROW_NUMBER: уникальный номер строки
ROW_NUMBER()присваивает каждой строке уникальный номер внутри окна.SELECT id, category, product, amount, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY amount DESC ) AS row_num FROM orders;Если в категории несколько заказов, самый дорогой получит
row_num = 1, следующий —row_num = 2, и так далее.Даже если суммы одинаковые,
ROW_NUMBER()всё равно поставит разные номера.Например, для сумм 100, 100, 50 номера будут такими:
Но здесь есть тонкость: если в
ORDER BYесть одинаковые значения, порядок между ними может быть нестабильным. Сегодня один заказ получит номер 1, завтра другой, если база выберет другой порядок чтения.Поэтому для надёжной сортировки лучше добавлять уникальный столбец в конец
ORDER BY.SELECT id, category, product, amount, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY amount DESC, id ) AS row_num FROM orders;Теперь при равной сумме победит заказ с меньшим
id, и результат будет стабильным.RANK и DENSE_RANK: ранги с учётом одинаковых значений
Кроме
ROW_NUMBER(), есть ещё две похожие функции:RANK();DENSE_RANK().Они нужны, когда одинаковые значения должны получить одинаковое место.
Представим суммы:
Разные функции пронумеруют их так:
ROW_NUMBER()RANK()DENSE_RANK()Разница такая:
ROW_NUMBER()всегда даёт уникальный номер каждой строке.RANK()даёт одинаковый ранг одинаковым значениям, но оставляет разрыв. Если два заказа поделили первое место, следующий будет третьим.DENSE_RANK()тоже даёт одинаковый ранг одинаковым значениям, но без разрыва. Если два заказа поделили первое место, следующий будет вторым.Пример:
SELECT id, category, product, amount, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY amount DESC, id ) AS row_num, RANK() OVER ( PARTITION BY category ORDER BY amount DESC ) AS rank_num, DENSE_RANK() OVER ( PARTITION BY category ORDER BY amount DESC ) AS dense_rank_num FROM orders;Как выбрать между ROW_NUMBER, RANK и DENSE_RANK
Выбор зависит от бизнес-смысла.
Если нужно строго 3 строки на категорию — берите
ROW_NUMBER().Например: «покажи ровно 3 продукта в каждой категории».
Если нужно показать всех, кто делит место в рейтинге, — берите
RANK()илиDENSE_RANK().Например: «покажи всех продуктов, которые попали в топ-3 мест, включая ничьи».
Разница особенно важна на собеседованиях. В задаче «топ-3 в каждой группе» всегда уточняйте: нужно ровно 3 строки или все участники, которые делят третье место.
Кейс: топ-3 продукта в каждой категории
Это одна из самых популярных задач на оконные функции.
Нужно найти три продукта с максимальной выручкой в каждой категории.
Сначала посчитаем выручку по продуктам, а потом пронумеруем продукты внутри каждой категории.
WITH product_revenue AS ( SELECT category, product, SUM(amount) AS total_amount FROM orders GROUP BY category, product ), ranked_products AS ( SELECT category, product, total_amount, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY total_amount DESC, product ) AS row_num FROM product_revenue ) SELECT category, product, total_amount FROM ranked_products WHERE row_num <= 3 ORDER BY category, row_num;Что происходит по шагам:
product_revenueмы считаем сумму заказов по каждой пареcategoryиproduct.ranked_productsнумеруем продукты внутри каждой категории по убыванию выручки.row_num <= 3.Почему нельзя написать
WHERE row_num <= 3в том же запросе, где создаётсяrow_num?Потому что
WHEREвыполняется раньше, чем оконные функции. На момент фильтрации столбцаrow_numещё нет. Поэтому обычно используют CTE или подзапрос.Вариант с RANK: если нужно учитывать ничьи
Если два продукта делят одно место по выручке,
ROW_NUMBER()всё равно выберет порядок между ними.А если бизнес говорит: «Покажи всех, кто реально попал в топ-3 мест», лучше использовать
RANK().WITH product_revenue AS ( SELECT category, product, SUM(amount) AS total_amount FROM orders GROUP BY category, product ), ranked_products AS ( SELECT category, product, total_amount, RANK() OVER ( PARTITION BY category ORDER BY total_amount DESC ) AS rank_num FROM product_revenue ) SELECT category, product, total_amount FROM ranked_products WHERE rank_num <= 3 ORDER BY category, rank_num, product;Так в результат могут попасть больше трёх строк на категорию, если есть одинаковые суммы.
Это не ошибка. Это именно смысл ранга.
LAG: взять значение из предыдущей строки
LAG()позволяет посмотреть назад — взять значение из предыдущей строки внутри окна.Например, есть дневная выручка. Нужно рядом с каждым днём показать выручку предыдущего дня.
WITH daily_revenue AS ( SELECT created_at AS day, SUM(amount) AS revenue FROM orders GROUP BY created_at ) SELECT day, revenue, LAG(revenue) OVER ( ORDER BY day ) AS prev_revenue FROM daily_revenue ORDER BY day;Результат:
У первой строки
prev_revenueравенNULL, потому что предыдущей строки нет.LAG()часто используют для сравнений:LEAD: взять значение из следующей строки
LEAD()работает в другую сторону: берёт значение из следующей строки.WITH daily_revenue AS ( SELECT created_at AS day, SUM(amount) AS revenue FROM orders GROUP BY created_at ) SELECT day, revenue, LEAD(revenue) OVER ( ORDER BY day ) AS next_revenue FROM daily_revenue ORDER BY day;Результат:
У последней строки
next_revenueбудетNULL, потому что следующей строки нет.На практике
LAG()встречается чаще, потому что аналитика обычно сравнивает текущее значение с прошлым. НоLEAD()полезен, когда нужно посмотреть на следующее событие: следующий статус, следующий визит, следующую покупку.Кейс: прирост выручки день ко дню
Теперь соберём классический аналитический запрос: процентный прирост выручки относительно предыдущего дня.
Сначала считаем дневную выручку:
WITH daily_revenue AS ( SELECT created_at AS day, SUM(amount) AS revenue FROM orders GROUP BY created_at ) SELECT day, revenue FROM daily_revenue ORDER BY day;Теперь добавим выручку предыдущего дня через
LAG():WITH daily_revenue AS ( SELECT created_at AS day, SUM(amount) AS revenue FROM orders GROUP BY created_at ) SELECT day, revenue, LAG(revenue) OVER ( ORDER BY day ) AS prev_revenue FROM daily_revenue ORDER BY day;Теперь посчитаем процент роста:
WITH daily_revenue AS ( SELECT created_at AS day, SUM(amount) AS revenue FROM orders GROUP BY created_at ), daily_with_prev AS ( SELECT day, revenue, LAG(revenue) OVER ( ORDER BY day ) AS prev_revenue FROM daily_revenue ) SELECT day, revenue, prev_revenue, ROUND( 100.0 * (revenue - prev_revenue) / NULLIF(prev_revenue, 0), 2 ) AS growth_pct FROM daily_with_prev ORDER BY day;Формула такая:
Зачем нужен
NULLIF(prev_revenue, 0)?Чтобы не делить на ноль. Если в предыдущий день выручка была 0, выражение вернёт
NULL, а не ошибку.Этот запрос уже похож на настоящую аналитику из дашборда: текущая выручка, прошлое значение и процент изменения.
LAG с шагом и значением по умолчанию
У
LAG()есть дополнительные аргументы.LAG(value, offset, default_value)Например:
SELECT id, created_at, amount, LAG(amount, 1, 0) OVER ( ORDER BY created_at, id ) AS prev_amount FROM orders;Здесь:
amount— какое значение берём из прошлой строки;1— на сколько строк назад смотрим;0— что вернуть, если прошлой строки нет.Можно смотреть не на одну строку назад, а на две:
SELECT id, created_at, amount, LAG(amount, 2) OVER ( ORDER BY created_at, id ) AS amount_two_rows_before FROM orders;Это полезно, если нужно сравнить значение не с предыдущим событием, а с более ранним.
Накопительная сумма
Накопительная сумма, или running total, показывает итог от начала периода до текущей строки.
Например, посчитаем дневную выручку и накопительный итог:
WITH daily_revenue AS ( SELECT created_at AS day, SUM(amount) AS revenue FROM orders GROUP BY created_at ) SELECT day, revenue, SUM(revenue) OVER ( ORDER BY day ) AS cumulative_revenue FROM daily_revenue ORDER BY day;Результат:
Каждая строка показывает не только выручку за день, но и сумму с начала истории до этого дня.
Это часто используют для:
Month-to-date: накопление внутри месяца
Если нужно считать накопительный итог заново с начала каждого месяца, добавляем
PARTITION BY.WITH daily_revenue AS ( SELECT created_at AS day, SUM(amount) AS revenue FROM orders GROUP BY created_at ) SELECT day, revenue, SUM(revenue) OVER ( PARTITION BY DATE_TRUNC('month', day) ORDER BY day ) AS month_to_date_revenue FROM daily_revenue ORDER BY day;Теперь окно делится по месяцам.
В начале каждого месяца накопительная сумма начинается заново.
Прочитать можно так:
«Для каждого дня посчитай сумму выручки с начала его месяца до этого дня».
Скользящее среднее за 7 дней
Скользящее среднее помогает сгладить скачки.
Например, в выходные заказов может быть меньше, в понедельник больше, а нам хочется увидеть спокойный тренд без резких зубцов.
Для этого используем frame:
WITH daily_revenue AS ( SELECT created_at AS day, SUM(amount) AS revenue FROM orders GROUP BY created_at ) SELECT day, revenue, AVG(revenue) OVER ( ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW ) AS revenue_7d_avg FROM daily_revenue ORDER BY day;Фраза:
ROWS BETWEEN 6 PRECEDING AND CURRENT ROWозначает:
«Возьми текущую строку и 6 строк перед ней».
Итого получается окно максимум из 7 строк.
В первые дни истории строк будет меньше. Например, на третий день среднее посчитается только по трём доступным дням. Это нормальное поведение.
ROWS и RANGE: простое предупреждение
В оконных функциях можно встретить разные типы рамок, например
ROWSиRANGE.Для новичка важно запомнить простое правило: если вы хотите считать ровно по количеству строк, используйте
ROWS.Например:
ROWS BETWEEN 6 PRECEDING AND CURRENT ROWЭто значит именно 6 предыдущих строк плюс текущая.
RANGEработает не с количеством строк, а с диапазоном значений вORDER BY. Это полезно в некоторых задачах, но для первых запросов часто только добавляет путаницу.Для скользящего среднего «7 строк» используйте
ROWS.Оконные функции и WHERE
Оконную функцию нельзя использовать прямо в
WHEREтого же уровня запроса.Такой запрос не сработает:
SELECT id, category, amount, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY amount DESC ) AS row_num FROM orders WHERE row_num <= 3;Проблема в порядке выполнения SQL:
WHEREотрабатывает раньше, чем создаётсяrow_num.Правильный вариант — использовать CTE или подзапрос:
WITH ranked_orders AS ( SELECT id, category, amount, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY amount DESC, id ) AS row_num FROM orders ) SELECT id, category, amount FROM ranked_orders WHERE row_num <= 3;Это важный шаблон. Он постоянно встречается в задачах на топ-N, ранжирование и фильтрацию по оконным вычислениям.
Несколько оконных функций в одном запросе
В одном запросе можно использовать несколько оконных функций сразу.
Например, покажем заказ, средний чек по категории, место заказа внутри категории и предыдущую сумму заказа:
SELECT id, category, product, amount, AVG(amount) OVER ( PARTITION BY category ) AS category_avg_amount, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY amount DESC, id ) AS category_row_num, LAG(amount) OVER ( PARTITION BY category ORDER BY created_at, id ) AS prev_amount_in_category FROM orders;Здесь у каждой функции своё окно:
Оконные функции можно комбинировать, но важно не терять смысл: у каждой функции должен быть понятный
PARTITION BYи понятныйORDER BY.Именованные окна
Если несколько функций используют одинаковое окно, его можно вынести в
WINDOW.SELECT id, category, product, amount, AVG(amount) OVER category_window AS category_avg_amount, SUM(amount) OVER category_window AS category_sum_amount FROM orders WINDOW category_window AS ( PARTITION BY category );Это делает запрос короче и аккуратнее.
Но в учебных задачах чаще пишут окно прямо внутри
OVER, чтобы сразу видеть логику рядом с функцией.Частые ошибки новичков
Думать, что PARTITION BY схлопывает строки
PARTITION BYне схлопывает строки. Он только говорит оконной функции, внутри какой группы считать.Схлопывает строки
GROUP BY.Добавить ORDER BY в OVER и получить накопительный итог
Если написать:
SUM(amount) OVER ( ORDER BY created_at )это будет накопительная сумма, а не сумма по всей таблице в каждой строке.
Для суммы по всей таблице нужен вариант без
ORDER BY:SUM(amount) OVER ()Использовать ROW_NUMBER без стабильной сортировки
Если в
ORDER BYесть одинаковые значения, результат может быть нестабильным.Лучше добавлять уникальный столбец:
ROW_NUMBER() OVER ( PARTITION BY category ORDER BY amount DESC, id )Пытаться фильтровать оконную функцию в WHERE
Нельзя так:
WHERE row_num <= 3на том же уровне, где
row_numсоздаётся.Нужно использовать CTE или подзапрос.
Путать RANK и ROW_NUMBER
ROW_NUMBER()даёт ровно разные номера.RANK()учитывает ничьи.Если в задаче сказано «ровно три строки», обычно нужен
ROW_NUMBER().Если сказано «все, кто вошёл в топ-3 мест», может понадобиться
RANK().Мини-шпаргалка по оконным функциям
ROW_NUMBER()RANK()DENSE_RANK()LAG()LEAD()AVG(...) OVER (...)SUM(...) OVER (ORDER BY ...)AVG(...) OVER (ORDER BY ... ROWS BETWEEN ... )ROW_NUMBER()илиRANK()плюс CTEГлавное из статьи
Оконные функции позволяют считать значения по группе или по соседним строкам, не схлопывая результат.
Базовая форма:
function_name(...) OVER ( PARTITION BY group_column ORDER BY sort_column )Главные элементы:
PARTITION BYделит строки на группы внутри оконной функции;ORDER BYзадаёт порядок строк внутри окна;Главные функции:
ROW_NUMBER()— уникальный номер строки;RANK()— рейтинг с одинаковыми местами и пропусками;DENSE_RANK()— рейтинг с одинаковыми местами без пропусков;LAG()— значение из предыдущей строки;LEAD()— значение из следующей строки.Самые частые практические задачи:
Если говорить совсем просто:
GROUP BYпревращает много строк в одну, а оконные функции оставляют строки на месте и добавляют к ним умные расчёты. Именно поэтому они так полезны в аналитике, отчётах и задачах на собеседованиях.