Оконные функции SQL выполняют вычисления для каждой строки по набору связанных с ней строк и при этом не схлопывают результат, как GROUP BY: строк на выходе столько же, сколько на входе. Окно задаёт оператор OVER. Внутри него PARTITION BY делит строки на группы, ORDER BY определяет порядок строк внутри окна, а ROWS или RANGE указывают границы окна относительно текущей строки.

Все запросы ниже прогнаны в SQLite 3.50.4 через модуль sqlite3 из Python 3.14.3 на Windows 10. Оконные функции появились в SQLite 3.25.0 (сентябрь 2018 года), а режим GROUPS, предложение EXCLUDE и сцепление окон добавили в 3.28.0. В более старой сборке запросы с OVER не заработают, поэтому версию проверьте запросом SELECT sqlite_version();.
- Таблица для примеров
- Чем оконные функции отличаются от GROUP BY
- Синтаксис оконных функций SQL
- PARTITION BY
- ORDER BY внутри OVER
- Рамка окна: ROWS, RANGE и GROUPS
- Именованное окно WINDOW
- Порядок выполнения
- Функции ранжирования: ROW_NUMBER, RANK и DENSE_RANK
- Топ-N в каждой группе
- LAG и LEAD: значение из предыдущей и следующей строки
- Нарастающий итог и скользящее среднее
- FIRST_VALUE и LAST_VALUE: ловушка рамки по умолчанию
- Оконные функции в PostgreSQL, MySQL и SQL Server
- Частые вопросы
- Можно ли использовать оконные функции в WHERE?
- Чем PARTITION BY отличается от GROUP BY?
- Чем ROWS отличается от RANGE?
- Можно ли использовать несколько оконных функций в одном запросе?
- Почему LAST_VALUE возвращает значение текущей строки?
Таблица для примеров
Учебная таблица продаж содержит категорию товара, дату продажи и сумму. Запустите этот блок первым, все остальные примеры работают с ней. Как устроен первичный ключ в CREATE TABLE, подробно разобрано в статье о первичном и внешнем ключе в SQL.
CREATE TABLE sales (
id INTEGER PRIMARY KEY,
category TEXT NOT NULL,
sale_date TEXT NOT NULL,
amount INTEGER NOT NULL
);
INSERT INTO sales (id, category, sale_date, amount) VALUES
(1, 'A', '2026-09-01', 500),
(2, 'B', '2026-09-01', 300),
(3, 'A', '2026-09-02', 700),
(4, 'C', '2026-09-02', 700),
(5, 'B', '2026-09-03', 300),
(6, 'A', '2026-09-03', 200),
(7, 'C', '2026-09-04', 400),
(8, 'B', '2026-09-04', 900),
(9, 'A', '2026-09-05', 700),
(10, 'C', '2026-09-05', 100);
В данных нарочно есть повторы: продажи по 700 встречаются трижды, по 300 дважды, и на каждый день приходится по две записи.
Чем оконные функции отличаются от GROUP BY
GROUP BY сворачивает группу в одну строку, и детали пропадают. Оконная функция вычисляет тот же агрегат, но приписывает его к каждой исходной строке. Сравните запросы к одной таблице, с группировкой и без неё:
SELECT category, SUM(amount) AS total
FROM sales
GROUP BY category;
SELECT id, category, amount,
COUNT(*) OVER (PARTITION BY category) AS sales_count,
SUM(amount) OVER (PARTITION BY category) AS category_total,
ROUND(100.0 * amount / SUM(amount) OVER (PARTITION BY category), 1) AS share
FROM sales
ORDER BY category, id;
SELECT category, SUM(amount) AS total,
RANK() OVER (ORDER BY SUM(amount) DESC) AS place
FROM sales
GROUP BY category
ORDER BY place;
Результат первого запроса, по строке на категорию:
| category | total |
|---|---|
| A | 2100 |
| B | 1500 |
| C | 1200 |
Второй запрос вернул все десять строк таблицы:
| id | category | amount | sales_count | category_total | share |
|---|---|---|---|---|---|
| 1 | A | 500 | 4 | 2100 | 23.8 |
| 3 | A | 700 | 4 | 2100 | 33.3 |
| 6 | A | 200 | 4 | 2100 | 9.5 |
| 9 | A | 700 | 4 | 2100 | 33.3 |
| 2 | B | 300 | 3 | 1500 | 20.0 |
| 5 | B | 300 | 3 | 1500 | 20.0 |
| 8 | B | 900 | 3 | 1500 | 60.0 |
| 4 | C | 700 | 3 | 1200 | 58.3 |
| 7 | C | 400 | 3 | 1200 | 33.3 |
| 10 | C | 100 | 3 | 1200 | 8.3 |
Третий:
| category | total | place |
|---|---|---|
| A | 2100 | 1 |
| B | 1500 | 2 |
| C | 1200 | 3 |
Суммы во втором запросе те же, что в первом, но каждая стоит рядом со своей продажей, и долю продажи в выручке категории можно получить в той же строке. Без оконной функции для этого нужно делать подзапрос с GROUP BY и соединять его с таблицей через JOIN. Третий пример показывает другое правило: обычные агрегатные функции выполняются раньше оконных функций. RANK получает уже готовые суммы по группам, поэтому агрегат можно передать оконной функции аргументом, а наоборот нельзя.

