SQL · Guías · 29 de octubre de 2026 · 12 min de lectura

Funciones de ventana SQL: OVER, PARTITION BY, ROW_NUMBER, RANK, LAG y totales acumulados

Filas de una tabla vistas a través de un marco que se desliza sobre ellas, junto a una línea de total acumulado que sube

Una función de ventana calcula un valor sobre un conjunto de filas relacionadas con la fila actual sin colapsarlas. Esa es la diferencia con GROUP BY: un SUM agrupado devuelve una fila por grupo; un SUM(...) OVER (...) devuelve todas las filas y añade a cada una el total de su grupo. La ventana se define con OVER: PARTITION BY dice qué filas forman el grupo, ORDER BY dice en qué orden se recorren y el marco (ROWS BETWEEN ...) dice cuántas filas entran en el cálculo. Con eso se resuelven rankings, top N por grupo, porcentajes del total, totales acumulados, comparaciones con el día anterior y medias móviles.

Todos los ejemplos usan la base de datos de una aerolínea del curso de SQL de Nomto (vuelos, reservas, billetes; valores en inglés, dinero en dólares) y los resultados son la salida real. Puedes ejecutarlos y modificarlos en la sala de SQL de Nomto.

La sintaxis en una línea

función(...) OVER (
  PARTITION BY columnas_del_grupo
  ORDER BY columnas_de_orden
  ROWS BETWEEN inicio AND fin
)

Las tres partes son opcionales:

Hay tres familias de funciones de ventana:

Familia Funciones Para qué
Agregados SUM, AVG, COUNT, MIN, MAX totales del grupo, porcentajes, acumulados, medias móviles
Ranking ROW_NUMBER, RANK, DENSE_RANK, NTILE numerar, ordenar puestos, deduplicar, cuartiles
Desplazamiento LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE mirar la fila anterior, la siguiente o la primera del grupo

OVER (): el porcentaje del total

¿Qué parte de los ingresos aporta cada clase de tarifa? Primero agrupamos por clase y luego, con una ventana sobre todo el resultado, sumamos los totales de las tres clases:

SELECT fare_conditions,
       SUM(amount) AS revenue,
       ROUND(100.0 * SUM(amount) / SUM(SUM(amount)) OVER (), 1) AS pct
FROM ticket_flights
GROUP BY fare_conditions
ORDER BY revenue DESC;
fare_conditions revenue pct
Economy 146956844.00 70.8
Business 55051796.00 26.5
Comfort 5661169.00 2.7

SUM(SUM(amount)) OVER () parece raro, pero se lee de dentro hacia fuera: el SUM interior es el agregado del GROUP BY (ingresos de una clase) y el exterior es la ventana que suma esos tres valores. Las funciones de ventana se calculan después de agrupar, por eso pueden usar agregados. El 100.0 evita la división entera.

PARTITION BY: el total del grupo junto a cada fila

Ahora el porcentaje dentro de cada grupo: qué parte de los asientos de cada modelo de avión es Business, Comfort o Economy.

SELECT aircraft_code,
       fare_conditions,
       COUNT(*) AS seats,
       SUM(COUNT(*)) OVER (PARTITION BY aircraft_code) AS model_seats,
       ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (PARTITION BY aircraft_code), 1) AS pct
FROM seats
WHERE aircraft_code IN ('773', '763', 'SU9')
GROUP BY aircraft_code, fare_conditions
ORDER BY aircraft_code, fare_conditions;
aircraft_code fare_conditions seats model_seats pct
763 Business 30 222 13.5
763 Economy 192 222 86.5
773 Business 30 402 7.5
773 Comfort 48 402 11.9
773 Economy 324 402 80.6
SU9 Business 12 97 12.4
SU9 Economy 85 97 87.6

Cada fila conserva su detalle (clase y número de asientos) y además lleva el total de su modelo. Sin funciones de ventana harías una subconsulta con el total por modelo y un JOIN.

ROW_NUMBER, RANK y DENSE_RANK: la diferencia está en los empates

Las tres numeran filas según un orden. Se comportan igual mientras no hay empates. Contamos a cuántos aeropuertos distintos se vuela desde cada aeropuerto y miramos la zona de la tabla donde hay empates (puestos 9 a 16):

WITH c AS (
  SELECT departure_airport,
         COUNT(DISTINCT arrival_airport) AS destinations
  FROM flights
  GROUP BY departure_airport
),
r AS (
  SELECT departure_airport,
         destinations,
         ROW_NUMBER() OVER (ORDER BY destinations DESC, departure_airport) AS row_num,
         RANK()       OVER (ORDER BY destinations DESC) AS rnk,
         DENSE_RANK() OVER (ORDER BY destinations DESC) AS dense_rnk
  FROM c
)
SELECT *
FROM r
WHERE row_num BETWEEN 9 AND 16
ORDER BY row_num;
departure_airport destinations row_num rnk dense_rnk
KJA 10 9 9 9
IKT 9 10 10 10
KZN 9 11 10 10
PEE 9 12 10 10
SGC 9 13 10 10
TJM 9 14 10 10
ARH 8 15 15 11
HMA 8 16 15 11

