SQLwindow-functionstutorialanalytics

Оконные функции в SQL: ROW_NUMBER, RANK, LAG и LEAD на понятных примерах

Оконные функции — главный инструмент SQL-аналитика. Разберём ROW_NUMBER, RANK, LAG/LEAD и PARTITION BY на реальных задачах: топ-N в категории, day-over-day метрики, кумулятивные суммы.

11 мин чтенияСправочникSQL · window-functions · tutorial · analytics

Оконные функции — это один из самых сильных инструментов SQL после JOIN и GROUP BY.

Они нужны там, где хочется посчитать что-то «по соседним строкам» или «внутри группы», но не потерять исходные строки.

Например:

  • найти топ-3 продукта в каждой категории;
  • пронумеровать заказы внутри каждого клиента;
  • посчитать выручку нарастающим итогом;
  • сравнить сегодняшнюю выручку со вчерашней;
  • посчитать скользящее среднее за 7 дней;
  • показать заказ и рядом средний чек по его категории.

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

Таблица для примеров

Будем использовать простую таблицу заказов:

CREATE TABLE orders (
  id INT PRIMARY KEY,
  category VARCHAR(50),
  product VARCHAR(100),
  amount NUMERIC(10, 2),
  created_at DATE
);

В ней есть:

  • id — идентификатор заказа;
  • category — категория товара;
  • product — название продукта;
  • amount — сумма заказа;
  • created_at — дата заказа.

На такой таблице удобно разбирать почти все базовые сценарии оконных функций.

Главная идея оконных функций

Оконная функция считает значение не по всей таблице сразу, а по некоторому «окну» строк.

Окно — это набор строк, которые связаны с текущей строкой.

Например:

  • все заказы той же категории;
  • все заказы того же клиента;
  • все дни до текущего дня;
  • текущая строка и 6 предыдущих;
  • предыдущая или следующая строка по дате.

Самое важное: оконные функции не схлопывают строки.

Они оставляют каждую строку на месте, но добавляют рядом новое вычисленное значение.

GROUP BY схлопывает строки, оконные функции — нет

Это главное отличие.

Когда вы используете GROUP BY, SQL собирает много строк в одну.

Например:

SELECT
  category,
  AVG(amount) AS avg_amount
FROM orders
GROUP BY category;

Если в таблице было 1000 заказов и 5 категорий, после запроса останется 5 строк: по одной строке на категорию.

Это удобно, когда нужен итоговый отчёт. Но иногда хочется видеть каждый заказ и рядом среднее значение по его категории.

С GROUP BY так просто не получится, потому что детализация уже потеряна.

А оконная функция позволяет сделать именно это:

SELECT
  id,
  category,
  product,
  amount,
  AVG(amount) OVER (PARTITION BY category) AS category_avg_amount
FROM orders;

Результат будет примерно таким:

id | category | product | amount | category_avg_amount
---+----------+---------+--------+--------------------
1  | books    | book_a  | 500.00 | 650.00
2  | books    | book_b  | 800.00 | 650.00
3  | games    | game_a  | 300.00 | 450.00
4  | games    | game_b  | 600.00 | 450.00

Каждый заказ остался отдельной строкой. Но рядом появилась средняя сумма по его категории.

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

Как читать OVER

Оконная функция почти всегда выглядит так:

function_name(...) OVER (...)

Например:

AVG(amount) OVER (PARTITION BY category)

Читать можно так:

«Посчитай среднее значение amount по окну, где строки разделены по category».

Часть OVER (...) говорит SQL, какие строки считать соседями текущей строки.

Внутри OVER чаще всего встречаются три настройки:

  • PARTITION BY;
  • ORDER BY;
  • frame, то есть рамка окна.

Разберём их по очереди.

PARTITION BY: разделить окно на группы

PARTITION BY делит строки на независимые группы внутри оконной функции.

Похоже на GROUP BY, но есть важное отличие: строки не схлопываются.

Среднее по всей таблице:

SELECT
  id,
  category,
  amount,
  AVG(amount) OVER () AS avg_amount_all
FROM orders;

Среднее отдельно по каждой категории:

SELECT
  id,
  category,
  amount,
  AVG(amount) OVER (PARTITION BY category) AS avg_amount_by_category
FROM orders;

