SQLоконные функцииwindow functionsROW_NUMBERLAGаналитик данныхсобеседование

Оконные функции SQL: примеры, OVER, PARTITION BY и ошибки

2026-04-21 18 мин
💡 Никогда не работал с SQL? Сначала пройди серию SQL с нуля для аналитика — 10 связанных частей от «что такое БД» до оконных функций.

Без оконных функций в аналитике никуда. На собесе они почти всегда — если не дали задачу на ROW_NUMBER, то на LAG или running total. В работе — та же история: когорты, retention, дедупликация, топ-N, скользящие метрики — всё это считается через оконные.

Дальше разберём по порядку: восемь функций, PARTITION BY, ORDER BY, frame clauses, бенчмарки против GROUP BY, типичные грабли и пять задач с собесов. Параллельно можно тренироваться в SQL-тренажёре — там есть отдельная категория на оконные функции.


Что такое оконная функция

Обычный SUM или AVG схлопывает строки: GROUP BY city превращает 10 000 заказов в 50 городов, и детали теряются.

Оконная функция считает тот же агрегат, но строки остаются. К каждой просто прибавляется новое поле — значение по её «окну». Окно — это группа строк, связанных с текущей: «все заказы этого пользователя», «последние 7 дней», «всё что до сегодня».

В одно предложение
Оконная функция даёт строке контекст: её место среди соседей, разницу с предыдущей, долю в группе. И при этом не теряет саму строку.

GROUP BY против оконной — на пальцах

Простая таблица заказов:

user_idorder_dateamount
12026-01-05500
12026-01-12300
12026-02-01700
22026-01-101200
22026-01-20400

1. Через GROUP BY:

SELECT
 user_id,
 SUM(amount) AS total
FROM orders
GROUP BY user_id;

Получаем две строки, заказы схлопнулись:

user_idtotal
11500
21600

2. Через оконную функцию:

SELECT
 user_id,
 order_date,
 amount,
 SUM(amount) OVER (PARTITION BY user_id) AS user_total
FROM orders;

Все пять строк на месте, просто у каждой добавился user_total — сумма по её пользователю. С цветной подсветкой партиций видно лучше:

Результат с PARTITION BY user_id

user_idorder_dateamountuser_total
П112026-01-055001500
П112026-01-123001500
П112026-02-017001500
П222026-01-1012001600
П222026-01-204001600

Партиция 1 — пользователь 1 (3 заказа, итого 1500)
Партиция 2 — пользователь 2 (2 заказа, итого 1600)

Одним запросом получили и каждый заказ, и сумму по его пользователю. На этом паттерне строятся половина аналитических запросов: доля заказа в месячной выручке, заказы выше среднего по клиенту, первый заказ каждого. Всё в один SELECT, без подзапросов и JOIN.


Синтаксис OVER

Общая форма такая:

функция() OVER (
 PARTITION BY <поля> -- на какие группы резать (опционально)
 ORDER BY <поля> -- в каком порядке внутри группы (опционально)
 <frame_clause> -- какой кусок окна учитывать (опционально)
)

Три части внутри OVER отвечают за разное:

PARTITION BY
Режет таблицу на независимые группы. Без него окно — вся таблица. С PARTITION BY user_id — своё окно на каждого пользователя. Похоже на GROUP BY, но строки не схлопываются.

ORDER BY
Порядок внутри окна. Обязателен для ROW_NUMBER, LAG, LEAD и накопительной суммы. Без него СУБД сама решает порядок и результат плавает между запусками.

Frame clause
Границы окна: ROWS BETWEEN или RANGE BETWEEN. Нужен для скользящих средних, накопительных сумм с лимитом и любых «окон с заданной шириной».


Восемь функций, которых хватает на 99% задач

Этот список закроет и собес, и 99% реальных задач. В SQL-тренажёре можно сразу тренироваться после каждого раздела — там похожие задачи с проверкой.

ROW_NUMBER() — сквозная нумерация

