SQL · Guías · 30 de octubre de 2026 · 14 min de lectura

LAG y LEAD en SQL: fila anterior y siguiente, con ejemplos

Ilustración plana de una fila de tarjetas de un panel de salidas con flechas que apuntan a la tarjeta anterior y a la siguiente, y un avión en el cielo

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(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í:

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

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, 简体中文