Если написать OVER (), окно — вся таблица.

Если написать OVER (PARTITION BY category), для каждой строки окном будут только строки с той же категорией.

Можно думать так:

  • GROUP BY category оставляет одну строку на категорию;
  • PARTITION BY category считает внутри категории, но сохраняет все строки.

ORDER BY внутри OVER: порядок строк в окне

ORDER BY внутри OVER задаёт порядок строк для оконной функции.

Он нужен, когда важна позиция:

  • кто первый;
  • кто второй;
  • какая строка была предыдущей;
  • какая строка будет следующей;
  • как считать накопительный итог.

Например, пронумеруем заказы внутри каждой категории по убыванию суммы:

SELECT
  id,
  category,
  product,
  amount,
  ROW_NUMBER() OVER (
    PARTITION BY category
    ORDER BY amount DESC
  ) AS row_num
FROM orders;

Для каждой категории SQL отдельно отсортирует заказы по amount DESC и проставит номера: 1, 2, 3 и так далее.

Важно: ORDER BY внутри OVER и обычный ORDER BY в конце запроса — это разные вещи.

ORDER BY внутри OVER нужен для расчёта оконной функции.

Обычный ORDER BY в конце нужен для сортировки итогового результата на экране.

SELECT
  id,
  category,
  amount,
  ROW_NUMBER() OVER (
    PARTITION BY category
    ORDER BY amount DESC
  ) AS row_num
FROM orders
ORDER BY category, row_num;

Внутренний порядок считает номера, внешний порядок красиво выводит результат.

Frame: какие строки внутри окна участвуют в расчёте

Frame, или рамка окна, отвечает на вопрос: какие именно строки внутри отсортированного окна участвуют в расчёте для текущей строки.

Звучит сложнее, чем есть на самом деле.

Например, накопительная сумма:

SELECT
  created_at,
  amount,
  SUM(amount) OVER (
    ORDER BY created_at
  ) AS running_amount
FROM orders
ORDER BY created_at;

Когда в оконном агрегате есть ORDER BY, сумма считается не по всей таблице сразу, а нарастающим итогом: от начала окна до текущей строки.

Пример:

created_at | amount | running_amount
-----------+--------+---------------
2026-06-01 | 100.00 | 100.00
2026-06-02 | 150.00 | 250.00
2026-06-03 | 200.00 | 450.00

Это частая ловушка новичков.

Вы могли добавить ORDER BY внутри OVER, чтобы «просто упорядочить», а агрегат внезапно стал накопительным.

Если вам нужно среднее по всей категории, порядок не нужен:

AVG(amount) OVER (PARTITION BY category)

Если нужен накопительный итог внутри категории, порядок нужен:

SUM(amount) OVER (
  PARTITION BY category
  ORDER BY created_at
)

ROW_NUMBER: уникальный номер строки

ROW_NUMBER() присваивает каждой строке уникальный номер внутри окна.

SELECT
  id,
  category,
  product,
  amount,
  ROW_NUMBER() OVER (
    PARTITION BY category
    ORDER BY amount DESC
  ) AS row_num
FROM orders;

Если в категории несколько заказов, самый дорогой получит row_num = 1, следующий — row_num = 2, и так далее.

Даже если суммы одинаковые, ROW_NUMBER() всё равно поставит разные номера.

Например, для сумм 100, 100, 50 номера будут такими:

amount | row_num
-------+--------
100.00 | 1
100.00 | 2
50.00  | 3

Но здесь есть тонкость: если в ORDER BY есть одинаковые значения, порядок между ними может быть нестабильным. Сегодня один заказ получит номер 1, завтра другой, если база выберет другой порядок чтения.

Поэтому для надёжной сортировки лучше добавлять уникальный столбец в конец ORDER BY.

SELECT
  id,
  category,
  product,
  amount,
  ROW_NUMBER() OVER (
    PARTITION BY category
    ORDER BY amount DESC, id
  ) AS row_num
FROM orders;

Теперь при равной сумме победит заказ с меньшим id, и результат будет стабильным.

RANK и DENSE_RANK: ранги с учётом одинаковых значений

Кроме ROW_NUMBER(), есть ещё две похожие функции:

  • RANK();
  • DENSE_RANK().

Они нужны, когда одинаковые значения должны получить одинаковое место.