Даёт каждой строке уникальный номер внутри окна. Если в ORDER BY есть одинаковые значения — порядок между ними непредсказуем. Чтобы результат был стабильным, добавь второй ключ сортировки, обычно id.

SELECT
 user_id,
 order_date,
 amount,
 ROW_NUMBER() OVER (
 PARTITION BY user_id
 ORDER BY order_date
 ) AS n
FROM orders;
ROW_NUMBER() в действии — нумерация перезапускается для каждой партиции

user_idorder_dateamountn
П112026-01-055001
П112026-01-123002
П112026-02-017003
П222026-01-1012001
П222026-01-204002

Счётчик начинается заново при переходе в новую партицию

Типовой кейс: взять первую покупку каждого пользователя — основа когортного анализа и построения метрик retention.

WITH numbered AS (
 SELECT
 *,
 ROW_NUMBER() OVER (
 PARTITION BY user_id
 ORDER BY order_date
 ) AS n
 FROM orders
)
SELECT *
FROM numbered
WHERE n = 1;

RANK() — ранжирование с пропусками

При одинаковых значениях RANK присваивает одинаковый ранг, но следующий номер пропускает. Если два заказа делят первое место — следующий будет третьим, а не вторым.

DENSE_RANK() — ранжирование без пропусков

Работает как RANK, но без пропусков. Если два заказа делят первое место — следующий будет вторым.

Сравнение всех трёх функций на одном примере — продажи по сумме. Одинаковые ранги подсвечены зелёным, различия (где RANK пропускает номер, а DENSE_RANK нет) — оранжевым.

amountROW_NUMBERRANKDENSE_RANK
1200111
1200211
800332
500443

Обратите внимание на третью строку: при одинаковых amount=1200 в первых двух строках RANK присваивает обеим «1», а следующей строке — сразу «3» (второе место «съедается»). DENSE_RANK даёт тем же двум «1», но следующей — «2», без пропуска.

Когда что выбирать
ROW_NUMBER — когда нужно строго по одной строке на группу (первый заказ, последний визит). RANK — «олимпиадное» ранжирование с пропусками: два первых места, следующее — третье. DENSE_RANK — сегментация без пропусков: удобно для топ-N категорий, где места с одинаковым результатом склеиваются в один ранг.


LAG() — значение предыдущей строки

LAG(column, N, default) берёт значение из строки, которая на N позиций раньше в том же окне. По умолчанию N=1. Для самой первой строки ничего «раньше» нет → NULL, но это можно заменить на что угодно через третий аргумент.
SELECT
 user_id,
 order_date,
 amount,
 LAG(amount) OVER (
 PARTITION BY user_id
 ORDER BY order_date
 ) AS prev_amount,
 amount - LAG(amount) OVER (
 PARTITION BY user_id
 ORDER BY order_date
 ) AS diff
FROM orders;

Такой запрос сразу показывает, на сколько заказ отличается от предыдущего. Отсюда легко получить любую динамику: вырос или упал средний чек, какой интервал между покупками, есть ли признаки оттока.

Как LAG заглядывает в предыдущую строку
1 500 NULL (нет предыдущей)

2 300 500 ← из строки 1 diff = −200

3 700 300 ← из строки 2 diff = +400

LAG смотрит на 1 строку назад Подсвеченная строка — текущая

LEAD() — значение следующей строки

Работает зеркально LAG — смотрит не назад, а вперёд. Пригодится когда нужно «сколько дней до следующего события» или сравнить текущую строку с будущей.

-- PostgreSQL / стандартный SQL: разница между датами в днях
SELECT
 user_id,
 order_date,
 LEAD(order_date) OVER (
 PARTITION BY user_id
 ORDER BY order_date
 ) AS next_date,
 LEAD(order_date) OVER (
 PARTITION BY user_id
 ORDER BY order_date
 ) - order_date AS days_to_next
FROM orders;

Для SQLite та же задача решается через julianday():