Cuál usar: ROW_NUMBER cuando necesitas exactamente una fila por puesto (deduplicar, quedarte con «la última»), RANK para clasificaciones al estilo deportivo y DENSE_RANK cuando preguntan por «el segundo valor más alto» contando valores distintos.

Top N por grupo

El caso de uso más frecuente, y una pregunta clásica de entrevista: las dos rutas con más vuelos desde cada aeropuerto de Moscú.

WITH r AS (
  SELECT departure_airport,
         arrival_airport,
         COUNT(*) AS flights,
         ROW_NUMBER() OVER (
           PARTITION BY departure_airport
           ORDER BY COUNT(*) DESC, arrival_airport
         ) AS rn
  FROM flights
  WHERE departure_airport IN ('DME', 'SVO', 'VKO')
  GROUP BY departure_airport, arrival_airport
)
SELECT departure_airport, arrival_airport, flights, rn
FROM r
WHERE rn <= 2
ORDER BY departure_airport, rn;
departure_airport arrival_airport flights rn
DME LED 244 1
DME BZK 183 2
SVO LED 305 1
SVO BZK 183 2
VKO BZK 183 1
VKO LED 183 2

Dos cosas que conviene ver aquí:

  1. El filtro va fuera. No puedes escribir WHERE rn <= 2 en la misma consulta donde calculas rn, porque WHERE se ejecuta antes que las funciones de ventana. Por eso el patrón es siempre CTE (o subconsulta) y filtro en la consulta exterior. Algunos motores tienen QUALIFY para filtrar ventanas directamente, pero PostgreSQL no.
  2. Los empates deciden. Desde VKO, BZK y LED tienen 183 vuelos cada una; con ROW_NUMBER y desempate alfabético sale BZK primero. Si quisieras que todas las rutas empatadas entren en el top, usarías RANK o DENSE_RANK en lugar de ROW_NUMBER.

Totales acumulados

Si a SUM(...) OVER le añades ORDER BY, el total deja de ser del grupo entero y pasa a ser acumulado: desde la primera fila hasta la actual. Ingresos por día de reserva en la primera semana de agosto y su acumulado:

WITH d AS (
  SELECT CAST(book_date AS DATE) AS day,
         COUNT(*)          AS bookings,
         SUM(total_amount) AS revenue
  FROM bookings
  WHERE book_date >= '2026-08-01' AND book_date < '2026-08-08'
  GROUP BY CAST(book_date AS DATE)
)
SELECT day,
       bookings,
       revenue,
       SUM(revenue) OVER (ORDER BY day) AS running_revenue
FROM d
ORDER BY day;
day bookings revenue running_revenue
2026-08-01 5746 4504496.00 4504496.00
2026-08-02 5761 4563873.00 9068369.00
2026-08-03 5665 4521583.00 13589952.00
2026-08-04 5813 4676666.00 18266618.00
2026-08-05 5937 4793778.00 23060396.00
2026-08-06 6081 4856044.00 27916440.00
2026-08-07 6184 4907956.00 32824396.00

Para un acumulado que se reinicia cada mes, añade PARTITION BY DATE_TRUNC('month', day).

ROWS frente a RANGE: el detalle que rompe acumulados

Cuando escribes ORDER BY sin marco, el marco por defecto es RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. RANGE trata como una sola posición todas las filas con el mismo valor de orden. Si ordenas por una columna con repetidos (por ejemplo, la fecha en una tabla de reservas individuales), todas las filas del mismo día reciben el mismo acumulado, el del final del día. Si quieres que el total crezca fila a fila, escribe el marco explícitamente y desempata:

SUM(total_amount) OVER (ORDER BY book_date, book_ref
                        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)

En el ejemplo de arriba no pasa nada porque hay una fila por día.

LAG y LEAD: comparar con la fila anterior

LAG(columna) devuelve el valor de la fila anterior según el ORDER BY de la ventana; LEAD, el de la siguiente. Con ellos se calculan variaciones día a día, tiempos entre eventos o el siguiente paso de un usuario. En el mismo ejemplo añadimos una media móvil de 7 días con un marco ROWS:

