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

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:
- Sin nada (
OVER ()): la ventana es todo el resultado. PARTITION BY: reinicia el cálculo en cada grupo, como unGROUP BYque no junta filas.ORDER BY: obligatorio para rankings y paraLAG/LEAD; con agregados comoSUMconvierte el total en un total acumulado.- Marco: qué filas alrededor de la actual entran en el cálculo (por ejemplo, las 6 anteriores y la actual).
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 |
ROW_NUMBERda números únicos: 10, 11, 12… aunque los valores sean iguales. Entre empatados el orden es arbitrario, por eso añadimosdeparture_airportcomo desempate: así el resultado es estable.RANKda el mismo puesto a los empatados y salta: cinco aeropuertos comparten el 10, y el siguiente es el 15.DENSE_RANKtambién comparte puesto pero no salta: después del 10 viene el 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í:
- El filtro va fuera. No puedes escribir
WHERE rn <= 2en la misma consulta donde calculasrn, porqueWHEREse 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 tienenQUALIFYpara filtrar ventanas directamente, pero PostgreSQL no. - Los empates deciden. Desde VKO, BZK y LED tienen 183 vuelos cada una; con
ROW_NUMBERy desempate alfabético sale BZK primero. Si quisieras que todas las rutas empatadas entren en el top, usaríasRANKoDENSE_RANKen lugar deROW_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:
- El filtro de fechas está en la consulta exterior a propósito. Si pusieras
WHERE day >= '2026-08-05'en la misma consulta que elLAG, el 5 de agosto saldría conprev_dayvacío y la media móvil de los primeros días se calcularía con menos de 7 valores: la ventana solo ve las filas que sobreviven alWHERE. - La caída del 11 de agosto no es una caída. Los datos se extrajeron el 11 de agosto a las 18:00, así que ese día está incompleto. Antes de comparar periodos, comprueba que el último esté cerrado: es un error muy común en informes reales.
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
- Filtrar por la ventana en el mismo
WHERE. No se puede: calcula en una CTE y filtra fuera. ROW_NUMBERsin desempate. Con valores iguales el resultado cambia entre ejecuciones. Añade una columna única alORDER BY.- Acumulados con
RANGEpor defecto sobre valores repetidos. EscribeROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. LAST_VALUEcon el marco por defecto. Devuelve la fila actual.- Filtrar antes de
LAGo de una media móvil. Los bordes del periodo pierden sus vecinos. - Huecos en las fechas.
LAGmira la fila anterior, no el día anterior. Si falta un día, compara con dos días atrás sin avisar. Para series sin huecos genera primero un calendario congenerate_series.
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, 简体中文