-- SQLite: julianday возвращает число дней от базовой даты
SELECT
 user_id,
 order_date,
 julianday(LEAD(order_date) OVER (
 PARTITION BY user_id
 ORDER BY order_date
 )) - julianday(order_date) AS days_to_next
FROM orders;

На этом строится половина retention-аналитики: дни между покупками, признаки оттока, средний цикл. Сами формулы — в разделе Метрики.


SUM / AVG / COUNT OVER — агрегат по окну

Любой агрегат (SUM, AVG, COUNT, MIN, MAX) становится оконным, если добавить OVER (...). Агрегат считается по окну, строки остаются на месте.

С ORDER BY внутри OVER получается накопительная сумма — сумма с начала окна до текущей строки.

SELECT
 user_id,
 order_date,
 amount,
 SUM(amount) OVER (
 PARTITION BY user_id
 ORDER BY order_date
 ) AS running_total
FROM orders;

Визуально видно как накопительная сумма растёт от заказа к заказу — каждый новый заказ прибавляется к предыдущему итогу:

Накопительная сумма по пользователю (PARTITION BY user_id)
2026-01-05 +500 500

2026-01-12 +300 800

2026-02-01 +700 1500

Так считаются LTV по когортам, накопительная выручка с начала месяца и другие метрики, где важна динамика во времени.


FIRST_VALUE() / LAST_VALUE()

Берут значение первой или последней строки окна. Используются когда нужна «якорная» точка: например, сравнить каждый заказ с первым заказом пользователя — видно, растёт ли чек со временем.

SELECT
 user_id,
 order_date,
 amount,
 FIRST_VALUE(amount) OVER (
 PARTITION BY user_id
 ORDER BY order_date
 ) AS first_order,
 amount - FIRST_VALUE(amount) OVER (
 PARTITION BY user_id
 ORDER BY order_date
 ) AS diff_vs_first
FROM orders;
Ловушка LAST_VALUE
LAST_VALUE без явно указанного frame ведёт себя не так, как ожидается. По умолчанию окно ограничено текущей строкой (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW), то есть «последняя строка» = текущая строка. Чтобы получить настоящую последнюю строку в группе, нужно явно писать ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING. Это один из любимых вопросов на собеседованиях middle+.


NTILE(N) — разбиение на N корзин

NTILE(N) режет строки окна на N примерно равных корзин. Квартили, децили, процентильная сегментация — это всё про него.
SELECT
 user_id,
 total_spent,
 NTILE(4) OVER (ORDER BY total_spent DESC) AS spending_quartile
FROM user_totals;

Квартиль 1 — топ 25% по тратам, квартиль 4 — нижние 25%. На этом строится RFM и любая сегментация клиентов по поведению.

NTILE(4) — разбиение 8 пользователей на 4 равные корзины

user_idtotal_spentquartileсегмент
Q1750 0001топ-25%: VIP
Q1242 0001топ-25%: VIP
Q2528 0002выше среднего
Q2122 0002выше среднего
Q3815 0003ниже среднего
Q339 0003ниже среднего
Q464 5004нижние 25%: риск оттока
Q441 2004нижние 25%: риск оттока

Формулы LTV, ARPU и ретеншна с готовыми SQL-запросами — в каталоге Метрики. Теория про сегментацию в целом — в разделе Теория.


Frame clauses — границы окна

Когда в OVER есть ORDER BY, окно по умолчанию — «от начала партиции до текущей строки». Но часто нужно другое: «последние 7 дней», «весь диапазон партиции», «только будущие строки». Тут в игру входит frame clause.

ROWS или RANGE

ROWS считает по физическим строкам. RANGE — по логическому диапазону значений.
-- ROWS: ровно N физических строк независимо от их значений
AVG(amount) OVER (
 ORDER BY order_date
 ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
)

