SQL para análise de dados · Guias · 30 de outubro de 2026 · 14 min de leitura

LAG e LEAD no SQL: linha anterior e próxima linha, com exemplos

Ilustração plana de uma fileira de cartões de painel de partidas com setas apontando para o cartão anterior e para o próximo, e um avião no céu

LAG(coluna) devolve o valor de coluna na linha anterior, e LEAD(coluna) na próxima linha, na ordem que você define com OVER (ORDER BY ...). As duas mantêm todas as linhas no resultado, então dá para pôr “este período” e “o período anterior” lado a lado e subtrair. Acrescente PARTITION BY para olhar só as linhas anteriores ou seguintes do mesmo cliente, produto ou voo. A sintaxe completa é LAG(coluna, offset, padrão): offset é quantas linhas para trás (1 por padrão) e padrão é o que devolver quando essa linha não existe (NULL por padrão).

Todos os exemplos rodam no banco de dados da companhia aérea do curso de SQL da Nomto, com sintaxe do PostgreSQL sobre o DuckDB (o motor da sala de SQL da Nomto), e cada tabela de resultado é a saída real da consulta. LAG e LEAD são funções de janela; se OVER e PARTITION BY são novidade para você, comece pelo guia de funções de janela no SQL.

LAG e LEAD: a linha anterior e a próxima

Reservas por dia no início de agosto, com o dia anterior e o dia seguinte em cada linha:

WITH daily AS (
  SELECT CAST(book_date AS date) AS day, COUNT(*) AS bookings
  FROM bookings
  WHERE book_date >= '2026-08-01' AND book_date < '2026-08-06'
  GROUP BY CAST(book_date AS date)
)
SELECT day, bookings,
       LAG(bookings)  OVER (ORDER BY day) AS prev_day,
       LEAD(bookings) OVER (ORDER BY day) AS next_day
FROM daily
ORDER BY day;
day bookings prev_day next_day
2026-08-01 5746 NULL 5761
2026-08-02 5761 5746 5665
2026-08-03 5665 5761 5813
2026-08-04 5813 5665 5937
2026-08-05 5937 5813 NULL

A primeira linha não tem anterior, então o LAG devolve NULL; a última não tem próxima, então o LEAD devolve NULL. Repare que a janela só enxerga as linhas que passaram pelo WHERE: o 1º de agosto não tem “dia anterior” aqui porque o 31 de julho foi filtrado, não porque não houve reservas.

LEAD(x) é o mesmo que LAG(x) com a ordem invertida: LEAD(bookings) OVER (ORDER BY day) é igual a LAG(bookings) OVER (ORDER BY day DESC). Use o que ficar mais natural de ler.

Offset e valor padrão

O segundo argumento olha mais para trás; o terceiro substitui o NULL quando não há o que olhar:

WITH daily AS (
  SELECT CAST(book_date AS date) AS day, COUNT(*) AS bookings
  FROM bookings
  WHERE book_date >= '2026-07-01' AND book_date < '2026-08-01'
  GROUP BY CAST(book_date AS date)
)
SELECT day, bookings,
       LAG(bookings, 1, 0) OVER (ORDER BY day) AS prev_or_zero,
       LAG(bookings, 7)    OVER (ORDER BY day) AS same_day_last_week,
       ROUND(100.0 * (bookings - LAG(bookings, 7) OVER (ORDER BY day))
             / LAG(bookings, 7) OVER (ORDER BY day), 1) AS wow_pct
FROM daily
ORDER BY day
LIMIT 10;
day bookings prev_or_zero same_day_last_week wow_pct
2026-07-01 5353 0 NULL NULL
2026-07-02 5535 5353 NULL NULL
2026-07-03 5544 5535 NULL NULL
2026-07-04 5460 5544 NULL NULL
2026-07-05 5676 5460 NULL NULL
2026-07-06 5571 5676 NULL NULL
2026-07-07 5684 5571 NULL NULL
2026-07-08 5501 5684 5353 2.8
2026-07-09 5507 5501 5535 -0.5
2026-07-10 5695 5507 5544 2.7

(Mostrados os 10 primeiros dias de julho.)

LAG(x, 7) quer dizer sete linhas para trás, não sete dias. Se faltar um dia em daily, a comparação se desloca sem aviso. Se pode haver buracos, monte antes um calendário completo; veja o guia de date spine (em inglês).

Variação em relação ao período anterior