WITH d AS (
  SELECT CAST(book_date AS DATE) AS day, COUNT(*) AS bookings
  FROM bookings
  GROUP BY CAST(book_date AS DATE)
),
w AS (
  SELECT day,
         bookings,
         LAG(bookings) OVER (ORDER BY day) AS prev_day,
         ROUND(AVG(bookings) OVER (ORDER BY day
               ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 1) AS avg_7d
  FROM d
)
SELECT day, bookings, prev_day, bookings - prev_day AS diff, avg_7d
FROM w
WHERE day >= '2026-08-05'
ORDER BY day;
day bookings prev_day diff avg_7d
2026-08-05 5937 5813 124 5726.3
2026-08-06 6081 5937 144 5801.6
2026-08-07 6184 6081 103 5883.9
2026-08-08 6350 6184 166 5970.1
2026-08-09 6531 6350 181 6080.1
2026-08-10 6787 6531 256 6240.4
2026-08-11 5335 6787 -1452 6172.1

Dos lecciones en esta tabla:

LAG admite dos argumentos más: LAG(bookings, 7) mira 7 filas atrás (la misma fecha de la semana anterior, si hay una fila por día) y LAG(bookings, 1, 0) devuelve 0 en lugar de NULL en la primera fila.

FIRST_VALUE y el total de la reserva en cada fila

Una reserva puede tener varios billetes y cada billete varios tramos. Con PARTITION BY book_ref cada tramo puede llevar el total de su reserva y el primer vuelo del viaje:

SELECT t.book_ref,
       t.ticket_no,
       tf.flight_id,
       tf.amount,
       SUM(tf.amount) OVER (PARTITION BY t.book_ref) AS booking_total,
       FIRST_VALUE(tf.flight_id) OVER (PARTITION BY t.book_ref
                                       ORDER BY f.scheduled_departure) AS first_flight
FROM tickets t
JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
JOIN flights f         ON f.flight_id  = tf.flight_id
WHERE t.book_ref = '000012'
ORDER BY f.scheduled_departure;
book_ref ticket_no flight_id amount booking_total first_flight
000012 0005432527326 7737 140.00 379.00 7737
000012 0005432527326 30620 239.00 379.00 7737

El total coincide con el total_amount de la reserva en bookings (379). Cuidado con LAST_VALUE: con el marco por defecto «hasta la fila actual» devuelve la propia fila, no la última del grupo. Para la última, usa FIRST_VALUE con el orden invertido o el marco ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.

NTILE: cuartiles y segmentos

NTILE(n) reparte las filas ordenadas en n grupos del mismo tamaño. Cuartiles del importe de las reservas:

WITH q AS (
  SELECT total_amount,
         NTILE(4) OVER (ORDER BY total_amount) AS quartile
  FROM bookings
)
SELECT quartile,
       COUNT(*)          AS bookings,
       MIN(total_amount) AS min_amount,
       MAX(total_amount) AS max_amount
FROM q
GROUP BY quartile
ORDER BY quartile;
quartile bookings min_amount max_amount
1 65697 34.00 290.00
2 65697 290.00 559.00
3 65697 559.00 992.00
4 65697 992.00 12045.00

La mitad de las reservas cuesta menos de unos 559 dólares, pero la más cara llega a 12 045. Fíjate en que 290 y 559 aparecen en dos cuartiles: NTILE reparte filas por cantidad, no por valor, así que los empates pueden quedar a ambos lados del corte. Para medianas exactas usa PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_amount).

Errores frecuentes con funciones de ventana

Cómo practicarlas

Las funciones de ventana se entienden de verdad cuando escribes diez o quince consultas con ellas y ves qué cambia al mover PARTITION BY o el marco. En la sala de SQL de Nomto puedes ejecutar todos estos ejemplos sobre la misma base de datos; el módulo del curso avanzado dedicado a ventanas empieza por OVER y PARTITION BY y sigue con rankings, top N, acumulados, LAG y marcos. Si te preparas para un puesto de analista, las ventanas aparecen en casi todas las preguntas de entrevista SQL, y tienes varios casos más en los ejercicios SQL resueltos.

Preguntas frecuentes

¿Qué diferencia hay entre GROUP BY y una función de ventana?

GROUP BY reduce cada grupo a una fila. Una función de ventana calcula el mismo agregado pero mantiene todas las filas, de modo que puedes ver el detalle y el total del grupo a la vez.

¿Cuándo uso ROW_NUMBER, RANK o DENSE_RANK?

ROW_NUMBER para quedarte con exactamente una fila por grupo (deduplicar, última compra). RANK para puestos con saltos tras un empate. DENSE_RANK para puestos sin saltos, por ejemplo al buscar el segundo valor distinto más alto.

¿Por qué no puedo usar una función de ventana en WHERE?

Porque WHERE se evalúa antes que el SELECT, y las ventanas se calculan en el SELECT. Calcula la ventana en una CTE o subconsulta y filtra en la consulta exterior.

¿Las funciones de ventana funcionan en MySQL?

Sí, desde MySQL 8.0. También en PostgreSQL, SQL Server, Oracle, SQLite (3.25 o posterior), BigQuery y Snowflake, con la misma sintaxis OVER (PARTITION BY ... ORDER BY ...).

También en Português (Brasil), Tiếng Việt, English, 简体中文