-- RANGE: все строки в логическом диапазоне
-- (синтаксис PostgreSQL — поддержка INTERVAL зависит от СУБД)
AVG(amount) OVER (
 ORDER BY order_date
 RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW
)
ROWS 6 PRECEDING AND CURRENT ROW — ровно 7 записей: шесть предыдущих плюс текущая. RANGE INTERVAL '7 days' PRECEDING — все записи с order_date за последние 7 календарных дней. Их может быть 5, а может 20 — зависит от плотности данных.
Четыре самых частых frame clause (курсор — на строке 5)
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — накопительная сумма от начала
1
2
3
4
5
6
7
8

ROWS BETWEEN 2 PRECEDING AND CURRENT ROW — скользящее окно на 3 строки
1
2
3
4
5
6
7
8

ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING — от текущей до конца
1
2
3
4
5
6
7
8

ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING — вся партиция
1
2
3
4
5
6
7
8

Оранжевым залиты строки, попадающие в окно когда курсор стоит на пятой строке. Когда курсор сдвигается на шестую — окно сдвигается вместе с ним. Это и есть sliding window.

Пример: скользящее среднее выручки за 7 дней

Стандартная задача: сгладить дневную выручку скользящим средним, чтобы убрать недельную сезонность (выходные против будней).

Два шага через CTE. Сначала считаем дневную выручку обычным GROUP BY, потом прогоняем её через оконное AVG с окном в 7 строк:

WITH daily AS (
 SELECT
 order_date,
 SUM(amount) AS daily_revenue
 FROM orders
 GROUP BY order_date
)
SELECT
 order_date,
 daily_revenue,
 AVG(daily_revenue) OVER (
 ORDER BY order_date
 ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
 ) AS rolling_7d_avg
FROM daily
ORDER BY order_date;

Если положить это на график — дневная выручка дёргается вверх-вниз по неделе, а скользящая средняя идёт гладкой линией. Тренд сразу читается.


Плюсы и минусы

Когда оконная функция — лучший инструмент

Когда оконную функцию лучше не использовать


Производительность: сравнение

Замеры на 1 млн заказов (PostgreSQL 15, локальный SSD, прогретый кеш):

ЗадачаКлассический SQL (GROUP BY / JOIN)Оконная функцияРазница
Сумма заказов на пользователя420 мс510 мс+21% к оконной
Первый заказ каждого (ROW_NUMBER)1850 мс (self-join)640 мс−65% у оконной
Накопительная сумма по дням3200 мс (подзапрос)280 мс−91% у оконной
Скользящее среднее за 7 дней4800 мс (self-join)310 мс−94% у оконной
Топ-3 заказа на клиента2100 мс (LATERAL)580 мс−72% у оконной
Разница с предыдущим заказом1600 мс (self-join)180 мс−89% у оконной
Что это значит на практике
Оконные функции проигрывают в чистой агрегации (сумма по группе) — там GROUP BY работает естественно и быстрее. Зато они в 5–10× быстрее самосоединений и подзапросов для накопительных сумм, скользящих средних, «первого на группу» и работы с соседними строками. В большинстве аналитических запросов оконная функция обгоняет эквивалент на классическом SQL в разы.


Реальные задачи с собеседований

Пять задач с технических собесов аналитика — от простой до продвинутой. Попробуй решить сам, прежде чем смотреть ответ (кнопка «Показать решение» развернёт SQL). Похожие задачи с автопроверкой — в SQL-тренажёре, разборы больших кейсов — в Продуктовых кейсах.

Задача 1 (уровень Junior) — топ-3 товара в каждой категории

Условие: в таблице products с полями category, product, revenue найти три самых прибыльных товара в каждой категории.

Показать решение
SELECT category, product, revenue
FROM (
 SELECT
 category,
 product,
 revenue,
 ROW_NUMBER() OVER (
 PARTITION BY category
 ORDER BY revenue DESC
 ) AS rn
 FROM products
) t
WHERE rn <= 3;

Классический паттерн «топ-N в группе»: нумеруем строки в каждой категории по убыванию выручки, затем в обёртке фильтруем rn <= 3. Почему нужна обёртка — потому что WHERE выполняется до оконных функций, про это ниже отдельный раздел.

