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

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(bookings, 7)compara a quarta-feira com a quarta-feira passada. Para dados diários com ritmo semanal, é uma comparação mais justa do que com ontem.LAG(bookings, 1, 0)devolve 0 na primeira linha. Cuidado com o valor padrão: um 0 aqui quer dizer “não há linha anterior”, e não “zero reservas ontem”, e uma variação percentual sobre 0 é divisão por zero. Em cálculos de variação, oNULLcostuma ser a resposta honesta.- O padrão precisa ter tipo compatível com a coluna.
LAG(day, 1, 'none')numa coluna de data dá erro.
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:
- Descarte períodos incompletos. Os dados começam no meio da semana, em 17 de junho, e terminam em 11 de agosto; incluir essas semanas parciais mostraria um salto falso no começo e uma queda falsa no fim.
- Escreva
100.0, não100. No PostgreSQL e no SQL Server, inteiro dividido por inteiro dá inteiro, e variações pequenas viram 0. Aquirevenueé decimal, então escaparia, mas numCOUNT(*)não escaparia.
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
- Usar LAG no WHERE.
WHERE LAG(status) OVER (...) = 'Cancelled'dá erro (“WHERE clause cannot contain window functions”). Calcule numa CTE e filtre por fora. - Filtrar antes da janela. Um
WHEREno intervalo de datas remove as linhas de que oLAGprecisaria; a primeira linha do intervalo fica comNULL. Se você precisa do valor anterior para o primeiro dia, calcule oLAGnum intervalo maior e filtre na consulta externa. - Esquecer o PARTITION BY. A primeira linha de cada entidade pega o valor da entidade anterior.
- ORDER BY não único. Se duas linhas têm o mesmo timestamp, qual é a “anterior” fica indefinido. Acrescente um desempate.
- Achar que linha é período.
LAG(x, 7)são sete linhas, não sete dias. Preencha as datas que faltam antes. - Esperar IGNORE NULLS em todo lugar. Para pegar o último valor não nulo, Oracle e Snowflake aceitam
LAG(x) IGNORE NULLS OVER (...)e o DuckDBLAG(x IGNORE NULLS) OVER (...), mas o PostgreSQL não temIGNORE NULLS. A alternativa portável:COUNT(x) OVER (ORDER BY t)dá um número de grupo que só cresce nas linhas não nulas, e depoisMAX(x) OVER (PARTITION BY esse_grupo)propaga o valor para a frente.
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, 简体中文