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

Оконные функции SQL: полный гайд с реальными примерами

2026-04-21 18 мин

Без оконных функций в аналитике никуда. На собесе они почти всегда — если не дали задачу на 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. WINDOW FUNCTIONS  — посчитали окна на уже сжатом наборе
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 минут оконные перестанут казаться сложными.

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