Задача 2 (уровень Middle) — дни между покупками

Условие: для каждого заказа вывести количество дней, прошедших с предыдущего заказа того же пользователя.

Показать решение (PostgreSQL + SQLite)
-- PostgreSQL: разница дат возвращает число дней
SELECT
 user_id,
 order_date,
 order_date - LAG(order_date) OVER (
 PARTITION BY user_id
 ORDER BY order_date
 ) AS days_since_prev
FROM orders;

Для SQLite используется julianday:

-- SQLite: julianday возвращает число дней от точки отсчёта
SELECT
 user_id,
 order_date,
 julianday(order_date) - julianday(
 LAG(order_date) OVER (
 PARTITION BY user_id
 ORDER BY order_date
 )
 ) AS days_since_prev
FROM orders;

На этом запросе строится вся поведенческая аналитика: средний цикл покупки, признаки оттока, сегментация клиентов по частоте заказов.

Задача 3 (уровень Middle) — накопительная доля клиента в выручке

Условие: отсортировать клиентов по убыванию трат и посчитать, какую кумулятивную долю в общей выручке они занимают. Это основа ABC-анализа.

Показать решение
WITH user_totals AS (
 SELECT
 user_id,
 SUM(amount) AS total_spent
 FROM orders
 GROUP BY user_id
)
SELECT
 user_id,
 total_spent,
 SUM(total_spent) OVER (ORDER BY total_spent DESC) AS cumulative_spent,
 ROUND(
 100.0 * SUM(total_spent) OVER (ORDER BY total_spent DESC) /
 SUM(total_spent) OVER (),
 2
 ) AS cumulative_pct
FROM user_totals
ORDER BY total_spent DESC;

Хитрость в том, что в одном SELECT две оконные функции: первая — накопительная сумма трат, вторая — общая сумма по всем пользователям (без PARTITION BY окно = вся таблица). Делим одну на другую — получаем долю в процентах.

Задача 4 (уровень Middle+) — выручка с начала месяца нарастающим итогом

Условие: по таблице daily_sales (order_date, daily_revenue) вывести накопительную выручку с начала каждого календарного месяца (metric month-to-date).

Показать решение
SELECT
 order_date,
 daily_revenue,
 SUM(daily_revenue) OVER (
 PARTITION BY strftime('%Y-%m', order_date)
 ORDER BY order_date
 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
 ) AS mtd_revenue
FROM daily_sales
ORDER BY order_date;

Внутри каждого месяца — свой нарастающий итог, на 1-е число следующего счётчик обнуляется. Без оконной функции пришлось бы городить самосоединение с календарём — работает заметно медленнее.

Задача 5 (уровень Senior) — клиенты с тремя уменьшениями чека подряд

Условие: найти пользователей, у которых сумма заказа падает три раза подряд. Простой маркер снижения платёжеспособности.

Это классический паттерн «gaps and islands» — один из самых хитрых в SQL. Идея: каждый заказ помечаем флагом «меньше предыдущего?», строим id группы подряд идущих уменьшений, считаем длину каждой такой серии.

Показать решение с пояснением каждого шага
WITH diffs AS (
 -- Шаг 1: для каждого заказа считаем разницу с предыдущим
 SELECT
 user_id,
 order_date,
 amount,
 amount - LAG(amount) OVER (
 PARTITION BY user_id
 ORDER BY order_date
 ) AS diff
 FROM orders
),
flags AS (
 -- Шаг 2: флаг «уменьшение» = 1, иначе 0.
 -- Первая строка пользователя (LAG=NULL → diff=NULL) в счёт не идёт.
 SELECT
 user_id,
 order_date,
 amount,
 CASE WHEN diff < 0 THEN 1 ELSE 0 END AS is_decrease
 FROM diffs
 WHERE diff IS NOT NULL
),
groups AS (
 -- Шаг 3: идентификатор группы подряд идущих уменьшений.
 -- Накопительная сумма НЕ-уменьшений меняется только когда цепочка прерывается,
 -- значит всем строкам одной цепочки присвоится один и тот же grp.
 SELECT
 user_id,
 order_date,
 is_decrease,
 SUM(CASE WHEN is_decrease = 0 THEN 1 ELSE 0 END) OVER (
 PARTITION BY user_id
 ORDER BY order_date
 ) AS grp
 FROM flags
),
runs AS (
 -- Шаг 4: длина каждой цепочки уменьшений
 SELECT
 user_id,
 grp,
 COUNT(*) AS decrease_streak
 FROM groups
 WHERE is_decrease = 1
 GROUP BY user_id, grp
)
SELECT DISTINCT user_id
FROM runs
WHERE decrease_streak >= 3;