Синтаксис оконных функций SQL
Общий вид вызова, в квадратных скобках необязательные части:
функция(аргументы) OVER (
[PARTITION BY столбцы]
[ORDER BY столбцы [ASC | DESC]]
[ROWS | RANGE | GROUPS BETWEEN начало AND конец]
)
Пустые скобки OVER () означают одно окно на весь результат запроса: SUM(amount) OVER () вернёт 4800 в каждой строке нашей таблицы, это общая сумма всех продаж.
PARTITION BY
PARTITION BY делит строки результата на группы по значениям указанных столбцов, и функция вычисляется внутри каждой группы отдельно. Без PARTITION BY весь результат запроса считается одной группой. В примере выше PARTITION BY category дал три окна: A, B и C.
ORDER BY внутри OVER
ORDER BY внутри окна задаёт, в каком порядке функция проходит строки группы. Порядок вывода он не меняет: за него отвечает ORDER BY в конце SELECT, и окно может сортировать строки иначе, чем итоговый результат. Для ROW_NUMBER, RANK, LAG и LEAD без порядка результат теряет смысл. Если не указать сортировку в окне, все строки группы становятся равноправными, и RANK с DENSE_RANK вернут 1 для каждой строки.
Рамка окна: ROWS, RANGE и GROUPS
Рамка окна (frame) выбирает из группы диапазон строк относительно текущей. ROWS считает физические строки: ROWS BETWEEN 2 PRECEDING AND CURRENT ROW берёт текущую строку и две перед ней. RANGE отбирает строки по значению столбца сортировки, и строки с равным значением попадают в рамку вместе. GROUPS считает группы равных строк. Если сортировка в окне есть, а рамка не указана, используется RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: от начала группы до текущей строки вместе со всеми строками, равными ей по сортировке. Это значение по умолчанию в SQLite, PostgreSQL и SQL Server. С ним в нарастающем итоге равные по сортировке строки получают одну сумму, а LAST_VALUE не видит строк после текущей, кроме равных ей.
Именованное окно WINDOW
Если несколько функций в одном SELECT используют одно окно, его описывают один раз в предложении WINDOW после HAVING и перед ORDER BY: WINDOW w AS (PARTITION BY category ORDER BY sale_date), а в функции пишут OVER w. Пример есть ниже, у FIRST_VALUE.
Порядок выполнения
Оконная функция видит только те строки, что остались после FROM, WHERE, GROUP BY и HAVING: строку, отсеянную условием WHERE, не увидит ни одна оконная функция. В одном запросе может быть несколько оконных функций с разными OVER, и все они работают с одним и тем же набором строк.
Функции ранжирования: ROW_NUMBER, RANK и DENSE_RANK
Основные функции ранжирования нумеруют строки в порядке ORDER BY и расходятся только на повторах. ROW_NUMBER присваивает каждой строке уникальный порядковый номер, даже если значения одинаковые. RANK даёт строкам с одинаковыми значениями одинаковый ранг и пропускает следующие номера. DENSE_RANK тоже присваивает одинаковый ранг, но без пропусков.
SELECT id, category, amount,
ROW_NUMBER() OVER (ORDER BY amount DESC, id) AS row_num,
RANK() OVER (ORDER BY amount DESC) AS rank_gaps,
DENSE_RANK() OVER (ORDER BY amount DESC) AS rank_dense
FROM sales
ORDER BY amount DESC, id;
SELECT category, id, amount, pos
FROM (
SELECT id, category, amount,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY amount DESC, id) AS pos
FROM sales
) AS ranked
WHERE pos <= 2
ORDER BY category, pos;
Второй запрос оставляет по две самые крупные продажи в каждой категории. Первый вернул такую таблицу:
| id | category | amount | row_num | rank_gaps | rank_dense |
|---|---|---|---|---|---|
| 8 | B | 900 | 1 | 1 | 1 |
| 3 | A | 700 | 2 | 2 | 2 |
| 4 | C | 700 | 3 | 2 | 2 |
| 9 | A | 700 | 4 | 2 | 2 |
| 1 | A | 500 | 5 | 5 | 3 |
| 7 | C | 400 | 6 | 6 | 4 |
| 2 | B | 300 | 7 | 7 | 5 |
| 5 | B | 300 | 8 | 7 | 5 |
| 6 | A | 200 | 9 | 9 | 6 |
| 10 | C | 100 | 10 | 10 | 7 |
Продажи по 700 получили ранг 2 у обеих функций. Следующий ранг у RANK равен 5, потому что ранг равен номеру первой строки в группе равных. DENSE_RANK ставит 3, следующий номер группы. RANK удобен для мест в рейтинге, где после трёх участников на втором месте следующий получает ранг 5. DENSE_RANK подходит, когда нужны уровни значений без пропусков.
В ROW_NUMBER мы добавили в сортировку второй столбец id. Строкам с равными суммами ROW_NUMBER раздаёт номера в неопределённом порядке, и без второго ключа любая продажа на 700 может оказаться второй. Для отчётов и постраничного вывода мы бы всегда добавляли такой ключ.
Остальные функции ранжирования в SQLite:
| Функция | Что возвращает |
|---|---|
NTILE(n) | делит группу на n частей как можно ровнее, при неравном делении первыми идут большие части; возвращает номер части для строки |
PERCENT_RANK() | (rank − 1) / (количество строк в группе − 1), число от 0.0 до 1.0; в группе из одной строки 0.0 |
CUME_DIST() | номер последней строки из группы равных, делённый на количество строк в группе |
Топ-N в каждой группе
Частая задача: две самые крупные продажи в каждой категории. Первая попытка обычно такая: WHERE ROW_NUMBER() OVER (PARTITION BY category ORDER BY amount DESC) <= 2. SQLite её не выполнит:
sqlite3.OperationalError: misuse of window function ROW_NUMBER()
Оконные функции разрешены только в списке SELECT и в ORDER BY запроса. В WHERE, GROUP BY и HAVING их использовать нельзя, потому что логически оконные функции выполняются после этих предложений. Номер строки вычисляют в подзапросе, а фильтруют снаружи, как во втором запросе из блока с ранжированием:
| category | id | amount | pos |
|---|---|---|---|
| A | 3 | 700 | 1 |
| A | 9 | 700 | 2 |
| B | 8 | 900 | 1 |
| B | 2 | 300 | 2 |
| C | 4 | 700 | 1 |
| C | 7 | 400 | 2 |
В категории B две продажи по 300, и в топ попала только одна, с меньшим id. Если вместо ROW_NUMBER поставить DENSE_RANK, условие pos <= 2 вернёт все продажи с двумя наибольшими суммами: в категории B это 900 и обе продажи по 300, в категории A две по 700 и одна на 500. Ещё одна задача на самый дорогой товар в категории разобрана в подборке задач по SQL с решениями.