Представим суммы:

100, 100, 50

Разные функции пронумеруют их так:

Функция Результат
ROW_NUMBER() 1, 2, 3
RANK() 1, 1, 3
DENSE_RANK() 1, 1, 2

Разница такая:

ROW_NUMBER() всегда даёт уникальный номер каждой строке.

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

DENSE_RANK() тоже даёт одинаковый ранг одинаковым значениям, но без разрыва. Если два заказа поделили первое место, следующий будет вторым.

Пример:

SELECT
  id,
  category,
  product,
  amount,
  ROW_NUMBER() OVER (
    PARTITION BY category
    ORDER BY amount DESC, id
  ) AS row_num,
  RANK() OVER (
    PARTITION BY category
    ORDER BY amount DESC
  ) AS rank_num,
  DENSE_RANK() OVER (
    PARTITION BY category
    ORDER BY amount DESC
  ) AS dense_rank_num
FROM orders;

Как выбрать между ROW_NUMBER, RANK и DENSE_RANK

Выбор зависит от бизнес-смысла.

Если нужно строго 3 строки на категорию — берите ROW_NUMBER().

Например: «покажи ровно 3 продукта в каждой категории».

Если нужно показать всех, кто делит место в рейтинге, — берите RANK() или DENSE_RANK().

Например: «покажи всех продуктов, которые попали в топ-3 мест, включая ничьи».

Разница особенно важна на собеседованиях. В задаче «топ-3 в каждой группе» всегда уточняйте: нужно ровно 3 строки или все участники, которые делят третье место.

Кейс: топ-3 продукта в каждой категории

Это одна из самых популярных задач на оконные функции.

Нужно найти три продукта с максимальной выручкой в каждой категории.

Сначала посчитаем выручку по продуктам, а потом пронумеруем продукты внутри каждой категории.

WITH product_revenue AS (
  SELECT
    category,
    product,
    SUM(amount) AS total_amount
  FROM orders
  GROUP BY category, product
),
ranked_products AS (
  SELECT
    category,
    product,
    total_amount,
    ROW_NUMBER() OVER (
      PARTITION BY category
      ORDER BY total_amount DESC, product
    ) AS row_num
  FROM product_revenue
)
SELECT
  category,
  product,
  total_amount
FROM ranked_products
WHERE row_num <= 3
ORDER BY category, row_num;

Что происходит по шагам:

  1. В product_revenue мы считаем сумму заказов по каждой паре category и product.
  2. В ranked_products нумеруем продукты внутри каждой категории по убыванию выручки.
  3. Во внешнем запросе оставляем только строки, где row_num <= 3.
  4. В конце сортируем результат по категории и номеру строки.

Почему нельзя написать WHERE row_num <= 3 в том же запросе, где создаётся row_num?

Потому что WHERE выполняется раньше, чем оконные функции. На момент фильтрации столбца row_num ещё нет. Поэтому обычно используют CTE или подзапрос.

Вариант с RANK: если нужно учитывать ничьи

Если два продукта делят одно место по выручке, ROW_NUMBER() всё равно выберет порядок между ними.

А если бизнес говорит: «Покажи всех, кто реально попал в топ-3 мест», лучше использовать RANK().

WITH product_revenue AS (
  SELECT
    category,
    product,
    SUM(amount) AS total_amount
  FROM orders
  GROUP BY category, product
),
ranked_products AS (
  SELECT
    category,
    product,
    total_amount,
    RANK() OVER (
      PARTITION BY category
      ORDER BY total_amount DESC
    ) AS rank_num
  FROM product_revenue
)
SELECT
  category,
  product,
  total_amount
FROM ranked_products
WHERE rank_num <= 3
ORDER BY category, rank_num, product;

Так в результат могут попасть больше трёх строк на категорию, если есть одинаковые суммы.

Это не ошибка. Это именно смысл ранга.

LAG: взять значение из предыдущей строки

LAG() позволяет посмотреть назад — взять значение из предыдущей строки внутри окна.

Например, есть дневная выручка. Нужно рядом с каждым днём показать выручку предыдущего дня.

WITH daily_revenue AS (
  SELECT
    created_at AS day,
    SUM(amount) AS revenue
  FROM orders
  GROUP BY created_at
)
SELECT
  day,
  revenue,
  LAG(revenue) OVER (
    ORDER BY day
  ) AS prev_revenue