O uso mais comum do LAG: receita semana contra semana, em valor absoluto e em percentual. Só semanas completas, de 29 de junho a 9 de agosto:

WITH weekly AS (
  SELECT CAST(date_trunc('week', book_date) AS date) AS week,
         SUM(total_amount) AS revenue
  FROM bookings
  WHERE book_date >= '2026-06-29' AND book_date < '2026-08-10'
  GROUP BY 1
)
SELECT week, revenue,
       LAG(revenue) OVER (ORDER BY week) AS prev_week,
       revenue - LAG(revenue) OVER (ORDER BY week) AS change,
       ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY week))
             / LAG(revenue) OVER (ORDER BY week), 1) AS pct_change
FROM weekly
ORDER BY week;
week revenue prev_week change pct_change
2026-06-29 29711792.00 NULL NULL NULL
2026-07-06 30884431.00 29711792.00 1172639.00 3.9
2026-07-13 30629366.00 30884431.00 -255065.00 -0.8
2026-07-20 30583420.00 30629366.00 -45946.00 -0.2
2026-07-27 31165038.00 30583420.00 581618.00 1.9
2026-08-03 33778748.00 31165038.00 2613710.00 8.4

A receita está em dólares americanos. A janela roda depois do GROUP BY, então trabalha sobre os totais semanais, não sobre as reservas individuais. Dois hábitos ajudam aqui:

Repetir LAG(revenue) OVER (ORDER BY week) três vezes é verboso, mas inofensivo: janelas idênticas compartilham uma única ordenação. Se preferir legibilidade, calcule prev_week numa CTE e faça a conta na consulta externa.

LAG com PARTITION BY

Sem PARTITION BY, a janela é o resultado inteiro. Isso serve para uma única série temporal, mas fica errado assim que as linhas pertencem a várias entidades. Aqui estão os voos de dois membros do programa de fidelidade, com o aeroporto de chegada anterior calculado dos dois jeitos:

WITH trips AS (
  SELECT tm.member_id, f.scheduled_departure, f.arrival_airport
  FROM ticket_members tm
  JOIN ticket_flights tf ON tf.ticket_no = tm.ticket_no
  JOIN flights f ON f.flight_id = tf.flight_id
  WHERE tm.member_id IN (2969, 12589)
), x AS (
  SELECT member_id, scheduled_departure,
         LAG(arrival_airport) OVER (ORDER BY member_id, scheduled_departure) AS prev_no_partition,
         LAG(arrival_airport) OVER (PARTITION BY member_id ORDER BY scheduled_departure) AS prev_partitioned,
         ROW_NUMBER() OVER (PARTITION BY member_id ORDER BY scheduled_departure) AS n
  FROM trips
)
SELECT member_id, scheduled_departure, prev_no_partition, prev_partitioned
FROM x
WHERE n <= 2
ORDER BY member_id, scheduled_departure;
member_id scheduled_departure prev_no_partition prev_partitioned
2969 2026-07-15 09:50:00 NULL NULL
2969 2026-07-15 18:15:00 AER AER
12589 2026-07-12 09:15:00 VKO NULL
12589 2026-07-12 13:50:00 ESL ESL

Sem partição, o primeiro voo do membro 12589 “herda” VKO, a última chegada do membro 2969. Qualquer diferença de tempo calculada assim seria absurda na primeira linha de cada membro. Com PARTITION BY member_id, a primeira linha de cada membro recebe corretamente NULL.

Tempo entre eventos

Subtraia o LAG de um timestamp do próprio timestamp e você tem o tempo desde o evento anterior. Os primeiros voos do membro 12589, com as horas desde a partida anterior:

WITH trips AS (
  SELECT tm.member_id, f.scheduled_departure, f.departure_airport, f.arrival_airport
  FROM ticket_members tm
  JOIN ticket_flights tf ON tf.ticket_no = tm.ticket_no
  JOIN flights f ON f.flight_id = tf.flight_id
  WHERE tm.member_id = 12589
)
SELECT scheduled_departure, departure_airport AS dep, arrival_airport AS arr,
       LAG(arrival_airport) OVER (ORDER BY scheduled_departure) AS prev_arr,
       ROUND(EXTRACT(EPOCH FROM scheduled_departure
             - LAG(scheduled_departure) OVER (ORDER BY scheduled_departure)) / 3600, 1) AS hours_since_prev