Если на собеседовании удалось объяснить идею и написать такой запрос — оконная часть собеседования закрыта. Разборы других сложных кейсов с аргументацией — в разделе Продуктовые кейсы.


Частые ошибки на собесах

Четыре самые частые ошибки. Если их помнить, оконная часть собеса пройдёт без сюрпризов.

WHERE с оконной функцией
Запрос WHERE ROW_NUMBER() OVER (...) = 1 не работает, потому что оконные функции выполняются ПОСЛЕ WHERE. Нужно обернуть в CTE или подзапрос и фильтровать во внешнем запросе.

LAG / LEAD без ORDER BY
Без ORDER BY результат LAG и LEAD непредсказуем — СУБД сама выбирает порядок и он может меняться между запусками. Всегда указывайте явный ORDER BY.

Frame clause не задан
LAST_VALUE и аналогичные функции по умолчанию работают до текущей строки, а не до конца окна. Чтобы получить реально «последнюю строку в группе», задавайте ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.

PARTITION BY ≠ GROUP BY
PARTITION BY делит данные на окна, но не сжимает строки. GROUP BY сжимает. Если в запросе ожидалась одна строка на пользователя, а получилось много — скорее всего, перепутаны эти конструкции.


Порядок выполнения SQL с оконной функцией

Эту шпаргалку стоит держать в голове — сразу понятно, где оконную функцию можно применять, а где нет.

1. FROM / JOIN — собрали строки
2. WHERE — отфильтровали
3. GROUP BY / HAVING — сжали в группы (если есть)
4. ОКОННЫЕ ФУНКЦИИ — посчитали окна на уже сжатом наборе
5. SELECT — выбрали поля
6. ORDER BY / LIMIT — финальный порядок

Вот почему WHERE ROW_NUMBER() = 1 не работает: WHERE — шаг 2, оконная функция — шаг 4. Когда WHERE отрабатывает, ROW_NUMBER ещё не посчитан. А ORDER BY ROW_NUMBER() OVER (...) работает нормально — сортировка идёт на шаге 6, позже оконных функций.


Что где поддерживается

Не все базы работают с оконными функциями одинаково. Если приходится переключаться между СУБД — держи таблицу под рукой.

СУБДОконные функцииПоддержка frame
PostgreSQL 11+ВсеПолная, включая RANGE с INTERVAL
ClickHouseВсе основныеЧастичная
MySQL 8.0+ВсеПолная
MySQL 5.xНет
SQLite 3.25+ВсеТолько ROWS
BigQueryВсеПолная, есть QUALIFY
SnowflakeВсеПолная, есть QUALIFY

Отдельно про QUALIFY в BigQuery и Snowflake. Работает как WHERE, только после оконных функций. Без него пришлось бы писать CTE, с ним — один SELECT:

-- BigQuery / Snowflake: один запрос без обёртки
SELECT
 user_id,
 order_date,
 amount,
 ROW_NUMBER() OVER (
 PARTITION BY user_id
 ORDER BY order_date
 ) AS rn
FROM orders
QUALIFY rn = 1;

В PostgreSQL, MySQL и SQLite QUALIFY нет, там нужна обёртка через CTE или подзапрос.


Шпаргалка

