Оконная функция SQL — это функция, которая считает значение по набору связанных строк (окну) и записывает результат в каждую строку, сохраняя их все в выдаче. Окно задаёт конструкция OVER: PARTITION BY делит строки на группы, ORDER BY задаёт порядок, ROWS или RANGE ограничивают рамку. Так одним запросом считают нарастающий итог, ранги, долю в группе и рост к прошлому месяцу.
Главное за 30 секунд
Оконная функция считает значение по строкам окна и возвращает его рядом с исходными колонками. Число строк в результате не меняется.
Общий синтаксис:
функция(аргументы) OVER (
PARTITION BY колонка_группы
ORDER BY колонка_порядка
ROWS BETWEEN начало AND конец
)Порядок выполнения запроса: FROM, WHERE, GROUP BY, HAVING, потом оконные функции, потом ORDER BY и LIMIT. Если одно окно повторяется, его объявляют один раз через WINDOW w AS (...) и пишут OVER w.
Все примеры ниже идут на учебной таблице sales: 10 продаж за январь–апрель 2026 года, четыре клиента, четыре менеджера, два региона.
SELECT * FROM sales ORDER BY id| id | sale_date | client | manager | region | amount |
|---|---|---|---|---|---|
| 1 | 2026-01-14 | Альфа | Иванова | Москва | 120000 |
| 2 | 2026-01-22 | Бета | Петров | СПб | 80000 |
| 3 | 2026-02-03 | Гамма | Смирнов | Москва | 95000 |
| 4 | 2026-02-17 | Альфа | Иванова | Москва | 60000 |
| 5 | 2026-03-02 | Бета | Козлова | СПб | 150000 |
| 6 | 2026-03-11 | Дельта | Смирнов | Москва | 60000 |
| 7 | 2026-03-25 | Гамма | Петров | СПб | 40000 |
| 8 | 2026-04-08 | Альфа | Смирнов | Москва | 110000 |
| 9 | 2026-04-19 | Дельта | Козлова | СПб | 90000 |
| 10 | 2026-04-28 | Бета | Петров | СПб | 55000 |
Каждый запрос и результат мы прогнали в SQLite 3.51 и DuckDB 1.4: выдача совпала. Запросы с DATE_TRUNC выполнятся и в PostgreSQL; для SQLite мы заменили его на strftime('%Y-%m-01', sale_date).
GROUP BY возвращает одну строку на группу: Москва 445 000, СПб 415 000. Оконная функция добавляет тот же итог к каждой исходной строке.
SELECT id, region, amount,
SUM(amount) OVER (PARTITION BY region) AS region_total
FROM sales
ORDER BY region, id| id | region | amount | region_total |
|---|---|---|---|
| 1 | Москва | 120000 | 445000 |
| 3 | Москва | 95000 | 445000 |
| 4 | Москва | 60000 | 445000 |
| 6 | Москва | 60000 | 445000 |
| 8 | Москва | 110000 | 445000 |
| 2 | СПб | 80000 | 415000 |
| 5 | СПб | 150000 | 415000 |
| 7 | СПб | 40000 | 415000 |
| 9 | СПб | 90000 | 415000 |
| 10 | СПб | 55000 | 415000 |
| Параметр | GROUP BY | Оконная функция |
|---|---|---|
| Строк в результате | По одной на группу | Столько же, сколько на входе |
| Доступ к исходным колонкам | Только колонки группировки и агрегаты | Любые колонки строки |
| Порядок внутри группы | Не учитывается | ORDER BY внутри OVER |
| Ранги, LAG, LEAD | Нет | Есть |
| Фильтр по результату | HAVING | Внешний запрос, CTE или QUALIFY |
Долю строки в группе через GROUP BY считают в два шага: агрегат в подзапросе и JOIN к таблице. Окно делает это в одном SELECT. Такие расчёты встречаются в задачах по юнит-экономике и при разборе финансовых KPI.
Функции делят на три группы: ранжирующие, агрегатные в окне и функции смещения.
| Группа | Функция | Что возвращает |
|---|---|---|
| Ранжирующие | ROW_NUMBER() | Номер строки по порядку, без повторов |
| Ранжирующие | RANK() | Ранг; при равенстве одинаковый ранг и пропуск следующих номеров: 1, 2, 2, 4 |
| Ранжирующие | DENSE_RANK() | Ранг без пропусков: 1, 2, 2, 3 |
| Ранжирующие | NTILE(n) | Номер корзины, если разбить строки на n равных частей |
| Агрегатные | SUM, AVG, COUNT, MIN, MAX | Агрегат по рамке окна: итог группы, нарастающий итог, скользящее среднее |
| Смещения | LAG(x, n) | Значение x из строки на n позиций раньше |
| Смещения | LEAD(x, n) | Значение x из строки на n позиций позже |
| Смещения | FIRST_VALUE(x) | Первое значение в рамке |
| Смещения | LAST_VALUE(x) | Последнее значение в рамке |
| Смещения | NTH_VALUE(x, n) | n-е значение в рамке |
Разница ранжирующих функций видна на продажах 4 и 6: у обеих сумма 60 000.
SELECT id, amount,
ROW_NUMBER() OVER (ORDER BY amount DESC, id) AS row_num,
RANK() OVER (ORDER BY amount DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY amount DESC) AS dense_rnk
FROM sales
WHERE amount <= 95000
ORDER BY amount DESC, id| id | amount | row_num | rnk | dense_rnk |
|---|---|---|---|---|
| 3 | 95000 | 1 | 1 | 1 |
| 9 | 90000 | 2 | 2 | 2 |
| 2 | 80000 | 3 | 3 | 3 |
| 4 | 60000 | 4 | 4 | 4 |
| 6 | 60000 | 5 | 4 | 4 |
| 10 | 55000 | 6 | 6 | 5 |
| 7 | 40000 | 7 | 7 | 6 |
ROW_NUMBER дал продажам 4 и 6 номера 4 и 5. RANK дал обеим 4 и перешёл к 6. DENSE_RANK дал обеим 4 и перешёл к 5. Колонку id в ORDER BY добавили, чтобы порядок равных сумм был одинаковым при каждом запуске.
Под каждым запросом — его результат на таблице sales.
Сначала CTE сворачивает продажи до месяцев, потом SUM с ORDER BY копит сумму.
WITH m AS (
SELECT DATE_TRUNC('month', sale_date) AS month,
SUM(amount) AS revenue
FROM sales
GROUP BY 1
)
SELECT month, revenue,
SUM(revenue) OVER (ORDER BY month) AS running_total
FROM m
ORDER BY month| month | revenue | running_total |
|---|---|---|
| 2026-01-01 | 200000 | 200000 |
| 2026-02-01 | 155000 | 355000 |
| 2026-03-01 | 250000 | 605000 |
| 2026-04-01 | 255000 | 860000 |
Окно по клиенту без ORDER BY отдаёт итог клиента в каждую строку. Множитель 100.0 переводит деление в дробное.
SELECT client, sale_date, amount,
SUM(amount) OVER (PARTITION BY client) AS client_total,
ROUND(100.0 * amount / SUM(amount) OVER (PARTITION BY client), 1) AS share_pct
FROM sales
ORDER BY client, sale_date| client | sale_date | amount | client_total | share_pct |
|---|---|---|---|---|
| Альфа | 2026-01-14 | 120000 | 290000 | 41.4 |
| Альфа | 2026-02-17 | 60000 | 290000 | 20.7 |
| Альфа | 2026-04-08 | 110000 | 290000 | 37.9 |
| Бета | 2026-01-22 | 80000 | 285000 | 28.1 |
| Бета | 2026-03-02 | 150000 | 285000 | 52.6 |
| Бета | 2026-04-28 | 55000 | 285000 | 19.3 |
| Гамма | 2026-02-03 | 95000 | 135000 | 70.4 |
| Гамма | 2026-03-25 | 40000 | 135000 | 29.6 |
| Дельта | 2026-03-11 | 60000 | 150000 | 40.0 |
| Дельта | 2026-04-19 | 90000 | 150000 | 60.0 |
GROUP BY считает сумму менеджера, RANK раздаёт места внутри региона.
WITH t AS (
SELECT region, manager, SUM(amount) AS total
FROM sales
GROUP BY region, manager
)
SELECT region, manager, total,
RANK() OVER (PARTITION BY region ORDER BY total DESC) AS place
FROM t
ORDER BY region, place| region | manager | total | place |
|---|---|---|---|
| Москва | Смирнов | 265000 | 1 |
| Москва | Иванова | 180000 | 2 |
| СПб | Козлова | 240000 | 1 |
| СПб | Петров | 175000 | 2 |
Решите ещё 150 задач на SQL
В модуле SQL программы SF Education 27 уроков, 150 задач в тренажёре и 2 бизнес-кейса. Оконные функции, подзапросы и CTE разбирают отдельными темами, первые 48 часов доступа бесплатны.
LAG(revenue) берёт выручку прошлого месяца. У января прошлого месяца нет, поэтому там NULL.
WITH m AS (
SELECT DATE_TRUNC('month', sale_date) AS month,
SUM(amount) AS revenue
FROM sales
GROUP BY 1
)
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
/ LAG(revenue) OVER (ORDER BY month), 1) AS growth_pct
FROM m
ORDER BY month| month | revenue | prev_revenue | growth_pct |
|---|---|---|---|
| 2026-01-01 | 200000 | NULL | NULL |
| 2026-02-01 | 155000 | 200000 | -22.5 |
| 2026-03-01 | 250000 | 155000 | 61.3 |
| 2026-04-01 | 255000 | 250000 | 2.0 |
Нулевой делитель защищают через NULLIF(..., 0).
Рамка ROWS BETWEEN 2 PRECEDING AND CURRENT ROW берёт текущий месяц и два предыдущих.
WITH m AS (
SELECT DATE_TRUNC('month', sale_date) AS month,
SUM(amount) AS revenue
FROM sales
GROUP BY 1
)
SELECT month, revenue,
CAST(ROUND(AVG(revenue) OVER w, 0) AS INTEGER) AS ma_3m,
COUNT(*) OVER w AS months_in_window
FROM m
WINDOW w AS (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
ORDER BY month| month | revenue | ma_3m | months_in_window |
|---|---|---|---|
| 2026-01-01 | 200000 | 200000 | 1 |
| 2026-02-01 | 155000 | 177500 | 2 |
| 2026-03-01 | 250000 | 201667 | 3 |
| 2026-04-01 | 255000 | 220000 | 3 |
В январе и феврале в окне один и два месяца. Колонка months_in_window показывает это, неполные окна отбрасывают условием months_in_window = 3 во внешнем запросе.
ROW_NUMBER нумерует покупки клиента по дате, внешний запрос оставляет номер 1.
WITH t AS (
SELECT client, sale_date, manager, amount,
ROW_NUMBER() OVER (PARTITION BY client ORDER BY sale_date) AS rn
FROM sales
)
SELECT client, sale_date, manager, amount
FROM t
WHERE rn = 1
ORDER BY client| client | sale_date | manager | amount |
|---|---|---|---|
| Альфа | 2026-01-14 | Иванова | 120000 |
| Бета | 2026-01-22 | Петров | 80000 |
| Гамма | 2026-02-03 | Смирнов | 95000 |
| Дельта | 2026-03-11 | Смирнов | 60000 |
Если дата первой покупки нужна рядом с каждой продажей, используйте FIRST_VALUE(sale_date) OVER (PARTITION BY client ORDER BY sale_date). По ней считают когорты.
Такие задачи дают на собеседованиях, SQL входит в навыки бизнес-аналитика.
Синтаксис OVER одинаков во всех трёх СУБД. Различаются функции дат и фильтр по окну.
DATE_TRUNC('month', sale_date). Деление целого на целое отбрасывает дробную часть: 5 / 2 даёт 2.DATE_FORMAT(sale_date, '%Y-%m-01'). QUALIFY в документации MySQL 8.0 не описан, фильтр по окну пишут через CTE.Три ошибки дают неверный результат или падение запроса.
С ORDER BY рамка по умолчанию заканчивается на текущей строке. LAST_VALUE возвращает значение текущей строки.
SELECT client, sale_date, amount,
LAST_VALUE(amount) OVER (PARTITION BY client ORDER BY sale_date) AS last_amount
FROM sales
WHERE client = 'Альфа'
ORDER BY sale_date| client | sale_date | amount | last_amount |
|---|---|---|---|
| Альфа | 2026-01-14 | 120000 | 120000 |
| Альфа | 2026-02-17 | 60000 | 60000 |
| Альфа | 2026-04-08 | 110000 | 110000 |
Рамку до конца группы задают явно:
SELECT client, sale_date, amount,
LAST_VALUE(amount) OVER (
PARTITION BY client ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_amount
FROM sales
WHERE client = 'Альфа'
ORDER BY sale_date| client | sale_date | amount | last_amount |
|---|---|---|---|
| Альфа | 2026-01-14 | 120000 | 110000 |
| Альфа | 2026-02-17 | 60000 | 110000 |
| Альфа | 2026-04-08 | 110000 | 110000 |
То же правило действует для NTH_VALUE.
WHERE выполняется раньше оконных функций, поэтому такой запрос падает:
SELECT client, sale_date
FROM sales
WHERE ROW_NUMBER() OVER (PARTITION BY client ORDER BY sale_date) = 1DuckDB отвечает «Binder Error: WHERE clause cannot contain window functions!», SQLite — «misuse of window function ROW_NUMBER()». Фильтр выносят в CTE, как в задаче 6, или в QUALIFY.
Без явной рамки окно работает как RANGE: строки с одинаковым значением ORDER BY считаются вместе. ROWS считает строки по одной.
SELECT id, amount,
SUM(amount) OVER (ORDER BY amount
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS sum_rows,
SUM(amount) OVER (ORDER BY amount
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS sum_range
FROM sales
WHERE amount <= 80000
ORDER BY amount, id| id | amount | sum_rows | sum_range |
|---|---|---|---|
| 7 | 40000 | 40000 | 40000 |
| 10 | 55000 | 95000 | 95000 |
| 4 | 60000 | 155000 | 215000 |
| 6 | 60000 | 215000 | 215000 |
| 2 | 80000 | 295000 | 295000 |
У продажи 4 sum_range сразу включает и продажу 6 с той же суммой. Для построчного итога пишите ROWS и добавляйте в ORDER BY уникальную колонку.
Нельзя: WHERE выполняется до расчёта окон, и СУБД вернёт ошибку. Посчитайте окно в CTE или подзапросе и отфильтруйте во внешнем запросе. В ClickHouse и DuckDB есть QUALIFY для такого фильтра.
ROW_NUMBER нумерует строки подряд и при равенстве даёт разные номера. RANK даёт равным строкам один ранг и пропускает следующие номера: 1, 2, 2, 4. DENSE_RANK пропусков не делает: 1, 2, 2, 3.
Если в OVER есть ORDER BY, рамка — RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, от начала группы до текущей строки с её дубликатами. Без ORDER BY в рамку входит вся группа. Это описано в документации SQLite и ClickHouse.
Можно. Окно считается после группировки и работает с её результатом. Запрос SELECT region, SUM(amount), SUM(SUM(amount)) OVER () FROM sales GROUP BY region вернёт итог региона и общий итог 860 000 в каждой строке.
Да, в MySQL 8.0 и новее. В справочнике MySQL 8.0 есть раздел Window Functions с ROW_NUMBER, RANK, LAG, LEAD и рамками ROWS и RANGE. Фильтр по результату окна пишут через CTE.
Другие материалы по аналитике собраны в разделе Аналитика.
Пишите аналитические запросы на SQL без выгрузок в Excel
Модуль SQL длится 1 месяц: 13 часов теории, 21 час практики, 150 задач и разбор кейсов с интервью на позицию аналитика. Выпускники получают удостоверение со сведениями в ФРДО, доступ к курсу остаётся навсегда.