SQLLAGLEADwindow

Что такое LAG и LEAD в SQL?

LAG и LEAD — это «возьми значение из предыдущей или следующей строки окна». Простыми словами: разница между соседними событиями (день-к-дню), время до следующего действия, изменение цены — задачи, которые без оконных функций требовали бы джойна таблицы саму к себе. С таблицами и частыми ошибками.

12 мин чтенияСправочникSQL · LAG · LEAD · window · tutorial

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

Запрос читается почти как рассказ:

  1. Для каждого товара возьми предыдущую цену.
  2. Сравни текущую цену с предыдущей.
  3. Поставь направление изменения.

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

Изменение статуса

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 становится гораздо сильнее для аналитики.

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

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

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