FROM daily_revenue
ORDER BY day;

Результат:

day        | revenue | prev_revenue
-----------+---------+-------------
2026-06-01 | 1000.00 | null
2026-06-02 | 1200.00 | 1000.00
2026-06-03 | 900.00  | 1200.00

У первой строки prev_revenue равен NULL, потому что предыдущей строки нет.

LAG() часто используют для сравнений:

  • сегодня против вчера;
  • этот месяц против прошлого;
  • текущая покупка против предыдущей;
  • текущий статус против прошлого;
  • текущая цена против предыдущей.

LEAD: взять значение из следующей строки

LEAD() работает в другую сторону: берёт значение из следующей строки.

WITH daily_revenue AS (
  SELECT
    created_at AS day,
    SUM(amount) AS revenue
  FROM orders
  GROUP BY created_at
)
SELECT
  day,
  revenue,
  LEAD(revenue) OVER (
    ORDER BY day
  ) AS next_revenue
FROM daily_revenue
ORDER BY day;

Результат:

day        | revenue | next_revenue
-----------+---------+-------------
2026-06-01 | 1000.00 | 1200.00
2026-06-02 | 1200.00 | 900.00
2026-06-03 | 900.00  | null

У последней строки next_revenue будет NULL, потому что следующей строки нет.

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

Кейс: прирост выручки день ко дню

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

Сначала считаем дневную выручку:

WITH daily_revenue AS (
  SELECT
    created_at AS day,
    SUM(amount) AS revenue
  FROM orders
  GROUP BY created_at
)
SELECT
  day,
  revenue
FROM daily_revenue
ORDER BY day;

Теперь добавим выручку предыдущего дня через LAG():

WITH daily_revenue AS (
  SELECT
    created_at AS day,
    SUM(amount) AS revenue
  FROM orders
  GROUP BY created_at
)
SELECT
  day,
  revenue,
  LAG(revenue) OVER (
    ORDER BY day
  ) AS prev_revenue
FROM daily_revenue
ORDER BY day;

Теперь посчитаем процент роста:

WITH daily_revenue AS (
  SELECT
    created_at AS day,
    SUM(amount) AS revenue
  FROM orders
  GROUP BY created_at
),
daily_with_prev AS (
  SELECT
    day,
    revenue,
    LAG(revenue) OVER (
      ORDER BY day
    ) AS prev_revenue
  FROM daily_revenue
)
SELECT
  day,
  revenue,
  prev_revenue,
  ROUND(
    100.0 * (revenue - prev_revenue) / NULLIF(prev_revenue, 0),
    2
  ) AS growth_pct
FROM daily_with_prev
ORDER BY day;

Формула такая:

growth_pct = 100 * (current_value - previous_value) / previous_value

Зачем нужен NULLIF(prev_revenue, 0)?

Чтобы не делить на ноль. Если в предыдущий день выручка была 0, выражение вернёт NULL, а не ошибку.

Этот запрос уже похож на настоящую аналитику из дашборда: текущая выручка, прошлое значение и процент изменения.

LAG с шагом и значением по умолчанию

У LAG() есть дополнительные аргументы.

LAG(value, offset, default_value)

Например:

SELECT
  id,
  created_at,
  amount,
  LAG(amount, 1, 0) OVER (
    ORDER BY created_at, id
  ) AS prev_amount
FROM orders;

Здесь:

  • amount — какое значение берём из прошлой строки;
  • 1 — на сколько строк назад смотрим;
  • 0 — что вернуть, если прошлой строки нет.

Можно смотреть не на одну строку назад, а на две:

SELECT
  id,
  created_at,
  amount,
  LAG(amount, 2) OVER (
    ORDER BY created_at, id
  ) AS amount_two_rows_before
FROM orders;

Это полезно, если нужно сравнить значение не с предыдущим событием, а с более ранним.

Накопительная сумма

Накопительная сумма, или running total, показывает итог от начала периода до текущей строки.

Например, посчитаем дневную выручку и накопительный итог:

WITH daily_revenue AS (
  SELECT
    created_at AS day,
    SUM(amount) AS revenue
  FROM orders
  GROUP BY created_at
)
SELECT
  day,
  revenue,
  SUM(revenue) OVER (
    ORDER BY day
  ) AS cumulative_revenue