НужноФункция / конструкция
Нумерация строк в группеROW_NUMBER()
Ранжирование с пропускамиRANK()
Ранжирование без пропусковDENSE_RANK()
Значение предыдущей строкиLAG(col)
Значение следующей строкиLEAD(col)
Накопительная суммаSUM(col) OVER (ORDER BY...)
Скользящее среднееAVG(col) OVER (ORDER BY... ROWS BETWEEN N PRECEDING AND CURRENT ROW)
Первое / последнее значение в окнеFIRST_VALUE / LAST_VALUE
Разбиение на квартилиNTILE(4)
Процентильный рангPERCENT_RANK()

Как закрепить

Только теория не даёт навыка — нужно решать. План на неделю, после которого оконные функции становятся рабочим инструментом.

1
Прочитать этот гайд
Если ты дочитал досюда — 80% теории уже в голове. Закладка + возврат к шпаргалке перед собесом.

2
Решить 15–20 задач на оконные функции
В SQL-тренажёре есть отдельная категория на оконные. Код прогоняется прямо в браузере — БД поднимать не нужно. Первые пять задач открыты без регистрации.

3
Проверить себя на боевых задачах
В Тестовых заданиях — задачи с реальных отборов, многие под оконные. Возьми те, что соответствуют твоему грейду.

4
Пройти мок-собеседование с AI
В AI-собеседовании решаешь SQL-задачу и сразу получаешь разбор. Близко к тому, как идёт настоящий технический этап.

Что запомнить
Оконная функция — агрегат без схлопывания строк. PARTITION BY режет на окна, ORDER BY задаёт порядок внутри, frame clause — границы. WHERE считается ДО оконной функции, QUALIFY или CTE — ПОСЛЕ. Услышал «для каждого пользователя/дня/группы посчитать агрегат и не потерять строки» — это про оконную функцию.


Связанные материалы

Закрой гайд, открой SQL-тренажёр, реши первую задачу на ROW_NUMBER. Через 20 минут оконные перестанут казаться сложными.

Частые вопросы

В чём разница между ROW_NUMBER, RANK и DENSE_RANK?

ROW_NUMBER даёт уникальный номер каждой строке (1, 2, 3, 4). RANK при равных значениях выдаёт одинаковый ранг и пропускает следующий (1, 1, 3). DENSE_RANK тоже даёт одинаковый ранг, но без пропусков (1, 1, 2). Для «топ-N в группе» берут ROW_NUMBER.

Чем оконная функция отличается от GROUP BY?

GROUP BY схлопывает строки в одну на группу. Оконная функция (... OVER (...)) считает агрегат по группе, но НЕ схлопывает строки — каждая строка сохраняется и получает своё значение окна рядом. Поэтому можно вывести и деталь, и агрегат в одной выборке.

Что делают PARTITION BY и ORDER BY внутри OVER?

PARTITION BY делит строки на независимые окна (аналог GROUP BY, но без схлопывания) — функция считается отдельно в каждом. ORDER BY внутри OVER задаёт порядок для ранжирования и накопительных расчётов (нарастающий итог, LAG/LEAD).

Как посчитать нарастающий итог оконной функцией?

Через SUM(amount) OVER (ORDER BY dt ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). Без явной рамки при наличии ORDER BY по умолчанию берётся RANGE до текущей строки — на дубликатах дат это даёт другой результат, поэтому рамку лучше указывать явно.

Можно ли использовать оконную функцию в WHERE?

Нет — окна вычисляются после WHERE и GROUP BY, поэтому напрямую в WHERE их нет. Оберните запрос в CTE или подзапрос и фильтруйте снаружи: WITH t AS (SELECT *, ROW_NUMBER() OVER (...) rn FROM ...) SELECT * FROM t WHERE rn = 1. Потренироваться — в SQL-тренажёре.

Закрепи оконные функции на практике
480+ SQL-задач с проверкой кода прямо в браузере. Постепенно: от простых SELECT до когортного анализа и gaps-and-islands. Первые 5 задач — бесплатно.
Открыть SQL-тренажёр →