SQL · Guías · 30 de octubre de 2026 · 14 min de lectura
LAG y LEAD en SQL: fila anterior y siguiente, con ejemplos

LAG(columna) devuelve el valor de columna de la fila anterior, y LEAD(columna) el de la fila siguiente, en el orden que fijas con OVER (ORDER BY ...). Las dos conservan todas las filas del resultado, así que puedes poner «este periodo» y «el periodo anterior» uno al lado del otro y restar. Añade PARTITION BY para mirar solo las filas anteriores o posteriores del mismo cliente, producto o vuelo. La sintaxis completa es LAG(columna, desplazamiento, por_defecto): el desplazamiento es cuántas filas hacia atrás (1 por defecto) y el valor por defecto es lo que se devuelve cuando no existe esa fila (NULL si no indicas nada).
Los ejemplos usan la base de datos de una aerolínea del curso de SQL de Nomto, con sintaxis de PostgreSQL sobre DuckDB (el motor de la sala de SQL de Nomto), y cada tabla de resultados es la salida real. LAG y LEAD son funciones de ventana; si OVER y PARTITION BY son nuevos para ti, empieza por la guía de funciones de ventana.
LAG y LEAD: la fila anterior y la siguiente
Reservas por día a principios de agosto, con el día anterior y el siguiente en cada fila:
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 |
La primera fila no tiene anterior, así que LAG devuelve NULL; la última no tiene siguiente, así que LEAD devuelve NULL. Fíjate en que la ventana solo ve las filas que pasaron el WHERE: el 1 de agosto no tiene «día anterior» porque el 31 de julio quedó fuera del filtro, no porque no hubiera reservas.
LEAD(x) es lo mismo que LAG(x) con el orden invertido: LEAD(bookings) OVER (ORDER BY day) equivale a LAG(bookings) OVER (ORDER BY day DESC). Usa la que se lea mejor.
Desplazamiento y valor por defecto
El segundo argumento mira más atrás; el tercero sustituye el NULL cuando no hay nada que mirar:
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 |
(Se muestran los 10 primeros días de julio.)
LAG(bookings, 7)compara el miércoles con el miércoles anterior. Con datos diarios que tienen ritmo semanal, es una comparación más justa que con el día de ayer.LAG(bookings, 1, 0)devuelve 0 en la primera fila. Cuidado con los valores por defecto: aquí un 0 significa «no hay fila anterior», no «ayer no hubo reservas», y una variación porcentual frente a 0 es una división por cero. Para calcular variaciones, elNULLsuele ser la respuesta honesta.- El valor por defecto debe tener un tipo compatible con la columna.
LAG(day, 1, 'none')sobre una columna de fecha falla.
LAG(x, 7) significa siete filas atrás, no siete días. Si falta un día en daily, la comparación se desplaza sin avisar. Si puede haber huecos, construye primero un calendario completo; lo explica la guía de date spine (en inglés).
Variación frente al periodo anterior
El uso más habitual de LAG: ingresos semana contra semana, en valor absoluto y en porcentaje. Solo semanas completas, del 29 de junio al 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 |
Los ingresos están en dólares. La ventana se calcula después del GROUP BY, así que trabaja sobre los totales semanales, no sobre reservas sueltas. Dos costumbres ayudan aquí:
- Descarta los periodos incompletos. Los datos empiezan a mitad de semana, el 17 de junio, y terminan el 11 de agosto; con esas semanas parciales verías un salto falso al principio y un desplome falso al final.
- Escribe
100.0, no100. En PostgreSQL y SQL Server, entero entre entero da entero, y las variaciones pequeñas se redondean a 0. Aquírevenuees decimal y no pasaría nada, pero con unCOUNT(*)sí.
Repetir LAG(revenue) OVER (ORDER BY week) tres veces es verboso pero inofensivo: las ventanas idénticas comparten una sola ordenación. Si prefieres legibilidad, calcula prev_week en una CTE y haz las cuentas en la consulta exterior. Es la versión en SQL de la columna «variación vs. mes anterior» que en Excel harías con =B3-B2 arrastrado hacia abajo.
LAG con PARTITION BY
Sin PARTITION BY, la ventana es todo el resultado. Para una sola serie temporal está bien, pero en cuanto las filas pertenecen a varias entidades, falla. Estos son los vuelos de dos socios del programa de fidelización, con el aeropuerto de llegada anterior calculado de las dos maneras:
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 |
Sin partición, el primer vuelo del socio 12589 «hereda» VKO, la última llegada del socio 2969. Cualquier diferencia de tiempo calculada así sería un disparate en la primera fila de cada socio. Con PARTITION BY member_id, la primera fila de cada socio recibe NULL, como debe ser.
Tiempo entre eventos
Resta al timestamp el LAG del mismo timestamp y obtienes el tiempo transcurrido desde el evento anterior. Los primeros vuelos del socio 12589, con las horas desde la salida 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 da un INTERVAL; EXTRACT(EPOCH FROM ...) lo convierte en segundos, que son más fáciles de promediar y comparar. Con fechas, fecha - fecha ya es un número entero de días en PostgreSQL y DuckDB. En SQL Server y MySQL lo habitual es DATEDIFF (en SQL Server, DATEDIFF(minute, inicio, fin); en MySQL, TIMESTAMPDIFF(MINUTE, inicio, fin)). Más en las funciones de fecha en SQL.
El segundo vuelo sale de donde aterrizó el primero (ESL) 4,6 horas después: una conexión. Convertimos la regla en un filtro y contamos conexiones por aeropuerto entre todos los socios. La escala es el tiempo desde la llegada programada anterior hasta esta salida, y la función de ventana se calcula en una CTE porque no puede ir en el 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 (...) da nombre a una ventana una sola vez y la reutiliza en varias funciones; lo admiten PostgreSQL, DuckDB y MySQL 8, y SQL Server desde la versión 2022.
Qué pasa después: LEAD sobre eventos
LEAD responde a «¿qué hizo el usuario justo después de X?». El siguiente evento tras select_fare en la app, con la mediana de tiempo hasta él:
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 |
El 86,4 % pasa a añadir un pasajero, normalmente en menos de un minuto. (none) significa que el cliente no hizo nada más después de ese evento en los datos. Y un select_fare seguido de otro select_fare a los 0 segundos es una señal de alarma: son eventos duplicados que hay que quitar primero (el patrón está en ROW_NUMBER, RANK y DENSE_RANK). Fíjate en el desempate event_id del orden de la ventana: con dos eventos en el mismo segundo, «el siguiente» sería arbitrario.
Detectar cambios y sesiones
LAG funciona con cualquier tipo, no solo con números. Compara el nivel de un socio con el anterior para ver cada subida y bajada:
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 los socios, el mismo LAG da una tabla de transiciones:
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 |
El LEAD(valid_from) de la consulta anterior es la base de las comprobaciones de historiales: la siguiente versión debería empezar justo cuando termina esta. Esa comprobación, con los solapamientos que encuentra en esta tabla, está en la guía de SCD tipo 2 (en inglés). La misma tabla de transiciones también se puede sacar con un self join, y comparar los dos resultados es un buen control de calidad.
Sesiones. Una regla habitual: empieza una sesión nueva cuando han pasado más de 30 minutos desde el evento anterior del usuario. Lo marcas con LAG, y una SUM acumulada de esas marcas numera las sesiones:
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 |
El primer evento tiene una diferencia NULL, la comparación no es verdadera y el ELSE 1 abre correctamente la sesión 1. La misma idea de «marcar y sumar» sirve para encontrar rachas de días consecutivos y los huecos entre ellas; esa familia de problemas está en gaps and islands en SQL (en inglés).
Errores frecuentes
- Usar LAG en el WHERE.
WHERE LAG(status) OVER (...) = 'Cancelled'falla («WHERE clause cannot contain window functions»). Calcúlalo en una CTE y filtra fuera. - Filtrar antes de la ventana. Un
WHEREsobre el rango de fechas elimina las filas queLAGnecesitaría, y la primera fila de tu rango muestraNULL. Si necesitas el valor anterior para el primer día, calculaLAGsobre un rango más amplio y filtra en la consulta exterior. - Olvidar PARTITION BY. La primera fila de cada entidad toma el valor de la entidad anterior.
- Un ORDER BY no único. Si dos filas tienen el mismo timestamp, cuál es «la anterior» no está definido. Añade un desempate.
- Suponer que las filas son periodos.
LAG(x, 7)son siete filas, no siete días. Rellena antes las fechas que falten. - Esperar IGNORE NULLS en todas partes. Para obtener el último valor no nulo, Oracle y Snowflake aceptan
LAG(x) IGNORE NULLS OVER (...)y DuckDBLAG(x IGNORE NULLS) OVER (...), pero PostgreSQL no tieneIGNORE NULLS. La alternativa portable:COUNT(x) OVER (ORDER BY t)da un número de grupo que solo crece en las filas no nulas, y luegoMAX(x) OVER (PARTITION BY ese_grupo)arrastra el valor hacia delante.
Preguntas frecuentes
¿Qué diferencia hay entre LAG y LEAD en SQL?
LAG devuelve un valor de una fila anterior a la actual y LEAD de una fila posterior, en el orden definido por OVER (ORDER BY ...). LEAD(x) OVER (ORDER BY t) es lo mismo que LAG(x) OVER (ORDER BY t DESC).
¿Cómo obtengo el valor de la fila anterior en SQL?
Con LAG(columna) OVER (ORDER BY columna_de_orden). Añade PARTITION BY si las filas pertenecen a distintos usuarios, productos o cuentas, para que la primera fila de cada uno reciba NULL en lugar del valor de otra entidad.
¿Cómo calculo la variación respecto al mes anterior?
Agrega a una fila por mes y luego usa valor - LAG(valor) OVER (ORDER BY mes) para la variación absoluta y 100.0 * (valor - LAG(valor) OVER (ORDER BY mes)) / LAG(valor) OVER (ORDER BY mes) para la porcentual. Excluye los meses incompletos.
¿Cómo sustituyo el NULL que devuelve LAG?
Pasa un tercer argumento: LAG(valor, 1, 0) devuelve 0 cuando no hay fila anterior. O envuélvelo: COALESCE(LAG(valor) OVER (...), 0). La diferencia: COALESCE también sustituye un NULL guardado en la fila anterior; el argumento por defecto, no.
¿MySQL tiene LAG y LEAD?
Sí, desde MySQL 8.0. SQL Server los tiene desde 2012, y PostgreSQL, DuckDB, Oracle, BigQuery y Snowflake los admiten con la misma sintaxis.
También en English, Português (Brasil), Tiếng Việt, 简体中文