FROM trips
ORDER BY scheduled_departure
LIMIT 6;
scheduled_departure dep arr prev_arr hours_since_prev
2026-07-12 09:15:00 DME ESL NULL NULL
2026-07-12 13:50:00 ESL UFA ESL 4.6
2026-07-15 08:15:00 UFA REN UFA 66.4
2026-07-16 09:35:00 REN VKO REN 25.3
2026-07-18 09:45:00 VKO LED VKO 48.2
2026-07-19 09:45:00 LED SVO LED 24

Timestamp menos timestamp dá um INTERVAL; EXTRACT(EPOCH FROM ...) o transforma em segundos, que são mais fáceis de tirar média e comparar. Para datas, date - date já é um número inteiro de dias no PostgreSQL e no DuckDB. Mais sobre isso em funções de data no SQL.

O segundo voo sai de onde o primeiro pousou (ESL), 4,6 horas depois: uma conexão. Transforme a regra num filtro e conte as conexões por aeroporto entre todos os membros. A escala é o tempo entre a chegada programada anterior e esta partida, e a função de janela fica numa CTE porque não pode ir no WHERE:

WITH trips AS (
  SELECT tm.member_id, f.scheduled_departure, f.scheduled_arrival,
         f.departure_airport, f.arrival_airport
  FROM ticket_members tm
  JOIN ticket_flights tf ON tf.ticket_no = tm.ticket_no
  JOIN flights f ON f.flight_id = tf.flight_id
), legs AS (
  SELECT member_id, departure_airport,
         LAG(arrival_airport) OVER w AS prev_arr,
         EXTRACT(EPOCH FROM scheduled_departure - LAG(scheduled_arrival) OVER w) / 60 AS layover_min
  FROM trips
  WINDOW w AS (PARTITION BY member_id ORDER BY scheduled_departure)
)
SELECT departure_airport AS hub, COUNT(*) AS connections,
       MIN(layover_min) AS min_layover, ROUND(AVG(layover_min)) AS avg_layover
FROM legs
WHERE prev_arr = departure_airport AND layover_min < 360
GROUP BY departure_airport
ORDER BY connections DESC
LIMIT 5;
hub connections min_layover avg_layover
LED 4085 125 219
SVO 3055 120 188
MCX 3031 125 125
ESL 1808 185 185
CSY 644 180 180

WINDOW w AS (...) dá nome a uma janela uma vez e a reaproveita em várias funções; PostgreSQL, DuckDB e MySQL 8 suportam, e o SQL Server a partir da versão 2022.

O que vem depois: LEAD em eventos

O LEAD responde “o que o usuário fez logo depois de X?”. O próximo evento depois de select_fare no app, com a mediana do tempo até ele:

WITH e AS (
  SELECT client_id, event_time, event_name,
         LEAD(event_name) OVER w AS next_event,
         EXTRACT(EPOCH FROM LEAD(event_time) OVER w - event_time) AS secs_to_next
  FROM app_events
  WINDOW w AS (PARTITION BY client_id ORDER BY event_time, event_id)
)
SELECT COALESCE(next_event, '(none)') AS next_event,
       COUNT(*) AS n,
       ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct,
       PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY secs_to_next) AS median_secs
FROM e
WHERE event_name = 'select_fare'
GROUP BY 1
ORDER BY n DESC;
next_event n pct median_secs
add_passenger 97471 86.4 53
(none) 7140 6.3 NULL
app_open 6616 5.9 44349.5
select_fare 762 0.7 0
push_open 399 0.4 295287
error 395 0.4 4

86,4% seguem para adicionar um passageiro, geralmente em menos de um minuto. (none) quer dizer que o cliente não fez mais nada depois desse evento nos dados. E select_fare seguido de select_fare 0 segundos depois é sinal de alerta: são eventos duplicados, que devem ser removidos antes (o padrão de remoção está em ROW_NUMBER, RANK e DENSE_RANK e em como encontrar duplicados no SQL). Repare no desempate event_id na ordem da janela: com dois eventos no mesmo segundo, o “próximo” seria arbitrário.

Detectar mudanças e sessões

O LAG funciona com qualquer tipo, não só números. Compare a categoria de um membro com a anterior para ver cada promoção e rebaixamento:

SELECT member_id, tier, valid_from,
       LAG(tier)        OVER (PARTITION BY member_id ORDER BY valid_from) AS prev_tier,
       LEAD(valid_from) OVER (PARTITION BY member_id ORDER BY valid_from) AS next_change