LAG и LEAD: значение из предыдущей и следующей строки
LAG берёт значение из предыдущей строки окна, LEAD из следующей строки. В нашей таблице так считают разницу с прошлой продажей в той же категории, без соединения таблицы с самой собой.
SELECT category, sale_date, amount,
LAG(amount) OVER (PARTITION BY category ORDER BY sale_date) AS prev_amount,
amount - LAG(amount) OVER (PARTITION BY category ORDER BY sale_date) AS diff,
LEAD(sale_date) OVER (PARTITION BY category ORDER BY sale_date) AS next_date
FROM sales
ORDER BY category, sale_date;
| category | sale_date | amount | prev_amount | diff | next_date |
|---|---|---|---|---|---|
| A | 2026-09-01 | 500 | NULL | NULL | 2026-09-02 |
| A | 2026-09-02 | 700 | 500 | 200 | 2026-09-03 |
| A | 2026-09-03 | 200 | 700 | -500 | 2026-09-05 |
| A | 2026-09-05 | 700 | 200 | 500 | NULL |
| B | 2026-09-01 | 300 | NULL | NULL | 2026-09-03 |
| B | 2026-09-03 | 300 | 300 | 0 | 2026-09-04 |
| B | 2026-09-04 | 900 | 300 | 600 | NULL |
| C | 2026-09-02 | 700 | NULL | NULL | 2026-09-04 |
| C | 2026-09-04 | 400 | 700 | -300 | 2026-09-05 |
| C | 2026-09-05 | 100 | 400 | -300 | NULL |
У первой строки каждой категории предыдущей строки нет, и LAG вернул NULL, а вместе с ним NULL получилась и разница. У последней строки нет следующей, поэтому next_date пустой. Полная форма LAG(amount, 1, 0) принимает смещение и значение по умолчанию: с ней у первой продажи категории A вместо NULL стоит 0, и разница amount - LAG(amount, 1, 0) в этой строке равна 500. Заменять NULL нулём стоит не всегда: разница «500 минус ноль» выглядит как прирост на 500, хотя сравнивать было не с чем. Рамку окна функции LAG и LEAD не учитывают, им нужны только разбиение на группы и сортировка.