FROM daily_revenue
ORDER BY day;

Результат:

day        | revenue | cumulative_revenue
-----------+---------+-------------------
2026-06-01 | 1000.00 | 1000.00
2026-06-02 | 1200.00 | 2200.00
2026-06-03 | 900.00  | 3100.00

Каждая строка показывает не только выручку за день, но и сумму с начала истории до этого дня.

Это часто используют для:

  • накопительной выручки;
  • накопительного числа пользователей;
  • прогресса к месячному плану;
  • баланса после каждой операции.

Month-to-date: накопление внутри месяца

Если нужно считать накопительный итог заново с начала каждого месяца, добавляем PARTITION BY.

WITH daily_revenue AS (
  SELECT
    created_at AS day,
    SUM(amount) AS revenue
  FROM orders
  GROUP BY created_at
)
SELECT
  day,
  revenue,
  SUM(revenue) OVER (
    PARTITION BY DATE_TRUNC('month', day)
    ORDER BY day
  ) AS month_to_date_revenue
FROM daily_revenue
ORDER BY day;

Теперь окно делится по месяцам.

В начале каждого месяца накопительная сумма начинается заново.

Прочитать можно так:

«Для каждого дня посчитай сумму выручки с начала его месяца до этого дня».

Скользящее среднее за 7 дней

Скользящее среднее помогает сгладить скачки.

Например, в выходные заказов может быть меньше, в понедельник больше, а нам хочется увидеть спокойный тренд без резких зубцов.

Для этого используем frame:

WITH daily_revenue AS (
  SELECT
    created_at AS day,
    SUM(amount) AS revenue
  FROM orders
  GROUP BY created_at
)
SELECT
  day,
  revenue,
  AVG(revenue) OVER (
    ORDER BY day
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ) AS revenue_7d_avg
FROM daily_revenue
ORDER BY day;

Фраза:

ROWS BETWEEN 6 PRECEDING AND CURRENT ROW

означает:

«Возьми текущую строку и 6 строк перед ней».

Итого получается окно максимум из 7 строк.

В первые дни истории строк будет меньше. Например, на третий день среднее посчитается только по трём доступным дням. Это нормальное поведение.

ROWS и RANGE: простое предупреждение

В оконных функциях можно встретить разные типы рамок, например ROWS и RANGE.

Для новичка важно запомнить простое правило: если вы хотите считать ровно по количеству строк, используйте ROWS.

Например:

ROWS BETWEEN 6 PRECEDING AND CURRENT ROW

Это значит именно 6 предыдущих строк плюс текущая.

RANGE работает не с количеством строк, а с диапазоном значений в ORDER BY. Это полезно в некоторых задачах, но для первых запросов часто только добавляет путаницу.

Для скользящего среднего «7 строк» используйте ROWS.

Оконные функции и WHERE

Оконную функцию нельзя использовать прямо в WHERE того же уровня запроса.

Такой запрос не сработает:

SELECT
  id,
  category,
  amount,
  ROW_NUMBER() OVER (
    PARTITION BY category
    ORDER BY amount DESC
  ) AS row_num
FROM orders
WHERE row_num <= 3;

Проблема в порядке выполнения SQL: WHERE отрабатывает раньше, чем создаётся row_num.

Правильный вариант — использовать CTE или подзапрос:

WITH ranked_orders AS (
  SELECT
    id,
    category,
    amount,
    ROW_NUMBER() OVER (
      PARTITION BY category
      ORDER BY amount DESC, id
    ) AS row_num
  FROM orders
)
SELECT
  id,
  category,
  amount
FROM ranked_orders
WHERE row_num <= 3;

Это важный шаблон. Он постоянно встречается в задачах на топ-N, ранжирование и фильтрацию по оконным вычислениям.

Несколько оконных функций в одном запросе

В одном запросе можно использовать несколько оконных функций сразу.

Например, покажем заказ, средний чек по категории, место заказа внутри категории и предыдущую сумму заказа:

SELECT
  id,
  category,
  product,
  amount,
  AVG(amount) OVER (
    PARTITION BY category
  ) AS category_avg_amount,
  ROW_NUMBER() OVER (
    PARTITION BY category
    ORDER BY amount DESC, id
  ) AS category_row_num,
  LAG(amount) OVER (
    PARTITION BY category
    ORDER BY created_at, id
  ) AS prev_amount_in_category
