💡 Никогда не работал с 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_id | order_date | amount |
|---|---|---|
| 1 | 2026-01-05 | 500 |
| 1 | 2026-01-12 | 300 |
| 1 | 2026-02-01 | 700 |
| 2 | 2026-01-10 | 1200 |
| 2 | 2026-01-20 | 400 |
1. Через GROUP BY:
SELECT
user_id,
SUM(amount) AS total
FROM orders
GROUP BY user_id;
Получаем две строки, заказы схлопнулись:
| user_id | total |
|---|---|
| 1 | 1500 |
| 2 | 1600 |
2. Через оконную функцию:
SELECT
user_id,
order_date,
amount,
SUM(amount) OVER (PARTITION BY user_id) AS user_total
FROM orders;
Все пять строк на месте, просто у каждой добавился user_total — сумма по её пользователю. С цветной подсветкой партиций видно лучше:
| user_id | order_date | amount | user_total |
|---|---|---|---|
| П11 | 2026-01-05 | 500 | 1500 |
| П11 | 2026-01-12 | 300 | 1500 |
| П11 | 2026-02-01 | 700 | 1500 |
| П22 | 2026-01-10 | 1200 | 1600 |
| П22 | 2026-01-20 | 400 | 1600 |
Одним запросом получили и каждый заказ, и сумму по его пользователю. На этом паттерне строятся половина аналитических запросов: доля заказа в месячной выручке, заказы выше среднего по клиенту, первый заказ каждого. Всё в один SELECT, без подзапросов и JOIN.
Синтаксис OVER
Общая форма такая:
функция() OVER (
PARTITION BY <поля> -- на какие группы резать (опционально)
ORDER BY <поля> -- в каком порядке внутри группы (опционально)
<frame_clause> -- какой кусок окна учитывать (опционально)
)
Три части внутри OVER отвечают за разное:
PARTITION BY user_id — своё окно на каждого пользователя. Похоже на GROUP BY, но строки не схлопываются.ROW_NUMBER, LAG, LEAD и накопительной суммы. Без него СУБД сама решает порядок и результат плавает между запусками.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;
| user_id | order_date | amount | n |
|---|---|---|---|
| П11 | 2026-01-05 | 500 | 1 |
| П11 | 2026-01-12 | 300 | 2 |
| П11 | 2026-02-01 | 700 | 3 |
| П22 | 2026-01-10 | 1200 | 1 |
| П22 | 2026-01-20 | 400 | 2 |
Типовой кейс: взять первую покупку каждого пользователя — основа когортного анализа и построения метрик 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 нет) — оранжевым.
| amount | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 1200 | 1 | 1 | 1 |
| 1200 | 2 | 1 | 1 |
| 800 | 3 | 3 | 2 |
| 500 | 4 | 4 | 3 |
Обратите внимание на третью строку: при одинаковых amount=1200 в первых двух строках RANK присваивает обеим «1», а следующей строке — сразу «3» (второе место «съедается»). DENSE_RANK даёт тем же двум «1», но следующей — «2», без пропуска.
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;
Такой запрос сразу показывает, на сколько заказ отличается от предыдущего. Отсюда легко получить любую динамику: вырос или упал средний чек, какой интервал между покупками, есть ли признаки оттока.
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;
Визуально видно как накопительная сумма растёт от заказа к заказу — каждый новый заказ прибавляется к предыдущему итогу:
Так считаются 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 без явно указанного 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 и любая сегментация клиентов по поведению.
| user_id | total_spent | quartile | сегмент |
|---|---|---|---|
| Q17 | 50 000 | 1 | топ-25%: VIP |
| Q12 | 42 000 | 1 | топ-25%: VIP |
| Q25 | 28 000 | 2 | выше среднего |
| Q21 | 22 000 | 2 | выше среднего |
| Q38 | 15 000 | 3 | ниже среднего |
| Q33 | 9 000 | 3 | ниже среднего |
| Q46 | 4 500 | 4 | нижние 25%: риск оттока |
| Q44 | 1 200 | 4 | нижние 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 — зависит от плотности данных.
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — накопительная сумма от началаROWS BETWEEN 2 PRECEDING AND CURRENT ROW — скользящее окно на 3 строкиROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING — от текущей до концаROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING — вся партицияОранжевым залиты строки, попадающие в окно когда курсор стоит на пятой строке. Когда курсор сдвигается на шестую — окно сдвигается вместе с ним. Это и есть 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;
Если положить это на график — дневная выручка дёргается вверх-вниз по неделе, а скользящая средняя идёт гладкой линией. Тренд сразу читается.
Плюсы и минусы
Когда оконная функция — лучший инструмент
- Нужны детали и агрегат одновременно. Топ-N заказов на пользователя с сохранением всех полей — без
GROUP BY, в один запрос. - Накопительные суммы.
SUMилиAVGсORDER BY— это накопительный итог в одну строку запроса, без самосоединений. - Доступ к соседним строкам.
LAGиLEAD— разница с предыдущей или следующей строкой. Без оконных функций это делается только через самосоединение. - Перцентили и квартили.
NTILE,PERCENT_RANK,CUME_DISTсчитаются одной функцией — раньше для этого использовалиCROSS JOIN. - Читаемость. Один
OVERвместо трёх подзапросов и двухJOIN.
Когда оконную функцию лучше не использовать
- Агрегат без деталей. Если строки-источники не нужны, обычный
GROUP BYбыстрее и проще. - Простая фильтрация.
WHERE amount > (SELECT AVG(amount) FROM orders)через подзапрос часто быстрее и понятнее, чем оконная функция. - MySQL < 8.0. В старых MySQL оконных функций нет. Вместо них — самосоединения или пользовательские переменные.
- Очень большие партиции. Окно на десятки миллионов строк может работать медленнее
GROUP BYиз-за сортировки внутри окна.
Производительность: сравнение
Замеры на 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 ROW_NUMBER() OVER (...) = 1 не работает, потому что оконные функции выполняются ПОСЛЕ WHERE. Нужно обернуть в CTE или подзапрос и фильтровать во внешнем запросе.ORDER BY результат LAG и LEAD непредсказуем — СУБД сама выбирает порядок и он может меняться между запусками. Всегда указывайте явный ORDER BY.LAST_VALUE и аналогичные функции по умолчанию работают до текущей строки, а не до конца окна. Чтобы получить реально «последнюю строку в группе», задавайте ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.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() |
Как закрепить
Только теория не даёт навыка — нужно решать. План на неделю, после которого оконные функции становятся рабочим инструментом.
PARTITION BY режет на окна, ORDER BY задаёт порядок внутри, frame clause — границы. WHERE считается ДО оконной функции, QUALIFY или CTE — ПОСЛЕ. Услышал «для каждого пользователя/дня/группы посчитать агрегат и не потерять строки» — это про оконную функцию.Связанные материалы
- SQL-тренажёр — 300 задач с проверкой кода в браузере, первые 5 бесплатно
- Вопросы с собеседований — 2000+ карточек по SQL, Python, статистике, A/B-тестам
- Продуктовые кейсы — 440+ кейсов с разбором
- AI-собеседование — разбор решений с честной обратной связью
- Конспекты по SQL и статистике — 210 модулей теории
- Продуктовые метрики — 340+ метрик с формулами и SQL-запросами
- Как стать аналитиком данных в 2026 — полный гайд по входу в профессию
Закрой гайд, открой 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-тренажёре.