Нарастающий итог и скользящее среднее
Агрегатные функции SUM, AVG, COUNT, MIN и MAX становятся оконными, если добавить к ним OVER. С сортировкой внутри окна SUM даёт нарастающий итог, а AVG с рамкой ROWS даёт скользящее среднее.
SELECT id, sale_date, amount,
SUM(amount) OVER (ORDER BY sale_date) AS total_range,
SUM(amount) OVER (ORDER BY sale_date, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS total_rows,
ROUND(AVG(amount) OVER (ORDER BY sale_date, id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 1) AS moving_avg
FROM sales
ORDER BY sale_date, id;
| id | sale_date | amount | total_range | total_rows | moving_avg |
|---|---|---|---|---|---|
| 1 | 2026-09-01 | 500 | 800 | 500 | 500.0 |
| 2 | 2026-09-01 | 300 | 800 | 800 | 400.0 |
| 3 | 2026-09-02 | 700 | 2200 | 1500 | 500.0 |
| 4 | 2026-09-02 | 700 | 2200 | 2200 | 566.7 |
| 5 | 2026-09-03 | 300 | 2700 | 2500 | 566.7 |
| 6 | 2026-09-03 | 200 | 2700 | 2700 | 400.0 |
| 7 | 2026-09-04 | 400 | 4000 | 3100 | 300.0 |
| 8 | 2026-09-04 | 900 | 4000 | 4000 | 500.0 |
| 9 | 2026-09-05 | 700 | 4800 | 4700 | 666.7 |
| 10 | 2026-09-05 | 100 | 4800 | 4800 | 566.7 |
Столбец total_range посчитан с рамкой по умолчанию, то есть RANGE. Обе продажи 1 сентября равны по sale_date, поэтому попадают в рамку вместе, и уже в первой строке стоит 800. Это итог на конец дня, а не на момент продажи. Столбец total_rows с явным ROWS растёт по одной строке: 500, 800, 1500. Если нужен итог по строкам, а в столбце сортировки бывают повторы, пишите ROWS явно и добавляйте в ORDER BY уникальный столбец.
moving_avg усредняет текущую строку и две предыдущие, то есть три последние продажи независимо от дат. В первых двух строках предыдущих строк меньше двух, и среднее значение берётся по тем, что есть: 500.0, потом (500 + 300) / 2 = 400.0. Для среднего по календарным дням нужен RANGE со смещением по числовому значению даты: AVG(amount) OVER (ORDER BY julianday(sale_date) RANGE BETWEEN 2 PRECEDING AND CURRENT ROW) на 3 сентября даёт 450.0, среднее шести продаж с 1 по 3 сентября. В ORDER BY такого окна может быть только одно выражение. С ORDER BY sale_date, id SQLite отвечает ошибкой RANGE with offset PRECEDING/FOLLOWING requires one ORDER BY expression.

FIRST_VALUE и LAST_VALUE: ловушка рамки по умолчанию
FIRST_VALUE возвращает первое значение в рамке окна, LAST_VALUE последнее. В отличие от функций ранжирования и смещения, эти функции рамку учитывают, а рамка по умолчанию заканчивается на текущей строке. В запросе ниже окно описано один раз через WINDOW, а для третьего столбца к нему дописаны границы:
SELECT category, sale_date, amount,
FIRST_VALUE(amount) OVER w AS first_amount,
LAST_VALUE(amount) OVER w AS last_wrong,
LAST_VALUE(amount) OVER (w ROWS BETWEEN UNBOUNDED PRECEDING
AND UNBOUNDED FOLLOWING) AS last_amount
FROM sales
WINDOW w AS (PARTITION BY category ORDER BY sale_date)
ORDER BY category, sale_date;
| category | sale_date | amount | first_amount | last_wrong | last_amount |
|---|---|---|---|---|---|
| A | 2026-09-01 | 500 | 500 | 500 | 700 |
| A | 2026-09-02 | 700 | 500 | 700 | 700 |
| A | 2026-09-03 | 200 | 500 | 200 | 700 |
| A | 2026-09-05 | 700 | 500 | 700 | 700 |
| B | 2026-09-01 | 300 | 300 | 300 | 900 |
| B | 2026-09-03 | 300 | 300 | 300 | 900 |
| B | 2026-09-04 | 900 | 300 | 900 | 900 |
| C | 2026-09-02 | 700 | 700 | 700 | 100 |
| C | 2026-09-04 | 400 | 700 | 400 | 100 |
| C | 2026-09-05 | 100 | 700 | 100 | 100 |
last_wrong просто повторяет amount: внутри категории даты не повторяются, поэтому последняя строка рамки по умолчанию и есть текущая строка. Последнюю продажу в категории даёт только рамка до UNBOUNDED FOLLOWING. Запись OVER (w ROWS ...) называется сцеплением окон: новое окно берёт из w PARTITION BY и ORDER BY, а границы добавляет свои. Своего PARTITION BY у такого окна быть не может, а у базового окна w не должно быть рамки.
Оконные функции в PostgreSQL, MySQL и SQL Server
Разработчики SQLite брали за образец документацию PostgreSQL и прогоняли многие тесты оконных функций против PostgreSQL.
| СУБД | С какой версии | На что обратить внимание |
|---|---|---|
| SQLite | 3.25.0 | GROUPS, EXCLUDE, сцепление окон и RANGE со смещением с 3.28.0; DISTINCT внутри оконной функции запрещён |
| PostgreSQL | 8.4 | RANGE со смещением, GROUPS и EXCLUDE с версии 11; для RANGE со смещением в ORDER BY окна нужен ровно один столбец, для дат смещение задают интервалом, например '1 day' |
| MySQL | 8.0 | GROUPS и EXCLUDE парсер принимает, но выполняет с ошибкой; DISTINCT в агрегатных оконных функциях и вложенные оконные функции не поддерживаются |
| SQL Server | ROWS и RANGE с 2012 | RANGE нельзя использовать с числовым смещением PRECEDING или FOLLOWING; OVER нельзя применять к агрегатам с DISTINCT |
В SQLite DISTINCT в окне падает с ошибкой DISTINCT is not supported for window functions, это мы проверили на COUNT(DISTINCT category) OVER ().
На больших таблицах оконная функция сортирует строки каждой группы. В SQL Server такому запросу может помочь некластеризованный индекс, в котором сначала идут столбцы PARTITION BY, затем столбцы ORDER BY в том же порядке. В SQLite мы проверили это через EXPLAIN QUERY PLAN для SUM(amount) OVER (PARTITION BY category ORDER BY sale_date): без индекса в плане есть строка USE TEMP B-TREE FOR ORDER BY, а после CREATE INDEX idx_sales_category_date ON sales (category, sale_date) план меняется на SCAN sales USING INDEX idx_sales_category_date, и отдельной сортировки нет. Как читать план и когда индекс мешает, разобрано в статье про индексы в SQL.

Частые вопросы
Можно ли использовать оконные функции в WHERE?
Нет. Оконные функции разрешены только в списке SELECT и в ORDER BY, в WHERE, GROUP BY и HAVING их использовать нельзя. На попытку в WHERE SQLite отвечает ошибкой misuse of window function ROW_NUMBER(). Вычислите значение в подзапросе и фильтруйте во внешнем запросе, как в примере с топ-N.
Чем PARTITION BY отличается от GROUP BY?
GROUP BY оставляет одну строку на группу. PARTITION BY только делит строки на окна для вычисления, и в результате остаются все исходные строки, к каждой из которых добавлено значение по её окну.
Чем ROWS отличается от RANGE?
ROWS отсчитывает физические строки от текущей. RANGE берёт строки по значению столбца сортировки, и все строки с тем же значением попадают в рамку вместе с текущей. На нашей таблице нарастающий итог с RANGE в первой строке равен 800, с ROWS 500.
Можно ли использовать несколько оконных функций в одном запросе?
Да, и у каждой может быть своё окно: в примере с ранжированием в одном SELECT стоят ROW_NUMBER, RANK и DENSE_RANK, а в примере с нарастающим итогом окна у SUM и AVG разные. Одинаковые окна удобно вынести в предложение WINDOW.
Почему LAST_VALUE возвращает значение текущей строки?
Рамка по умолчанию заканчивается на текущей строке, точнее на последней из строк, равных ей по сортировке. Если равных нет, последней оказывается сама текущая строка. Укажите ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.








