GROUP BY tiene un precio que casi nadie te cuenta: te quedas sin
el detalle. Preguntas el promedio por canal y te devuelve cuatro filas; los 900
pedidos desaparecieron.
Y hay preguntas que necesitan las dos cosas a la vez. "¿Cuánto se aleja este pedido del promedio de su canal?" pide el promedio del canal (un resumen) y el monto de este pedido (el detalle), en la misma fila.
Eso es lo que hacen las funciones de ventana: calculan un resumen y no aplastan las filas. Es lo que más separa a alguien que sabe SQL de alguien que lo usa, y una vez que las entiendes ya no hay vuelta 🌟
OVER (), o el resumen que se queda al lado
SELECT id, canal, monto,
ROUND(AVG(monto) OVER (), 2) AS promedio_global,
ROUND(monto - AVG(monto) OVER (), 2) AS diferencia
FROM pedidos
WHERE monto IS NOT NULL
ORDER BY id
LIMIT 5;
id canal monto promedio_global diferencia -- ----------- ------ --------------- ---------- 1 Web 892.06 610.14 281.92 2 Marketplace 731.09 610.14 120.95 3 Tienda 407.39 610.14 -202.75 4 Tienda 358.23 610.14 -251.91 5 Web 844.94 610.14 234.8
Ese OVER () es toda la diferencia. Sin él,
AVG(monto) te habría dado una sola fila; con él, el mismo 610,14 se
repite al lado de cada pedido y puedes restarlo.
Piensa el OVER () como una ventana: es el
conjunto de filas que la función mira para calcular su número. Vacío significa
"míralas todas".
PARTITION BY, o una ventana por grupo
SELECT id, canal, monto,
ROUND(AVG(monto) OVER (PARTITION BY canal), 2) AS promedio_del_canal
FROM pedidos
WHERE monto IS NOT NULL
ORDER BY id
LIMIT 5;
id canal monto promedio_del_canal -- ----------- ------ ------------------ 1 Web 892.06 615.99 2 Marketplace 731.09 611.5 3 Tienda 407.39 598.41 4 Tienda 358.23 598.41 5 Web 844.94 615.99
Ahora cada fila ve solo las de su canal. El pedido 1 es Web y le sale 615,99; el 3 es Tienda y le sale 598,41. Los mismos números del capítulo 6, pero sin perder las 873 filas.
PARTITION BY es a las ventanas lo que
GROUP BY es a los grupos, con una diferencia enorme: no
reduce nada. Si lo tienes claro, ya entendiste la mitad del capítulo 💛
Rankings: las tres formas de numerar
SELECT ciudad, COUNT(*) AS clientes,
ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) AS fila,
RANK() OVER (ORDER BY COUNT(*) DESC) AS rango,
DENSE_RANK() OVER (ORDER BY COUNT(*) DESC) AS rango_denso
FROM clientes
GROUP BY ciudad;
ciudad clientes fila rango rango_denso -------- -------- ---- ----- ----------- Chiclayo 25 1 1 1 Piura 24 2 2 2 Arequipa 22 3 3 3 Trujillo 17 4 4 4 Cusco 17 5 4 4 Lima 15 6 6 5
Aquí está toda la teoría en una tabla, y menos mal que Trujillo y Cusco empatan con 17, porque el empate es lo único que las diferencia:
- 🔢
ROW_NUMBERnumera 1, 2, 3, 4, 5, 6. No sabe de empates: a uno le toca el 4 y al otro el 5, y cuál es cuál lo decide el motor. - 🥇
RANKles da 4 a los dos y después salta al 6. Es el podio de una carrera: dos cuartos, no hay quinto. - 🎗️
DENSE_RANKles da 4 a los dos y sigue en 5. No deja huecos.
Cuál usar depende de la pregunta. Para "el top 3 de verdad", RANK
o DENSE_RANK, porque si hay empate en el tercer puesto quieres los
dos. Para "dame exactamente una fila por grupo",
ROW_NUMBER.
El top N por grupo, que es la reina de las consultas
"El pedido más grande de cada canal" es de las cosas que más te van a pedir y de las que peor se resuelven sin ventanas.
SELECT canal, id, monto,
ROW_NUMBER() OVER (PARTITION BY canal ORDER BY monto DESC) AS puesto
FROM pedidos
WHERE monto IS NOT NULL
ORDER BY canal, puesto
LIMIT 8;
canal id monto puesto ----------- --- ------- ------ Marketplace 874 1244.55 1 Marketplace 72 1244.2 2 Marketplace 65 1195.8 3 Marketplace 646 1189.28 4 Marketplace 798 1160.71 5 Marketplace 103 1145.49 6 Marketplace 644 1142.65 7 Marketplace 660 1055.15 8
La ventana numera de nuevo dentro de cada canal: el más caro de Marketplace es el puesto 1, el siguiente el 2, y cuando cambia el canal vuelve a empezar en 1. Ahora solo falta quedarse con los primeros. Y aquí viene el tropiezo clásico.
SELECT canal, id, monto FROM pedidos WHERE ROW_NUMBER() OVER (PARTITION BY canal ORDER BY monto DESC) = 1;
OperationalError: misuse of window function ROW_NUMBER()
misuse of window function. Acuérdate del orden de ejecución del
capítulo 6: el WHERE pasa en el paso 2 y las ventanas se calculan
en el paso 5, junto con el SELECT. Cuando el WHERE
mira, el puesto todavía no existe.
Lo mismo pasa con HAVING y GROUP BY. La solución es
siempre la misma: calcula la ventana en un CTE y filtra fuera,
que es exactamente para lo que aprendimos WITH en el capítulo 8.
WITH ranking AS (
SELECT canal, id, monto,
ROW_NUMBER() OVER (PARTITION BY canal ORDER BY monto DESC) AS puesto
FROM pedidos
WHERE monto IS NOT NULL
)
SELECT canal, id, monto, puesto
FROM ranking
WHERE puesto <= 2
ORDER BY canal, puesto;
canal id monto puesto ----------- --- ------- ------ Marketplace 874 1244.55 1 Marketplace 72 1244.2 2 Tienda 151 1236.97 1 Tienda 142 1170.3 2 Web 674 1372.34 1 Web 373 1341.15 2 WhatsApp 25 1399.98 1 WhatsApp 440 1322.73 2
Los dos pedidos más grandes de cada canal, en ocho filas. Ese patrón
(WITH + ROW_NUMBER + WHERE puesto <= N)
lo vas a escribir mil veces, y funciona igual en los cuatro motores.
Filtrar por el resultado de una ventana
| PostgreSQL | CTE y filtrar fuera. No tiene QUALIFY. |
| MySQL | CTE y filtrar fuera |
| SQL Server | CTE y filtrar fuera |
| SQLite | CTE y filtrar fuera |
Los cuatro igual, y por una vez la incomodidad es pareja. Si alguna vez lees código con QUALIFY, que hace esto en una línea, es de Snowflake o de BigQuery: ninguno de estos cuatro lo tiene.
El acumulado, o cómo va el año
Cuando el OVER lleva ORDER BY, la ventana deja de
ser "todas las filas del grupo" y pasa a ser "todas las de aquí para atrás". Y
eso es un acumulado.
WITH ventas_mes AS (
SELECT STRFTIME('%Y-%m', fecha) AS mes, ROUND(SUM(monto), 2) AS soles
FROM pedidos
WHERE fecha >= '2026-01-01'
GROUP BY mes
)
SELECT mes, soles,
ROUND(SUM(soles) OVER (ORDER BY mes), 2) AS acumulado
FROM ventas_mes
ORDER BY mes;
mes soles acumulado ------- -------- --------- 2026-01 29373.17 29373.17 2026-02 24033.91 53407.08 2026-03 28103.64 81510.72 2026-04 27651.42 109162.14 2026-05 33711.97 142874.11 2026-06 25293.12 168167.23
168.167 soles en el primer semestre de 2026, y la columna de al lado te dice cómo se llegó ahí. Es la típica que pide un gerente y que en Excel es una fórmula que se arrastra y se rompe 📈
Esa ventana se puede escribir a mano, y a veces hay que hacerlo.
WITH ventas_mes AS (
SELECT STRFTIME('%Y-%m', fecha) AS mes, ROUND(SUM(monto), 2) AS soles
FROM pedidos GROUP BY mes
)
SELECT mes, soles,
ROUND(AVG(soles) OVER (ORDER BY mes ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS media_movil_3
FROM ventas_mes
ORDER BY mes
LIMIT 6;
mes soles media_movil_3 ------- -------- ------------- 2025-01 36743.94 36743.94 2025-02 20231.23 28487.58 2025-03 36274.87 31083.35 2025-04 35680.53 30728.88 2025-05 28200.36 33385.25 2025-06 25942.48 29941.12
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW es "esta fila y las dos
de antes", o sea una media móvil de tres meses. Sirve para ver la tendencia sin
que un mes raro te la tape: enero fue 36.743 y febrero 20.231, y a partir de la
segunda fila la media de tres va suavecita entre 28 y 34 mil.
Fíjate en las dos primeras filas: enero no tiene dos meses antes, así que su media es él solo. Las medias móviles siempre arrancan cojas y eso hay que decirlo en el reporte 🌸
Mirar la fila de al lado: LAG y LEAD
La pregunta más frecuente del mundo de los reportes: ¿cuánto crecimos respecto al mes pasado?
WITH ventas_mes AS (
SELECT STRFTIME('%Y-%m', fecha) AS mes, ROUND(SUM(monto), 2) AS soles
FROM pedidos
WHERE fecha >= '2026-01-01'
GROUP BY mes
)
SELECT mes, soles,
LAG(soles) OVER (ORDER BY mes) AS mes_anterior,
ROUND(soles - LAG(soles) OVER (ORDER BY mes), 2) AS diferencia,
ROUND(100.0 * (soles - LAG(soles) OVER (ORDER BY mes)) / LAG(soles) OVER (ORDER BY mes), 1) AS variacion
FROM ventas_mes
ORDER BY mes;
mes soles mes_anterior diferencia variacion ------- -------- ------------ ---------- --------- 2026-01 29373.17 2026-02 24033.91 29373.17 -5339.26 -18.2 2026-03 28103.64 24033.91 4069.73 16.9 2026-04 27651.42 28103.64 -452.22 -1.6 2026-05 33711.97 27651.42 6060.55 21.9 2026-06 25293.12 33711.97 -8418.85 -25.0
LAG trae el valor de la fila anterior y LEAD el de
la siguiente. Con eso, comparar meses deja de ser un rompecabezas de
subconsultas y pasa a ser una columna más.
Enero está vacío en las tres columnas, y está bien: no hay mes anterior, así
que es NULL. Del capítulo 4 ya sabemos que cualquier cuenta con
NULL da NULL, por eso la diferencia y la variación
también salen vacías.
Y mira lo que dice el negocio: febrero cae 18%, mayo sube 22%, junio cae 25%. Junio no está terminado en esta base, o sea que esa caída puede ser mitad realidad y mitad calendario. El último mes de un reporte casi siempre está incompleto y casi siempre alguien lo lee como una caída 🫠
Desde qué versión hay funciones de ventana
| PostgreSQL | desde la 8.4, año 2009 |
| MySQL | desde la 8.0, año 2018 |
| SQL Server | desde 2005 las de ranking, desde 2012 el resto |
| SQLite | desde la 3.25, año 2018 |
La sintaxis es la misma en los cuatro, que es la buena noticia. La mala es MySQL 5.7 y SQLite viejito, que no las tienen y todavía andan por ahí: si tu consulta da error de sintaxis en el OVER, mira la versión antes de volverte loca.
Repartir en grupos: NTILE
WITH por_cliente AS (
SELECT c.id, c.nombre, SUM(p.monto) AS soles
FROM clientes c JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.id, c.nombre
),
en_cuartiles AS (
SELECT nombre, soles, NTILE(4) OVER (ORDER BY soles DESC) AS cuartil
FROM por_cliente
)
SELECT cuartil, COUNT(*) AS clientes,
ROUND(MIN(soles), 2) AS el_mas_bajo,
ROUND(MAX(soles), 2) AS el_mas_alto,
ROUND(SUM(soles), 2) AS soles
FROM en_cuartiles
GROUP BY cuartil
ORDER BY cuartil;
cuartil clientes el_mas_bajo el_mas_alto soles ------- -------- ----------- ----------- --------- 1 30 5230.87 10131.72 208380.05 2 30 4235.85 5173.36 142569.61 3 30 2820.29 4217.49 106697.68 4 29 436.26 2749.91 59795.19
NTILE(4) parte la lista ordenada en cuatro montones del mismo
tamaño. Como son 119 clientes y no se divide exacto, el último se queda con 29 y
los otros tres con 30.
Y ahí tienes una segmentación de clientes hecha en SQL, sin machine learning ni nada: el cuartil de arriba, 30 clientes, se lleva 208.380 soles de los 517.442 totales, o sea el 40%. Los 29 de abajo aportan 59.795, un 12%. Eso es una conversación de negocio completa 💡
Ventanas sobre grupos, que parece imposible y no lo es
SELECT canal, COUNT(*) AS pedidos,
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS porcentaje
FROM pedidos
GROUP BY canal
ORDER BY pedidos DESC;
canal pedidos porcentaje ----------- ------- ---------- Web 234 26.0 WhatsApp 231 25.7 Marketplace 229 25.4 Tienda 206 22.9
Ese SUM(COUNT(*)) OVER () es raro de ver la primera vez y es
perfectamente legal: primero el GROUP BY arma los cuatro grupos con
su cuenta, y después la ventana suma esas cuatro cuentas. Un
COUNT de COUNT.
Así sacas porcentajes sobre el total sin ninguna subconsulta, y es de las cosas que más quedan bien en un reporte.
Con PARTITION BY el porcentaje se calcula sobre el grupo que
quieras. Aquí, cuánto pesa cada cliente dentro de su propia ciudad.
SELECT c.ciudad, c.nombre, ROUND(SUM(p.monto), 2) AS soles,
ROUND(100.0 * SUM(p.monto) / SUM(SUM(p.monto)) OVER (PARTITION BY c.ciudad), 1) AS peso_en_su_ciudad
FROM clientes c JOIN pedidos p ON p.id_cliente = c.id
WHERE c.ciudad = 'Lima'
GROUP BY c.id, c.ciudad, c.nombre
ORDER BY soles DESC
LIMIT 5;
ciudad nombre soles peso_en_su_ciudad ------ --------------------- -------- ----------------- Lima Distribuidora Paz 058 10131.72 15.9 Lima Almacenes Vega 072 6470.85 10.2 Lima Comercial Rojas 021 5230.87 8.2 Lima Almacenes Vega 039 5068.22 8.0 Lima Minimarket Aurora 107 4870.84 7.6
Distribuidora Paz 058 es el 15,9% de toda Lima ella sola. Cuando un cliente pesa así, el riesgo tiene nombre y apellido 😬
Ejercicios
Siete sobre tienda.db. Intenta antes de abrir 💛
1. El mejor cliente de cada ciudad
Un solo cliente por ciudad: el que más soles dejó.
WITH por_ciudad AS (
SELECT c.ciudad, c.id, c.nombre, SUM(p.monto) AS soles
FROM clientes c JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.ciudad, c.id, c.nombre
),
ranking AS (
SELECT ciudad, nombre, soles,
ROW_NUMBER() OVER (PARTITION BY ciudad ORDER BY soles DESC) AS puesto
FROM por_ciudad
)
SELECT ciudad, nombre, ROUND(soles, 2) AS soles
FROM ranking
WHERE puesto = 1
ORDER BY soles DESC;
ciudad nombre soles -------- -------------------------- -------- Lima Distribuidora Paz 058 10131.72 Piura Almacenes Vega 067 9516.06 Chiclayo Market Central 087 8148.56 Trujillo Restaurante Miraflores 093 8119.95 Arequipa Cafe del Puerto 060 7948.71 Cusco Mayorista Peru 027 7732.46
Dos CTE encadenados: uno agrupa y el otro numera. Se podría hacer en uno solo, pero así se lee mejor y en SQL eso vale mucho.
2. El día anterior y el siguiente
Ventas de los primeros días de junio de 2026, con la del día de antes y la del día de después al lado.
WITH ventas_dia AS (
SELECT fecha, ROUND(SUM(monto), 2) AS soles
FROM pedidos
WHERE fecha BETWEEN '2026-06-01' AND '2026-06-10'
GROUP BY fecha
)
SELECT fecha, soles,
LAG(soles) OVER (ORDER BY fecha) AS dia_anterior,
LEAD(soles) OVER (ORDER BY fecha) AS dia_siguiente
FROM ventas_dia
ORDER BY fecha
LIMIT 5;
fecha soles dia_anterior dia_siguiente ---------- ------- ------------ ------------- 2026-06-01 1854.01 1133.93 2026-06-02 1133.93 1854.01 413.38 2026-06-03 413.38 1133.93 890.15 2026-06-04 890.15 413.38 756.51 2026-06-05 756.51 890.15 1525.18
Ojo con una trampa que no se ve: si un día no tuvo ventas, no hay fila, así
que LAG te trae el día que sí vendió, no "el día de ayer". Para que
sea de verdad ayer, necesitas el calendario recursivo del capítulo 8.
3. Cuánto se aleja cada pedido del promedio de su canal
Los primeros pedidos con su monto, el promedio de su canal y la diferencia.
SELECT id, canal, monto,
ROUND(AVG(monto) OVER (PARTITION BY canal), 2) AS promedio_del_canal,
ROUND(monto - AVG(monto) OVER (PARTITION BY canal), 2) AS diferencia
FROM pedidos
WHERE monto IS NOT NULL
ORDER BY id
LIMIT 5;
id canal monto promedio_del_canal diferencia -- ----------- ------ ------------------ ---------- 1 Web 892.06 615.99 276.07 2 Marketplace 731.09 611.5 119.59 3 Tienda 407.39 598.41 -191.02 4 Tienda 358.23 598.41 -240.18 5 Web 844.94 615.99 228.95
Esa columna de diferencia es el primer paso para buscar valores raros: si un pedido se aleja muchísimo del promedio de su canal, o es una venta buenísima o es un error de carga, y las dos cosas valen la pena mirarlas.
4. Ranking de segmentos
Los segmentos por número de clientes, con las tres formas de numerar.
SELECT segmento, COUNT(*) AS clientes,
ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) AS fila,
RANK() OVER (ORDER BY COUNT(*) DESC) AS rango,
DENSE_RANK() OVER (ORDER BY COUNT(*) DESC) AS rango_denso
FROM clientes
GROUP BY segmento;
segmento clientes fila rango rango_denso ---------- -------- ---- ----- ----------- Horeca 36 1 1 1 Bodega 35 2 2 2 Minimarket 25 3 3 3 Mayorista 24 4 4 4
Aquí las tres columnas dan lo mismo, y ese es el punto: sin empates las tres son idénticas. Por eso la diferencia no se nota hasta el día que hay un empate y el reporte sale mal.
5. Comparar contra el mejor
Los pedidos de Tienda, con el mayor del canal al lado y qué porcentaje representa cada uno.
SELECT canal, id, monto,
FIRST_VALUE(monto) OVER (PARTITION BY canal ORDER BY monto DESC) AS el_mayor_del_canal,
ROUND(100.0 * monto / FIRST_VALUE(monto) OVER (PARTITION BY canal ORDER BY monto DESC), 1) AS respecto_al_mayor
FROM pedidos
WHERE monto IS NOT NULL AND canal = 'Tienda'
ORDER BY monto DESC
LIMIT 4;
canal id monto el_mayor_del_canal respecto_al_mayor ------ --- ------- ------------------ ----------------- Tienda 151 1236.97 1236.97 100.0 Tienda 142 1170.3 1236.97 94.6 Tienda 236 1170.08 1236.97 94.6 Tienda 361 1150.04 1236.97 93.0
FIRST_VALUE trae el primer valor de la ventana. Con
ORDER BY monto DESC, el primero es el mayor. Y existe
LAST_VALUE, que tiene fama de traicionera porque por defecto la
ventana termina en la fila actual: para que sea el último de verdad hay que
escribirle ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
a mano.
6. El acumulado del año pasado
Los primeros meses de 2025 con la media de todos los meses y el acumulado, escribiendo el marco a mano.
WITH ventas_mes AS (
SELECT STRFTIME('%Y-%m', fecha) AS mes, ROUND(SUM(monto), 2) AS soles
FROM pedidos GROUP BY mes
)
SELECT mes, soles,
ROUND(AVG(soles) OVER (), 2) AS media_de_todos,
ROUND(SUM(soles) OVER (ORDER BY mes ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW), 2) AS acumulado
FROM ventas_mes
ORDER BY mes
LIMIT 4;
mes soles media_de_todos acumulado ------- -------- -------------- --------- 2025-01 36743.94 29591.88 36743.94 2025-02 20231.23 29591.88 56975.17 2025-03 36274.87 29591.88 93250.04 2025-04 35680.53 29591.88 128930.57
Ese ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW es
exactamente lo que hace OVER (ORDER BY mes) por defecto. Lo escribo
entero aquí para que lo reconozcas cuando lo veas, porque en código de trabajo
aparece muchísimo.
Y fíjate que AVG(soles) OVER (), sin ORDER BY, sí
mira todas las filas: 29.591 es la media de los 18 meses, y se repite igual en
todas.
7. Escríbelo para los cuatro
Sin ejecutar: el pedido más caro de cada canal, en los cuatro motores.
-- Se escribe IGUAL en PostgreSQL, MySQL 8, SQL Server y SQLite 3.25+.
WITH ranking AS (
SELECT canal, id, monto,
ROW_NUMBER() OVER (PARTITION BY canal ORDER BY monto DESC) AS puesto
FROM pedidos
WHERE monto IS NOT NULL
)
SELECT canal, id, monto FROM ranking WHERE puesto = 1;
Otra vez una sola versión para los cuatro. Las ventanas llegaron tarde a todos los motores, así que cuando llegaron ya estaban estandarizadas y nadie se inventó su propia sintaxis 🌟
Lo único que cambia es la versión mínima, y la que más muerde es MySQL: si tu empresa sigue en 5.7, esta consulta no corre y hay que resolverla con subconsultas correlacionadas, que es bastante más feo.
Lo que te llevas
- 🪟 Una función de ventana calcula un resumen sin aplastar las
filas. La marca es el
OVER. - 🧩
PARTITION BYes elGROUP BYde las ventanas, y la diferencia es que no reduce nada. - 🥇
ROW_NUMBERnunca empata,RANKempata y salta,DENSE_RANKempata y no salta. Sin empates las tres son iguales. - 🚫 No se puede filtrar por una ventana en el
WHERE: se calcula después. Se mete en un CTE y se filtra fuera. - 📈 Con
ORDER BYdentro delOVERtienes acumulados; conROWS BETWEENtienes medias móviles. - ↔️
LAGyLEADtraen la fila anterior y la siguiente. Es el crecimiento mes a mes en una columna. - 📊
NTILE(4)parte en cuartiles: el 25% de arriba de esta tienda son 30 clientes y el 40% de la venta. - 🗓️ Están en los cuatro motores con la misma sintaxis, pero MySQL las tiene desde la 8.0 y SQLite desde la 3.25, las dos de 2018.
En el capítulo 10 dejamos de leer y empezamos a escribir: crear tablas, tipos, claves y los cuatro autoincrementos, que es donde los motores se vuelven a separar del todo.
Que tengas lindo día! 🌸