FROM loyalty_tier_history
WHERE member_id = 12589
ORDER BY valid_from;
member_id tier valid_from prev_tier next_change
12589 basic 2026-07-01 00:00:00 NULL 2026-07-19 00:00:00
12589 silver 2026-07-19 00:00:00 basic 2026-07-23 00:00:00
12589 gold 2026-07-23 00:00:00 silver 2026-07-31 00:00:00
12589 platinum 2026-07-31 00:00:00 gold NULL

Agregado sobre todos os membros, o mesmo LAG dá uma tabela de transições:

WITH h AS (
  SELECT member_id, tier, valid_from,
         LAG(tier) OVER (PARTITION BY member_id ORDER BY valid_from) AS prev_tier
  FROM loyalty_tier_history
)
SELECT prev_tier, tier, COUNT(*) AS moves
FROM h
WHERE prev_tier IS NOT NULL AND prev_tier <> tier
GROUP BY prev_tier, tier
ORDER BY moves DESC;
prev_tier tier moves
basic silver 4364
silver gold 659
gold platinum 145
silver basic 22
gold silver 4

O LEAD(valid_from) da consulta anterior é a base das checagens de histórico: a próxima versão deve começar exatamente quando esta termina. Essa checagem, com as sobreposições que ela encontra nesta tabela, está no guia de SCD tipo 2 (em inglês). A mesma comparação feita com self join, e por que ela dá outro número, está em self join no SQL.

Sessões. Uma regra comum: uma nova sessão começa quando se passaram mais de 30 minutos desde o evento anterior do usuário. Marque isso com LAG e depois uma SUM acumulada das marcações numera as sessões:

WITH e AS (
  SELECT client_id, event_time,
         CASE WHEN event_time - LAG(event_time) OVER (PARTITION BY client_id ORDER BY event_time)
                   <= INTERVAL '30 minutes' THEN 0 ELSE 1 END AS new_session
  FROM app_events
  WHERE client_id = '00017a7b4ac7'
)
SELECT event_time, new_session,
       SUM(new_session) OVER (PARTITION BY client_id ORDER BY event_time
                              ROWS UNBOUNDED PRECEDING) AS session_no
FROM e
ORDER BY event_time
LIMIT 8;
event_time new_session session_no
2026-07-14 08:02:57 1 1
2026-07-14 08:03:07 0 1
2026-07-14 08:03:52 0 1
2026-07-14 08:04:48 0 1
2026-07-14 08:05:36 0 1
2026-07-19 09:35:03 1 2
2026-07-19 09:35:15 0 2
2026-07-19 09:35:39 0 2

O primeiro evento tem intervalo NULL, a comparação não é verdadeira, e o ELSE 1 abre corretamente a sessão 1. A mesma ideia de marcar e somar encontra sequências de dias consecutivos e os buracos entre elas; essa família de problemas está em gaps and islands no SQL (em inglês).

Erros comuns

Perguntas frequentes

Qual a diferença entre LAG e LEAD no SQL?

O LAG devolve um valor de uma linha anterior à atual, o LEAD de uma linha posterior, na ordem definida por OVER (ORDER BY ...). LEAD(x) OVER (ORDER BY t) é o mesmo que LAG(x) OVER (ORDER BY t DESC).

Como pegar o valor da linha anterior no SQL?

LAG(coluna) OVER (ORDER BY coluna_de_ordem). Acrescente PARTITION BY se as linhas pertencem a usuários, produtos ou contas diferentes, para que a primeira linha de cada um receba NULL em vez do valor de outra entidade.

Como calcular a variação em relação ao mês anterior?

Agregue para uma linha por mês e depois use valor - LAG(valor) OVER (ORDER BY mes) para a variação absoluta e 100.0 * (valor - LAG(valor) OVER (ORDER BY mes)) / LAG(valor) OVER (ORDER BY mes) para a percentual. Deixe de fora os meses incompletos.

Como substituir o NULL do LAG?

Passe um terceiro argumento: LAG(valor, 1, 0) devolve 0 quando não há linha anterior. Ou envolva a função: COALESCE(LAG(valor) OVER (...), 0). A diferença: o COALESCE também substitui um NULL gravado na linha anterior, o argumento padrão não.

O MySQL tem LAG e LEAD?

Tem, a partir do MySQL 8.0. O SQL Server tem desde a versão 2012, e PostgreSQL, DuckDB, Oracle, BigQuery e Snowflake suportam as duas com a mesma sintaxe.

Outros idiomas: English, Español, Tiếng Việt, 简体中文