LAG и LEAD — это оконные функции, которые позволяют строке «посмотреть» на соседние строки.
LAG смотрит назад: берёт значение из предыдущей строки.
LEAD смотрит вперёд: берёт значение из следующей строки.
Звучит просто, но именно на этом строится огромное количество аналитики: сравнение с прошлым днём, поиск разницы между событиями, расчёт роста выручки, анализ изменения цены, промежутки между действиями пользователя.
Обычный SELECT работает построчно: каждая строка видит только свои значения. А LAG и LEAD дают строке память о соседях. Как будто в таблице появляется возможность сказать: «А что было до меня?» или «А что будет после меня?».
Зачем нужны LAG и LEAD
Представь таблицу с выручкой по дням.
| sale_date |
revenue |
| 2026-01-01 |
1000 |
| 2026-01-02 |
1200 |
| 2026-01-03 |
1100 |
| 2026-01-04 |
1500 |
Если смотреть только на одну строку, мы видим выручку конкретного дня. Но бизнесу обычно интересно не только «сколько было», а «как изменилось».
Например:
- выручка выросла или упала относительно прошлого дня;
- сколько дней прошло между двумя заказами клиента;
- через сколько минут пользователь сделал следующее действие;
- изменилась ли цена товара;
- какой был предыдущий статус заказа;
- насколько текущий месяц лучше прошлого.
Без оконных функций такие задачи часто превращаются в громоздкие self join, подзапросы и сложные условия. С LAG и LEAD всё становится гораздо чище: мы просто говорим базе, какую соседнюю строку нужно посмотреть.
Главное отличие LAG и LEAD
LAG берёт значение из предыдущей строки.
LAG(revenue) OVER (ORDER BY sale_date)
LEAD берёт значение из следующей строки.
LEAD(revenue) OVER (ORDER BY sale_date)
Но слово «предыдущая» или «следующая» имеет смысл только тогда, когда есть порядок. Поэтому почти всегда внутри OVER нужен ORDER BY.
Без сортировки база не понимает, какая строка должна считаться предыдущей, а какая следующей.
Базовый синтаксис
Общая форма выглядит так:
LAG(column_name, offset, default_value) OVER (
PARTITION BY group_column
ORDER BY sort_column
)
LEAD(column_name, offset, default_value) OVER (
PARTITION BY group_column
ORDER BY sort_column
)
Разберём аргументы.
column_name — значение, которое нужно взять из соседней строки.
offset — на сколько строк назад или вперёд смотреть. По умолчанию 1.
default_value — что вернуть, если соседней строки нет. По умолчанию возвращается NULL.
Простейший пример:
SELECT
sale_date,
revenue,
LAG(revenue) OVER (ORDER BY sale_date) AS prev_revenue,
LEAD(revenue) OVER (ORDER BY sale_date) AS next_revenue
FROM daily_revenue
ORDER BY sale_date;
Результат может быть таким:
| sale_date |
revenue |
prev_revenue |
next_revenue |
| 2026-01-01 |
1000 |
NULL |
1200 |
| 2026-01-02 |
1200 |
1000 |
1100 |
| 2026-01-03 |
1100 |
1200 |
1500 |
| 2026-01-04 |
1500 |
1100 |
NULL |
У первой строки нет предыдущей, поэтому prev_revenue равен NULL.
У последней строки нет следующей, поэтому next_revenue равен NULL.
LAG: сравнение с предыдущей строкой
Самый частый сценарий для LAG — сравнить текущее значение с предыдущим.
Допустим, есть таблица daily_revenue.
| sale_date |
revenue |
| 2026-01-01 |
1000 |
| 2026-01-02 |
1200 |
| 2026-01-03 |
1100 |
| 2026-01-04 |
1500 |
Хотим понять, на сколько изменилась выручка каждый день.
SELECT
sale_date,
revenue,
LAG(revenue) OVER (ORDER BY sale_date) AS prev_revenue,
revenue - LAG(revenue) OVER (ORDER BY sale_date) AS delta
FROM daily_revenue
ORDER BY sale_date;
Результат:
| sale_date |
revenue |
prev_revenue |
delta |
| 2026-01-01 |
1000 |
NULL |
NULL |
| 2026-01-02 |
1200 |
1000 |
200 |
| 2026-01-03 |
1100 |
1200 |
-100 |
| 2026-01-04 |
1500 |
1100 |
400 |
Теперь таблица читается гораздо богаче.
1 января сравнивать не с чем, поэтому NULL.
2 января выручка выросла на 200.
3 января упала на 100.
4 января выросла на 400.
Именно так обычно считают day-over-day изменения: текущий день минус предыдущий день.
Как не повторять LAG несколько раз
В примере выше LAG(revenue) написан дважды. Для маленького запроса это нормально, но в реальном коде часто удобнее сначала посчитать предыдущее значение в CTE, а потом использовать его.
WITH revenue_with_prev AS (
SELECT
sale_date,
revenue,
LAG(revenue) OVER (ORDER BY sale_date) AS prev_revenue
FROM daily_revenue
)
SELECT
sale_date,
revenue,
prev_revenue,
revenue - prev_revenue AS delta
FROM revenue_with_prev
ORDER BY sale_date;
Так запрос легче читать: сначала мы добавили колонку prev_revenue, потом спокойно посчитали разницу.
Для новичка это хорошая привычка. Если выражение становится длинным, лучше вынести его в CTE и дать понятное имя.
Default: что вернуть, если соседней строки нет
По умолчанию LAG и LEAD возвращают NULL, если соседней строки нет.
Но можно указать значение по умолчанию.
SELECT
sale_date,
visits,
LAG(visits, 1, 0) OVER (ORDER BY sale_date) AS prev_visits
FROM daily_stats
ORDER BY sale_date;
Здесь:
LAG(visits, 1, 0)
означает: возьми visits из строки на одну позицию назад, а если такой строки нет — верни 0.
Для первой строки prev_visits будет не NULL, а 0.
Но с дефолтами нужно быть аккуратным. Иногда NULL честнее, чем 0.
Если у первого дня нет предыдущего дня, это не значит, что вчера было ноль визитов. Это значит: «предыдущего значения в наборе нет». Поэтому в аналитике NULL часто лучше отражает реальность.
LEAD: взгляд на следующую строку
LEAD работает в другую сторону: смотрит не назад, а вперёд.
Это удобно, когда нужно понять, что случится после текущего события.
Например, есть таблица событий пользователя:
| user_id |
event_at |
event_name |
| 1 |
2026-01-01 10:00:00 |
page_view |
| 1 |
2026-01-01 10:02:00 |
click |
| 1 |
2026-01-01 10:05:00 |
purchase |
| 2 |
2026-01-01 11:00:00 |
page_view |
| 2 |
2026-01-01 11:10:00 |
click |
Хотим для каждого события узнать время следующего события того же пользователя.
SELECT
user_id,
event_at,
event_name,
LEAD(event_at) OVER (
PARTITION BY user_id
ORDER BY event_at
) AS next_event_at
FROM events
ORDER BY user_id, event_at;
Результат:
| user_id |
event_at |
event_name |
next_event_at |
| 1 |
2026-01-01 10:00:00 |
page_view |
2026-01-01 10:02:00 |
| 1 |
2026-01-01 10:02:00 |
click |
2026-01-01 10:05:00 |
| 1 |
2026-01-01 10:05:00 |
purchase |
NULL |
| 2 |
2026-01-01 11:00:00 |
page_view |
2026-01-01 11:10:00 |
| 2 |
2026-01-01 11:10:00 |
click |
NULL |
У последнего события каждого пользователя следующего события нет, поэтому там NULL.
Time-to-next-event: время до следующего события
Теперь можно посчитать промежуток между текущим и следующим событием.
WITH events_with_next AS (
SELECT
user_id,
event_at,
event_name,
LEAD(event_at) OVER (
PARTITION BY user_id
ORDER BY event_at
) AS next_event_at
FROM events
)
SELECT
user_id,
event_at,
event_name,
next_event_at,
next_event_at - event_at AS gap_to_next_event
FROM events_with_next
ORDER BY user_id, event_at;
Такой запрос отвечает на вопросы вроде:
- сколько времени прошло до следующего клика;
- как быстро пользователь дошёл до покупки;
- какой интервал между визитами;
- где в сценарии пользователь «завис» надолго.
Здесь особенно важен PARTITION BY user_id.
Без него база сравнила бы событие одного пользователя со следующим событием другого пользователя. Формально запрос сработал бы, но результат был бы бессмысленным.
PARTITION BY: не смешиваем разные группы
PARTITION BY делит строки на отдельные группы, внутри которых работает оконная функция.
Если мы анализируем заказы клиентов, предыдущий заказ нужно искать только среди заказов того же клиента.
SELECT
customer_id,
order_id,
created_at,
amount,
LAG(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at
) AS prev_amount
FROM orders
ORDER BY customer_id, created_at;
Что здесь происходит:
- база делит строки по
customer_id;
- внутри каждого клиента сортирует заказы по
created_at;
- для каждого заказа берёт сумму предыдущего заказа этого же клиента.
Это важная мысль: LAG и LEAD не должны случайно перескакивать между пользователями, товарами, странами или проектами, если сравнение имеет смысл только внутри группы.
У первой строки каждой партиции LAG вернёт NULL.
У последней строки каждой партиции LEAD вернёт NULL.
Пример: предыдущий заказ клиента
Допустим, есть таблица orders.
| order_id |
customer_id |
created_at |
amount |
| 101 |
1 |
2026-01-01 |
500 |
| 102 |
1 |
2026-01-10 |
700 |
| 103 |
2 |
2026-01-03 |
300 |
| 104 |
2 |
2026-01-08 |
450 |
| 105 |
1 |
2026-01-20 |
900 |
Запрос:
SELECT
customer_id,
order_id,
created_at,
amount,
LAG(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at
) AS prev_amount
FROM orders
ORDER BY customer_id, created_at;
Результат:
| customer_id |
order_id |
created_at |
amount |
prev_amount |
| 1 |
101 |
2026-01-01 |
500 |
NULL |
| 1 |
102 |
2026-01-10 |
700 |
500 |
| 1 |
105 |
2026-01-20 |
900 |
700 |
| 2 |
103 |
2026-01-03 |
300 |
NULL |
| 2 |
104 |
2026-01-08 |
450 |
300 |
Для клиента 1 предыдущие заказы ищутся только среди заказов клиента 1.
Для клиента 2 — только среди заказов клиента 2.
Так и должно быть: «предыдущий заказ другого клиента» в такой задаче не имеет смысла.
Offset больше 1: не только соседняя строка
Второй аргумент LAG или LEAD показывает, на сколько строк назад или вперёд нужно смотреть.
По умолчанию используется 1.
LAG(visits) OVER (ORDER BY sale_date)
То же самое, что:
LAG(visits, 1) OVER (ORDER BY sale_date)
Но можно посмотреть на две, три, семь строк назад.
Например, если в таблице есть ежедневная статистика без пропусков, можно сравнить день с показателем неделю назад:
SELECT
stat_date,
visits,
LAG(visits, 7) OVER (ORDER BY stat_date) AS visits_week_ago,
visits - LAG(visits, 7) OVER (ORDER BY stat_date) AS week_delta
FROM daily_stats
ORDER BY stat_date;
LAG(visits, 7) берёт значение на семь строк раньше.
Важно: это именно семь строк, а не обязательно семь календарных дней. Если в таблице пропущены даты, результат может быть не тем, что ты ожидаешь.
Если нужно сравнение именно с датой неделю назад, иногда лучше использовать соединение по дате:
SELECT
current_stats.stat_date,
current_stats.visits,
previous_stats.visits AS visits_week_ago
FROM daily_stats AS current_stats
LEFT JOIN daily_stats AS previous_stats
ON previous_stats.stat_date = current_stats.stat_date - INTERVAL '7 days'
ORDER BY current_stats.stat_date;
LAG смотрит на позицию строки в отсортированном наборе. Это мощно, но важно понимать, что позиция и календарная дата — не одно и то же.
Изменение цены товара
Ещё один классический пример — история цен.
Есть таблица price_history.
| product_id |
price_date |
price |
| 10 |
2026-01-01 |
100 |
| 10 |
2026-01-05 |
120 |
| 10 |
2026-01-10 |
120 |
| 10 |
2026-01-15 |
90 |
| 20 |
2026-01-03 |
500 |
| 20 |
2026-01-08 |
550 |
Хотим понять, цена выросла, упала или осталась прежней.
WITH prices_with_prev AS (
SELECT
product_id,
price_date,
price,
LAG(price) OVER (
PARTITION BY product_id
ORDER BY price_date
) AS prev_price
FROM price_history
)
SELECT
product_id,
price_date,
price,
prev_price,
CASE
WHEN prev_price IS NULL THEN 'first'
WHEN price > prev_price THEN 'up'
WHEN price < prev_price THEN 'down'
ELSE 'same'
END AS price_direction
FROM prices_with_prev
ORDER BY product_id, price_date;
Результат:
| product_id |
price_date |
price |
prev_price |
price_direction |
| 10 |
2026-01-01 |
100 |
NULL |
first |
| 10 |
2026-01-05 |
120 |
100 |
up |
| 10 |
2026-01-10 |
120 |
120 |
same |
| 10 |
2026-01-15 |
90 |
120 |
down |
| 20 |
2026-01-03 |
500 |
NULL |
first |
| 20 |
2026-01-08 |
550 |
500 |
up |
Запрос читается почти как рассказ:
- Для каждого товара возьми предыдущую цену.
- Сравни текущую цену с предыдущей.
- Поставь направление изменения.
Такой подход часто используют в аналитике, мониторинге цен, аудитах изменений и отчётах по истории статусов.
Изменение статуса
LAG полезен не только для чисел. Он отлично работает и со строками.
Например, есть история статусов заказов:
| order_id |
changed_at |
status |
| 1001 |
2026-01-01 10:00:00 |
created |
| 1001 |
2026-01-01 10:05:00 |
paid |
| 1001 |
2026-01-01 10:20:00 |
shipped |
| 1001 |
2026-01-02 12:00:00 |
delivered |
Можно посмотреть, из какого статуса заказ перешёл в текущий.
SELECT
order_id,
changed_at,
status,
LAG(status) OVER (
PARTITION BY order_id
ORDER BY changed_at
) AS prev_status
FROM order_status_history
ORDER BY order_id, changed_at;
Результат:
| order_id |
changed_at |
status |
prev_status |
| 1001 |
2026-01-01 10:00:00 |
created |
NULL |
| 1001 |
2026-01-01 10:05:00 |
paid |
created |
| 1001 |
2026-01-01 10:20:00 |
shipped |
paid |
| 1001 |
2026-01-02 12:00:00 |
delivered |
shipped |
Теперь можно анализировать переходы: из какого статуса в какой чаще всего переходят, где заказы застревают, какие цепочки выглядят подозрительно.
Важность стабильной сортировки
Для LAG и LEAD сортировка — это не украшение, а основа смысла.
Например:
LAG(amount) OVER (ORDER BY created_at)
говорит: предыдущая строка определяется по времени создания.
Но что, если у двух заказов одинаковый created_at?
Тогда порядок между ними может быть нестабильным. Сегодня база поставит один заказ раньше, завтра при другом плане выполнения — другой. Это редкая, но неприятная ловушка.
Лучше добавлять уникальный ключ в сортировку:
LAG(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, order_id
)
Теперь порядок стабильный: сначала по дате, а если даты совпали — по order_id.
Для аналитики это очень важно. Если ты сравниваешь «предыдущее» и «следующее», порядок должен быть предсказуемым.
LAG и LEAD нельзя использовать в WHERE напрямую
Новички часто хотят написать так:
SELECT
order_id,
amount,
LAG(amount) OVER (ORDER BY created_at) AS prev_amount
FROM orders
WHERE LAG(amount) OVER (ORDER BY created_at) IS NOT NULL;
Так нельзя.
Причина в порядке выполнения SQL. WHERE отрабатывает раньше, чем оконные функции. На этапе WHERE результата LAG ещё не существует.
Правильный вариант — сначала посчитать оконную функцию в CTE или подзапросе, а потом фильтровать.
WITH orders_with_prev AS (
SELECT
order_id,
amount,
created_at,
LAG(amount) OVER (ORDER BY created_at) AS prev_amount
FROM orders
)
SELECT
order_id,
amount,
created_at,
prev_amount
FROM orders_with_prev
WHERE prev_amount IS NOT NULL
ORDER BY created_at;
Это универсальный приём: если нужно фильтровать по результату оконной функции, заверни запрос в CTE.
FIRST_VALUE и LAST_VALUE: близкие родственники
Рядом с LAG и LEAD часто встречаются функции FIRST_VALUE и LAST_VALUE.
FIRST_VALUE берёт первое значение в окне.
SELECT
customer_id,
order_id,
created_at,
amount,
FIRST_VALUE(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, order_id
) AS first_amount
FROM orders
ORDER BY customer_id, created_at, order_id;
Этот запрос покажет сумму первого заказа клиента рядом с каждым его заказом.
С LAST_VALUE есть важная тонкость. Многие ожидают, что она вернёт последнее значение во всей группе. Но по умолчанию оконная рамка часто заканчивается на текущей строке. Поэтому LAST_VALUE без явной рамки может вернуть значение текущей строки, а не настоящую последнюю строку группы.
Чтобы получить именно последнее значение по всей партиции, рамку лучше указать явно:
SELECT
customer_id,
order_id,
created_at,
amount,
LAST_VALUE(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, order_id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_amount
FROM orders
ORDER BY customer_id, created_at, order_id;
Здесь рамка говорит: «смотри от самой первой строки партиции до самой последней».
Для LAG и LEAD эта ловушка обычно не так заметна, потому что они берут строку по смещению. Но если рядом в запросе появляется LAST_VALUE, про рамку нужно помнить.
Производительность LAG и LEAD
LAG и LEAD удобные, но не бесплатные.
Чтобы найти предыдущую или следующую строку, базе нужно упорядочить данные внутри окна. Поэтому запросы с оконными функциями часто требуют сортировки.
Например:
LAG(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at
)
Базе удобно, если есть индекс, похожий на порядок окна:
CREATE INDEX orders_customer_created_idx
ON orders (customer_id, created_at);
Такой индекс может помочь, потому что строки уже лежат ближе к нужному порядку: сначала customer_id, потом created_at.
Но всё зависит от таблицы, фильтров, объёма данных и плана выполнения. На маленьких таблицах ты разницы не заметишь. На десятках и сотнях миллионов строк сортировка может стать главным местом затрат.
Для новичка достаточно запомнить:
LAG и LEAD обычно требуют сортировки;
- большие таблицы лучше анализировать с подходящими индексами;
- полезно смотреть план через
EXPLAIN ANALYZE;
- не стоит бездумно гонять оконные функции по огромной таблице без фильтров.
Частые ошибки с LAG и LEAD
Забыть ORDER BY внутри OVER
Плохой вариант:
SELECT
sale_date,
revenue,
LAG(revenue) OVER () AS prev_revenue
FROM daily_revenue;
Здесь нет понятного порядка. А если нет порядка, нет и нормального смысла слова «предыдущий».
Правильно:
SELECT
sale_date,
revenue,
LAG(revenue) OVER (ORDER BY sale_date) AS prev_revenue
FROM daily_revenue;
А ещё лучше, если возможны одинаковые даты, добавить уникальный ключ:
SELECT
sale_date,
revenue,
LAG(revenue) OVER (ORDER BY sale_date, revenue_id) AS prev_revenue
FROM daily_revenue;
Забыть PARTITION BY
Если нужно сравнивать заказы внутри клиента, обязательно разделяй строки по клиенту.
Плохой вариант:
SELECT
customer_id,
order_id,
amount,
LAG(amount) OVER (ORDER BY created_at) AS prev_amount
FROM orders;
Здесь предыдущий заказ может принадлежать другому клиенту.
Правильно:
SELECT
customer_id,
order_id,
amount,
LAG(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, order_id
) AS prev_amount
FROM orders;
Думать, что offset — это дни, месяцы или секунды
LAG(visits, 7) означает «семь строк назад», а не «семь дней назад».
SELECT
stat_date,
visits,
LAG(visits, 7) OVER (ORDER BY stat_date) AS visits_7_rows_ago
FROM daily_stats;
Если в таблице есть пропуски дат, семь строк назад могут оказаться не ровно неделей назад.
Для календарного сравнения иногда нужен JOIN по дате или календарная таблица.
Слишком рано заменять NULL на 0
Так можно:
SELECT
sale_date,
revenue,
LAG(revenue, 1, 0) OVER (ORDER BY sale_date) AS prev_revenue
FROM daily_revenue;
Но подумай, что означает 0.
Если предыдущей строки нет, это не всегда равно нулевой выручке. Иногда честнее оставить NULL, чтобы было видно: данных для сравнения нет.
Использовать LAG или LEAD в WHERE
Так нельзя, потому что WHERE выполняется до оконных функций.
Правильно — через CTE:
WITH t AS (
SELECT
order_id,
amount,
created_at,
LAG(amount) OVER (ORDER BY created_at, order_id) AS prev_amount
FROM orders
)
SELECT
order_id,
amount,
created_at,
prev_amount
FROM t
WHERE prev_amount IS NOT NULL;
Забыть про стабильную сортировку
Если сортируешь только по колонке, где бывают одинаковые значения, результат может быть нестабильным.
Лучше:
ORDER BY created_at, order_id
чем просто:
ORDER BY created_at
Особенно если ты готовишь отчёт, тест, витрину данных или задачу, где результат должен повторяться один в один.
Как читать запросы с LAG и LEAD
Вот запрос:
SELECT
customer_id,
order_id,
amount,
LAG(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, order_id
) AS prev_amount
FROM orders;
Читай его так:
«Для каждого заказа покажи сумму заказа. Ещё покажи сумму предыдущего заказа этого же клиента, если отсортировать его заказы по дате и идентификатору».
А вот запрос:
SELECT
user_id,
event_at,
LEAD(event_at) OVER (
PARTITION BY user_id
ORDER BY event_at
) AS next_event_at
FROM events;
Читай так:
«Для каждого события покажи время следующего события того же пользователя».
Когда ты начинаешь читать оконные функции человеческими фразами, они становятся намного проще. OVER отвечает на вопрос: «внутри какого набора строк и в каком порядке мы смотрим соседей?».
Главное про LAG и LEAD
LAG берёт значение из предыдущей строки окна.
LEAD берёт значение из следующей строки окна.
Базовый пример:
SELECT
sale_date,
revenue,
LAG(revenue) OVER (ORDER BY sale_date) AS prev_revenue,
LEAD(revenue) OVER (ORDER BY sale_date) AS next_revenue
FROM daily_revenue
ORDER BY sale_date;
ORDER BY внутри OVER почти всегда обязателен, потому что без порядка нет понятия предыдущей и следующей строки.
PARTITION BY нужен, когда сравнение должно идти внутри группы: по пользователю, клиенту, товару, региону, заказу.
Третий аргумент позволяет указать значение по умолчанию:
LAG(revenue, 1, 0) OVER (ORDER BY sale_date)
Но NULL не всегда нужно заменять на 0: иногда отсутствие предыдущей строки лучше оставить как отсутствие данных.
LAG и LEAD особенно полезны для задач:
- сравнить текущий день с предыдущим;
- найти разницу между соседними событиями;
- посчитать время до следующего действия;
- отследить изменение цены;
- увидеть переходы между статусами;
- сравнить заказ клиента с его прошлым заказом.
Главная идея простая: LAG и LEAD превращают таблицу из набора отдельных строк в последовательность. А когда у строки появляется «прошлое» и «будущее», SQL становится гораздо сильнее для аналитики.
LAGиLEAD— это оконные функции, которые позволяют строке «посмотреть» на соседние строки.LAGсмотрит назад: берёт значение из предыдущей строки.LEADсмотрит вперёд: берёт значение из следующей строки.Звучит просто, но именно на этом строится огромное количество аналитики: сравнение с прошлым днём, поиск разницы между событиями, расчёт роста выручки, анализ изменения цены, промежутки между действиями пользователя.
Обычный
SELECTработает построчно: каждая строка видит только свои значения. АLAGиLEADдают строке память о соседях. Как будто в таблице появляется возможность сказать: «А что было до меня?» или «А что будет после меня?».Зачем нужны LAG и LEAD
Представь таблицу с выручкой по дням.
Если смотреть только на одну строку, мы видим выручку конкретного дня. Но бизнесу обычно интересно не только «сколько было», а «как изменилось».
Например:
Без оконных функций такие задачи часто превращаются в громоздкие
self join, подзапросы и сложные условия. СLAGиLEADвсё становится гораздо чище: мы просто говорим базе, какую соседнюю строку нужно посмотреть.Главное отличие LAG и LEAD
LAGберёт значение из предыдущей строки.LAG(revenue) OVER (ORDER BY sale_date)LEADберёт значение из следующей строки.LEAD(revenue) OVER (ORDER BY sale_date)Но слово «предыдущая» или «следующая» имеет смысл только тогда, когда есть порядок. Поэтому почти всегда внутри
OVERнуженORDER BY.Без сортировки база не понимает, какая строка должна считаться предыдущей, а какая следующей.
Базовый синтаксис
Общая форма выглядит так:
LAG(column_name, offset, default_value) OVER ( PARTITION BY group_column ORDER BY sort_column ) LEAD(column_name, offset, default_value) OVER ( PARTITION BY group_column ORDER BY sort_column )Разберём аргументы.
column_name— значение, которое нужно взять из соседней строки.offset— на сколько строк назад или вперёд смотреть. По умолчанию1.default_value— что вернуть, если соседней строки нет. По умолчанию возвращаетсяNULL.Простейший пример:
SELECT sale_date, revenue, LAG(revenue) OVER (ORDER BY sale_date) AS prev_revenue, LEAD(revenue) OVER (ORDER BY sale_date) AS next_revenue FROM daily_revenue ORDER BY sale_date;Результат может быть таким:
У первой строки нет предыдущей, поэтому
prev_revenueравенNULL.У последней строки нет следующей, поэтому
next_revenueравенNULL.LAG: сравнение с предыдущей строкой
Самый частый сценарий для
LAG— сравнить текущее значение с предыдущим.Допустим, есть таблица
daily_revenue.Хотим понять, на сколько изменилась выручка каждый день.
SELECT sale_date, revenue, LAG(revenue) OVER (ORDER BY sale_date) AS prev_revenue, revenue - LAG(revenue) OVER (ORDER BY sale_date) AS delta FROM daily_revenue ORDER BY sale_date;Результат:
Теперь таблица читается гораздо богаче.
1 января сравнивать не с чем, поэтому
NULL.2 января выручка выросла на
200.3 января упала на
100.4 января выросла на
400.Именно так обычно считают day-over-day изменения: текущий день минус предыдущий день.
Как не повторять LAG несколько раз
В примере выше
LAG(revenue)написан дважды. Для маленького запроса это нормально, но в реальном коде часто удобнее сначала посчитать предыдущее значение в CTE, а потом использовать его.WITH revenue_with_prev AS ( SELECT sale_date, revenue, LAG(revenue) OVER (ORDER BY sale_date) AS prev_revenue FROM daily_revenue ) SELECT sale_date, revenue, prev_revenue, revenue - prev_revenue AS delta FROM revenue_with_prev ORDER BY sale_date;Так запрос легче читать: сначала мы добавили колонку
prev_revenue, потом спокойно посчитали разницу.Для новичка это хорошая привычка. Если выражение становится длинным, лучше вынести его в CTE и дать понятное имя.
Default: что вернуть, если соседней строки нет
По умолчанию
LAGиLEADвозвращаютNULL, если соседней строки нет.Но можно указать значение по умолчанию.
SELECT sale_date, visits, LAG(visits, 1, 0) OVER (ORDER BY sale_date) AS prev_visits FROM daily_stats ORDER BY sale_date;Здесь:
LAG(visits, 1, 0)означает: возьми
visitsиз строки на одну позицию назад, а если такой строки нет — верни0.Для первой строки
prev_visitsбудет неNULL, а0.Но с дефолтами нужно быть аккуратным. Иногда
NULLчестнее, чем0.Если у первого дня нет предыдущего дня, это не значит, что вчера было ноль визитов. Это значит: «предыдущего значения в наборе нет». Поэтому в аналитике
NULLчасто лучше отражает реальность.LEAD: взгляд на следующую строку
LEADработает в другую сторону: смотрит не назад, а вперёд.Это удобно, когда нужно понять, что случится после текущего события.
Например, есть таблица событий пользователя:
Хотим для каждого события узнать время следующего события того же пользователя.
SELECT user_id, event_at, event_name, LEAD(event_at) OVER ( PARTITION BY user_id ORDER BY event_at ) AS next_event_at FROM events ORDER BY user_id, event_at;Результат:
У последнего события каждого пользователя следующего события нет, поэтому там
NULL.Time-to-next-event: время до следующего события
Теперь можно посчитать промежуток между текущим и следующим событием.
WITH events_with_next AS ( SELECT user_id, event_at, event_name, LEAD(event_at) OVER ( PARTITION BY user_id ORDER BY event_at ) AS next_event_at FROM events ) SELECT user_id, event_at, event_name, next_event_at, next_event_at - event_at AS gap_to_next_event FROM events_with_next ORDER BY user_id, event_at;Такой запрос отвечает на вопросы вроде:
Здесь особенно важен
PARTITION BY user_id.Без него база сравнила бы событие одного пользователя со следующим событием другого пользователя. Формально запрос сработал бы, но результат был бы бессмысленным.
PARTITION BY: не смешиваем разные группы
PARTITION BYделит строки на отдельные группы, внутри которых работает оконная функция.Если мы анализируем заказы клиентов, предыдущий заказ нужно искать только среди заказов того же клиента.
SELECT customer_id, order_id, created_at, amount, LAG(amount) OVER ( PARTITION BY customer_id ORDER BY created_at ) AS prev_amount FROM orders ORDER BY customer_id, created_at;Что здесь происходит:
customer_id;created_at;Это важная мысль:
LAGиLEADне должны случайно перескакивать между пользователями, товарами, странами или проектами, если сравнение имеет смысл только внутри группы.У первой строки каждой партиции
LAGвернётNULL.У последней строки каждой партиции
LEADвернётNULL.Пример: предыдущий заказ клиента
Допустим, есть таблица
orders.Запрос:
SELECT customer_id, order_id, created_at, amount, LAG(amount) OVER ( PARTITION BY customer_id ORDER BY created_at ) AS prev_amount FROM orders ORDER BY customer_id, created_at;Результат:
Для клиента
1предыдущие заказы ищутся только среди заказов клиента1.Для клиента
2— только среди заказов клиента2.Так и должно быть: «предыдущий заказ другого клиента» в такой задаче не имеет смысла.
Offset больше 1: не только соседняя строка
Второй аргумент
LAGилиLEADпоказывает, на сколько строк назад или вперёд нужно смотреть.По умолчанию используется
1.LAG(visits) OVER (ORDER BY sale_date)То же самое, что:
LAG(visits, 1) OVER (ORDER BY sale_date)Но можно посмотреть на две, три, семь строк назад.
Например, если в таблице есть ежедневная статистика без пропусков, можно сравнить день с показателем неделю назад:
SELECT stat_date, visits, LAG(visits, 7) OVER (ORDER BY stat_date) AS visits_week_ago, visits - LAG(visits, 7) OVER (ORDER BY stat_date) AS week_delta FROM daily_stats ORDER BY stat_date;LAG(visits, 7)берёт значение на семь строк раньше.Важно: это именно семь строк, а не обязательно семь календарных дней. Если в таблице пропущены даты, результат может быть не тем, что ты ожидаешь.
Если нужно сравнение именно с датой неделю назад, иногда лучше использовать соединение по дате:
SELECT current_stats.stat_date, current_stats.visits, previous_stats.visits AS visits_week_ago FROM daily_stats AS current_stats LEFT JOIN daily_stats AS previous_stats ON previous_stats.stat_date = current_stats.stat_date - INTERVAL '7 days' ORDER BY current_stats.stat_date;LAGсмотрит на позицию строки в отсортированном наборе. Это мощно, но важно понимать, что позиция и календарная дата — не одно и то же.Изменение цены товара
Ещё один классический пример — история цен.
Есть таблица
price_history.Хотим понять, цена выросла, упала или осталась прежней.
WITH prices_with_prev AS ( SELECT product_id, price_date, price, LAG(price) OVER ( PARTITION BY product_id ORDER BY price_date ) AS prev_price FROM price_history ) SELECT product_id, price_date, price, prev_price, CASE WHEN prev_price IS NULL THEN 'first' WHEN price > prev_price THEN 'up' WHEN price < prev_price THEN 'down' ELSE 'same' END AS price_direction FROM prices_with_prev ORDER BY product_id, price_date;Результат:
Запрос читается почти как рассказ:
Такой подход часто используют в аналитике, мониторинге цен, аудитах изменений и отчётах по истории статусов.
Изменение статуса
LAGполезен не только для чисел. Он отлично работает и со строками.Например, есть история статусов заказов:
Можно посмотреть, из какого статуса заказ перешёл в текущий.
SELECT order_id, changed_at, status, LAG(status) OVER ( PARTITION BY order_id ORDER BY changed_at ) AS prev_status FROM order_status_history ORDER BY order_id, changed_at;Результат:
Теперь можно анализировать переходы: из какого статуса в какой чаще всего переходят, где заказы застревают, какие цепочки выглядят подозрительно.
Важность стабильной сортировки
Для
LAGиLEADсортировка — это не украшение, а основа смысла.Например:
LAG(amount) OVER (ORDER BY created_at)говорит: предыдущая строка определяется по времени создания.
Но что, если у двух заказов одинаковый
created_at?Тогда порядок между ними может быть нестабильным. Сегодня база поставит один заказ раньше, завтра при другом плане выполнения — другой. Это редкая, но неприятная ловушка.
Лучше добавлять уникальный ключ в сортировку:
LAG(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, order_id )Теперь порядок стабильный: сначала по дате, а если даты совпали — по
order_id.Для аналитики это очень важно. Если ты сравниваешь «предыдущее» и «следующее», порядок должен быть предсказуемым.
LAG и LEAD нельзя использовать в WHERE напрямую
Новички часто хотят написать так:
SELECT order_id, amount, LAG(amount) OVER (ORDER BY created_at) AS prev_amount FROM orders WHERE LAG(amount) OVER (ORDER BY created_at) IS NOT NULL;Так нельзя.
Причина в порядке выполнения SQL.
WHEREотрабатывает раньше, чем оконные функции. На этапеWHEREрезультатаLAGещё не существует.Правильный вариант — сначала посчитать оконную функцию в CTE или подзапросе, а потом фильтровать.
WITH orders_with_prev AS ( SELECT order_id, amount, created_at, LAG(amount) OVER (ORDER BY created_at) AS prev_amount FROM orders ) SELECT order_id, amount, created_at, prev_amount FROM orders_with_prev WHERE prev_amount IS NOT NULL ORDER BY created_at;Это универсальный приём: если нужно фильтровать по результату оконной функции, заверни запрос в CTE.
FIRST_VALUE и LAST_VALUE: близкие родственники
Рядом с
LAGиLEADчасто встречаются функцииFIRST_VALUEиLAST_VALUE.FIRST_VALUEберёт первое значение в окне.SELECT customer_id, order_id, created_at, amount, FIRST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, order_id ) AS first_amount FROM orders ORDER BY customer_id, created_at, order_id;Этот запрос покажет сумму первого заказа клиента рядом с каждым его заказом.
С
LAST_VALUEесть важная тонкость. Многие ожидают, что она вернёт последнее значение во всей группе. Но по умолчанию оконная рамка часто заканчивается на текущей строке. ПоэтомуLAST_VALUEбез явной рамки может вернуть значение текущей строки, а не настоящую последнюю строку группы.Чтобы получить именно последнее значение по всей партиции, рамку лучше указать явно:
SELECT customer_id, order_id, created_at, amount, LAST_VALUE(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, order_id ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_amount FROM orders ORDER BY customer_id, created_at, order_id;Здесь рамка говорит: «смотри от самой первой строки партиции до самой последней».
Для
LAGиLEADэта ловушка обычно не так заметна, потому что они берут строку по смещению. Но если рядом в запросе появляетсяLAST_VALUE, про рамку нужно помнить.Производительность LAG и LEAD
LAGиLEADудобные, но не бесплатные.Чтобы найти предыдущую или следующую строку, базе нужно упорядочить данные внутри окна. Поэтому запросы с оконными функциями часто требуют сортировки.
Например:
LAG(amount) OVER ( PARTITION BY customer_id ORDER BY created_at )Базе удобно, если есть индекс, похожий на порядок окна:
CREATE INDEX orders_customer_created_idx ON orders (customer_id, created_at);Такой индекс может помочь, потому что строки уже лежат ближе к нужному порядку: сначала
customer_id, потомcreated_at.Но всё зависит от таблицы, фильтров, объёма данных и плана выполнения. На маленьких таблицах ты разницы не заметишь. На десятках и сотнях миллионов строк сортировка может стать главным местом затрат.
Для новичка достаточно запомнить:
LAGиLEADобычно требуют сортировки;EXPLAIN ANALYZE;Частые ошибки с LAG и LEAD
Забыть ORDER BY внутри OVER
Плохой вариант:
SELECT sale_date, revenue, LAG(revenue) OVER () AS prev_revenue FROM daily_revenue;Здесь нет понятного порядка. А если нет порядка, нет и нормального смысла слова «предыдущий».
Правильно:
SELECT sale_date, revenue, LAG(revenue) OVER (ORDER BY sale_date) AS prev_revenue FROM daily_revenue;А ещё лучше, если возможны одинаковые даты, добавить уникальный ключ:
SELECT sale_date, revenue, LAG(revenue) OVER (ORDER BY sale_date, revenue_id) AS prev_revenue FROM daily_revenue;Забыть PARTITION BY
Если нужно сравнивать заказы внутри клиента, обязательно разделяй строки по клиенту.
Плохой вариант:
SELECT customer_id, order_id, amount, LAG(amount) OVER (ORDER BY created_at) AS prev_amount FROM orders;Здесь предыдущий заказ может принадлежать другому клиенту.
Правильно:
SELECT customer_id, order_id, amount, LAG(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, order_id ) AS prev_amount FROM orders;Думать, что offset — это дни, месяцы или секунды
LAG(visits, 7)означает «семь строк назад», а не «семь дней назад».SELECT stat_date, visits, LAG(visits, 7) OVER (ORDER BY stat_date) AS visits_7_rows_ago FROM daily_stats;Если в таблице есть пропуски дат, семь строк назад могут оказаться не ровно неделей назад.
Для календарного сравнения иногда нужен
JOINпо дате или календарная таблица.Слишком рано заменять NULL на 0
Так можно:
SELECT sale_date, revenue, LAG(revenue, 1, 0) OVER (ORDER BY sale_date) AS prev_revenue FROM daily_revenue;Но подумай, что означает
0.Если предыдущей строки нет, это не всегда равно нулевой выручке. Иногда честнее оставить
NULL, чтобы было видно: данных для сравнения нет.Использовать LAG или LEAD в WHERE
Так нельзя, потому что
WHEREвыполняется до оконных функций.Правильно — через CTE:
WITH t AS ( SELECT order_id, amount, created_at, LAG(amount) OVER (ORDER BY created_at, order_id) AS prev_amount FROM orders ) SELECT order_id, amount, created_at, prev_amount FROM t WHERE prev_amount IS NOT NULL;Забыть про стабильную сортировку
Если сортируешь только по колонке, где бывают одинаковые значения, результат может быть нестабильным.
Лучше:
ORDER BY created_at, order_idчем просто:
ORDER BY created_atОсобенно если ты готовишь отчёт, тест, витрину данных или задачу, где результат должен повторяться один в один.
Как читать запросы с LAG и LEAD
Вот запрос:
SELECT customer_id, order_id, amount, LAG(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, order_id ) AS prev_amount FROM orders;Читай его так:
«Для каждого заказа покажи сумму заказа. Ещё покажи сумму предыдущего заказа этого же клиента, если отсортировать его заказы по дате и идентификатору».
А вот запрос:
SELECT user_id, event_at, LEAD(event_at) OVER ( PARTITION BY user_id ORDER BY event_at ) AS next_event_at FROM events;Читай так:
«Для каждого события покажи время следующего события того же пользователя».
Когда ты начинаешь читать оконные функции человеческими фразами, они становятся намного проще.
OVERотвечает на вопрос: «внутри какого набора строк и в каком порядке мы смотрим соседей?».Главное про LAG и LEAD
LAGберёт значение из предыдущей строки окна.LEADберёт значение из следующей строки окна.Базовый пример:
SELECT sale_date, revenue, LAG(revenue) OVER (ORDER BY sale_date) AS prev_revenue, LEAD(revenue) OVER (ORDER BY sale_date) AS next_revenue FROM daily_revenue ORDER BY sale_date;ORDER BYвнутриOVERпочти всегда обязателен, потому что без порядка нет понятия предыдущей и следующей строки.PARTITION BYнужен, когда сравнение должно идти внутри группы: по пользователю, клиенту, товару, региону, заказу.Третий аргумент позволяет указать значение по умолчанию:
LAG(revenue, 1, 0) OVER (ORDER BY sale_date)Но
NULLне всегда нужно заменять на0: иногда отсутствие предыдущей строки лучше оставить как отсутствие данных.LAGиLEADособенно полезны для задач:Главная идея простая:
LAGиLEADпревращают таблицу из набора отдельных строк в последовательность. А когда у строки появляется «прошлое» и «будущее», SQL становится гораздо сильнее для аналитики.