FROM orders;

Здесь у каждой функции своё окно:

  • среднее считается по категории;
  • номер считается по категории и сумме;
  • предыдущее значение ищется по категории и дате.

Оконные функции можно комбинировать, но важно не терять смысл: у каждой функции должен быть понятный PARTITION BY и понятный ORDER BY.

Именованные окна

Если несколько функций используют одинаковое окно, его можно вынести в WINDOW.

SELECT
  id,
  category,
  product,
  amount,
  AVG(amount) OVER category_window AS category_avg_amount,
  SUM(amount) OVER category_window AS category_sum_amount
FROM orders
WINDOW category_window AS (
  PARTITION BY category
);

Это делает запрос короче и аккуратнее.

Но в учебных задачах чаще пишут окно прямо внутри OVER, чтобы сразу видеть логику рядом с функцией.

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

Думать, что PARTITION BY схлопывает строки

PARTITION BY не схлопывает строки. Он только говорит оконной функции, внутри какой группы считать.

Схлопывает строки GROUP BY.

Добавить ORDER BY в OVER и получить накопительный итог

Если написать:

SUM(amount) OVER (
  ORDER BY created_at
)

это будет накопительная сумма, а не сумма по всей таблице в каждой строке.

Для суммы по всей таблице нужен вариант без ORDER BY:

SUM(amount) OVER ()

Использовать ROW_NUMBER без стабильной сортировки

Если в ORDER BY есть одинаковые значения, результат может быть нестабильным.

Лучше добавлять уникальный столбец:

ROW_NUMBER() OVER (
  PARTITION BY category
  ORDER BY amount DESC, id
)

Пытаться фильтровать оконную функцию в WHERE

Нельзя так:

WHERE row_num <= 3

на том же уровне, где row_num создаётся.

Нужно использовать CTE или подзапрос.

Путать RANK и ROW_NUMBER

ROW_NUMBER() даёт ровно разные номера.

RANK() учитывает ничьи.

Если в задаче сказано «ровно три строки», обычно нужен ROW_NUMBER().

Если сказано «все, кто вошёл в топ-3 мест», может понадобиться RANK().

Мини-шпаргалка по оконным функциям

Задача Что использовать
Пронумеровать строки ROW_NUMBER()
Построить рейтинг с пропусками RANK()
Построить рейтинг без пропусков DENSE_RANK()
Взять предыдущее значение LAG()
Взять следующее значение LEAD()
Посчитать среднее без схлопывания строк AVG(...) OVER (...)
Посчитать накопительную сумму SUM(...) OVER (ORDER BY ...)
Посчитать скользящее среднее AVG(...) OVER (ORDER BY ... ROWS BETWEEN ... )
Найти топ-N в каждой группе ROW_NUMBER() или RANK() плюс CTE

Главное из статьи

Оконные функции позволяют считать значения по группе или по соседним строкам, не схлопывая результат.

Базовая форма:

function_name(...) OVER (
  PARTITION BY group_column
  ORDER BY sort_column
)

Главные элементы:

  • PARTITION BY делит строки на группы внутри оконной функции;
  • ORDER BY задаёт порядок строк внутри окна;
  • frame уточняет, какие строки вокруг текущей участвуют в расчёте.

Главные функции:

  • ROW_NUMBER() — уникальный номер строки;
  • RANK() — рейтинг с одинаковыми местами и пропусками;
  • DENSE_RANK() — рейтинг с одинаковыми местами без пропусков;
  • LAG() — значение из предыдущей строки;
  • LEAD() — значение из следующей строки.

Самые частые практические задачи:

  • топ-3 продукта в каждой категории;
  • сравнение выручки день ко дню;
  • накопительная сумма;
  • скользящее среднее;
  • среднее по группе рядом с каждой строкой.

Если говорить совсем просто: GROUP BY превращает много строк в одну, а оконные функции оставляют строки на месте и добавляют к ним умные расчёты. Именно поэтому они так полезны в аналитике, отчётах и задачах на собеседованиях.

Закрепи на практике

Решай задачи в SQL-тренажёре с мгновенной проверкой и подсказками.

Открыть тренажёр