En 04-05 dejamos una frase pendiente: el top N por grupo y "cualquier cálculo que deba agregar sin colapsar las filas" necesitan funciones de ventana. Este es el problema. SUM(importe) sobre las 47 líneas de TiendaVerde devuelve una fila con 727,95 €; pero lo que casi siempre quieres es las 47 líneas, cada una con su importe y, al lado, el total — para calcular el porcentaje que representa, su posición en un ranking o cuánto llevas acumulado.
Un GROUP BY no puede hacerlo: colapsa exactamente lo que quieres conservar. La cláusula OVER sí, y es la diferencia entre saber SQL y saber usarlo. En esta lección verás la anatomía de OVER (PARTITION BY ... ORDER BY ... marco), dónde se ejecuta una función de ventana y por qué eso hace imposible filtrarla en el WHERE —el error más frecuente que existe—, las tres familias con su tabla de referencia, el marco y la trampa clásica de LAST_VALUE, y seis casos reales de TiendaVerde: acumulado mensual, media móvil, variación respecto al mes anterior, top N por categoría, ranking de clientes y cada producto frente a la media de su categoría.
Contenido
- La idea central: agregar sin colapsar
- La anatomía de
OVER - Dónde se ejecuta: el error del
WHERE - Ranking:
ROW_NUMBER,RANK,DENSE_RANK,NTILE,PERCENT_RANK - Desplazamiento:
LAG,LEAD,FIRST_VALUE,LAST_VALUE,NTH_VALUE - Agregados como ventana, y el marco:
ROWSfrente aRANGE WINDOW: dar nombre a una ventana- Los casos de TiendaVerde
- Rendimiento y dialecto
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- La idea central: agregar sin colapsar
Una función de ventana calcula un valor para cada fila a partir de un conjunto de filas relacionadas con ella —su ventana—, sin reducir el número de filas del resultado.
La forma más pequeña posible es OVER (), la ventana vacía: "todas las filas del resultado".
-- Usamos la vista v_detalle_ventas de 10-01, que ya trae la columna `importe`
SELECT linea_id, producto, importe,
SUM(importe) OVER () AS total_general,
ROUND(100 * importe / SUM(importe) OVER (), 2) AS pct
FROM v_detalle_ventas
ORDER BY linea_id;| linea_id | producto | importe | total_general | pct |
|---|---|---|---|---|
| 1 | Aceite de oliva virgen extra 500 ml | 23.90 | 727.95 | 3.28 |
| 2 | Arroz integral ecológico 1 kg | 11.70 | 727.95 | 1.61 |
| 3 | Infusión de manzanilla ecológica 20 uds | 6.50 | 727.95 | 0.89 |
(3 primeras de 47 filas.) Las 47 líneas siguen ahí, y cada una lleva pegados los 727,95 € del total y su porcentaje. Con GROUP BY esto es imposible: o tienes el detalle, o tienes el total. Aquí tienes los dos.
Y esa es la comparación que hay que fijar antes de seguir:
GROUP BY |
Función de ventana | |
|---|---|---|
| Filas del resultado / el detalle | Una por grupo / se pierde | Todas las de entrada / se conserva |
| Se puede filtrar por el agregado / sirve para | Sí, con HAVING / resumir |
No directamente (apartado 3) / enriquecer cada fila |
- La anatomía de
OVER
OVERLa forma general es funcion(args) OVER (PARTITION BY expr ORDER BY expr ROWS/RANGE ...), con tres piezas: PARTITION BY dice en qué grupos se divide (sin él, todo es un solo grupo), ORDER BY en qué orden se recorre cada grupo, y el marco qué franja del grupo entra en el cálculo.
flowchart LR
A["47 líneas"] --> B["<b>PARTITION BY</b><br/>divide en grupos"] --> C["<b>ORDER BY</b><br/>ordena cada grupo"]
C --> D["<b>marco</b><br/>qué filas del grupo entran<br/>para la fila actual"] --> E["un valor por cada<br/>una de las 47 filas"]
Las tres partes son opcionales e independientes, y cada combinación significa algo distinto:
| Escrito | Significa |
|---|---|
OVER () |
Todas las filas, sin orden ni marco: el total general |
OVER (PARTITION BY categoria_id) / OVER (ORDER BY fecha) |
El total de su categoría / el acumulado hasta la fila actual |
El detalle que sorprende a todo el mundo: añadir ORDER BY a una ventana cambia el resultado de un SUM, porque activa un marco por omisión que va del principio hasta la fila actual. Sin ORDER BY, SUM da el total del grupo; con él, da el acumulado. No es un error: es la puerta de entrada al marco del apartado 6.
- Dónde se ejecuta: el error del
WHERE
WHERELas funciones de ventana se evalúan casi al final: FROM → WHERE → GROUP BY → HAVING → funciones de ventana → SELECT → DISTINCT → ORDER BY → LIMIT. De ahí se deducen dos hechos importantes. El primero: una función de ventana ve solo las filas que sobrevivieron al WHERE. Si filtras por pais = 'Francia', el SUM(...) OVER () será el total de Francia, no el de la tienda. El segundo es el error número uno de la lección:
-- ⚠️ INCORRECTA: filtrar por una función de ventana en el WHERE
SELECT producto, SUM(cantidad) AS uds FROM v_detalle_ventas
WHERE ROW_NUMBER() OVER (ORDER BY SUM(cantidad) DESC) <= 3
GROUP BY producto;ERROR: window functions are not allowed in WHERE
LINE 2: WHERE ROW_NUMBER() OVER (ORDER BY SUM(cantidad) DESC) <= 3
^Y no es una limitación arbitraria: cuando se evalúa el WHERE, la función de ventana todavía no se ha calculado, porque necesita saber qué filas pasan el filtro. Sería circular. La solución es siempre la misma —calcular en un nivel y filtrar en el siguiente—, y con CTE (10-02) se lee perfectamente:
-- ✅ CORRECTA
WITH ventas AS (
SELECT producto_id, producto, SUM(cantidad) AS uds
FROM v_detalle_ventas GROUP BY producto_id, producto),
ranking AS (
SELECT producto, uds, ROW_NUMBER() OVER (ORDER BY uds DESC, producto_id) AS puesto
FROM ventas)
SELECT * FROM ranking WHERE puesto <= 3;| producto | uds | puesto |
|---|---|---|
| Arroz integral ecológico 1 kg | 14 | 1 |
| Tomate triturado ecológico 400 g | 14 | 2 |
| Kombucha de jengibre 750 ml | 12 | 3 |
Recuérdalo así: una función de ventana no se filtra, se envuelve. Lo mismo vale para el HAVING y para los JOIN.
- Ranking:
ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK
ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK| Función | Qué devuelve | Con empates | ¿Deja huecos? |
|---|---|---|---|
ROW_NUMBER() |
Número correlativo 1, 2, 3… | Los rompe arbitrariamente | No |
RANK() / DENSE_RANK() |
Posición deportiva / posición sin huecos | Mismo número a los empatados | Sí tras dos primeros (3.º) / no (2.º) |
NTILE(n) / PERCENT_RANK() / CUME_DIST() |
n cubos del mismo tamaño / posición relativa entre 0 y 1 |
Reparte por posición / igual que RANK |
— |
TiendaVerde tiene un empate perfecto para verlo: el arroz y el tomate, con 14 unidades vendidas cada uno.
SELECT producto, SUM(cantidad) AS uds,
ROW_NUMBER() OVER (ORDER BY SUM(cantidad) DESC, producto_id) AS row_number,
RANK() OVER (ORDER BY SUM(cantidad) DESC) AS rank,
DENSE_RANK() OVER (ORDER BY SUM(cantidad) DESC) AS dense_rank
FROM v_detalle_ventas GROUP BY producto_id, producto ORDER BY uds DESC, producto_id;| producto | uds | row_number | rank | dense_rank |
|---|---|---|---|---|
| Arroz integral ecológico 1 kg | 14 | 1 | 1 | 1 |
| Tomate triturado ecológico 400 g | 14 | 2 | 1 | 1 |
| Kombucha de jengibre 750 ml | 12 | 3 | 3 | 2 |
| Aceite de oliva virgen extra 500 ml | 9 | 4 | 4 | 3 |
| Infusión de manzanilla ecológica 20 uds | 9 | 5 | 4 | 3 |
(5 primeras de 17 filas; la sexta, el cepillo de bambú con 9 uds, recibe 6, 4 y 3; la séptima, Pasta de espelta con 7 uds, recibe 7, 7 y 4: los 17 productos que se han vendido alguna vez.) .)* Léelo por columnas y no lo olvidarás. ROW_NUMBER numera 1, 2, 3, 4, 5, 6, 7: nunca repite, y para eso hace falta un desempate explícito (, p.id) o la elección es arbitraria y puede cambiar entre ejecuciones. RANK da 1, 1, 3, 4, 4, 4, 7: los empatados comparten posición y el siguiente salta tantos puestos como empatados hubiera; es el podio deportivo. DENSE_RANK da 1, 1, 2, 3, 3, 3, 4: comparten posición sin dejar huecos, así que es "el segundo mejor valor", no "el segundo".
Cuál usar: ROW_NUMBER para elegir una fila por grupo (deduplicar, top 1); RANK para un ranking publicable; DENSE_RANK cuando lo que numeras son valores distintos, no filas.
- Desplazamiento:
LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE
LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUEEstas funciones miran a otra fila de la misma ventana. Sobre la serie mensual de TiendaVerde:
SELECT mes, facturacion, LAG(facturacion) OVER w AS mes_anterior,
LAG(facturacion, 2, 0) OVER w AS hace_2_meses, LEAD(facturacion) OVER w AS mes_siguiente
FROM mv_ventas_mensuales WINDOW w AS (ORDER BY mes) ORDER BY mes;| mes | facturacion | mes_anterior | hace_2_meses | mes_siguiente |
|---|---|---|---|---|
| 2025-03 | 68.80 | (null) | 0.00 | 61.28 |
| 2025-04 | 61.28 | 68.80 | 0.00 | 58.85 |
| 2025-05 | 58.85 | 61.28 | 68.80 | 95.48 |
(3 primeras de 12 filas.) Los tres argumentos de LAG y LEAD son (columna, desplazamiento, valor_por_defecto): por omisión el desplazamiento es 1 y el valor por defecto es NULL —de ahí el *(null)* del primer mes—, mientras que LAG(facturacion, 2, 0) mira dos filas atrás y devuelve 0 cuando no hay tal fila. Ese tercer argumento evita un COALESCE y, sobre todo, evita que una resta se convierta en NULL.
FIRST_VALUE, LAST_VALUE y NTH_VALUE(expr, n) devuelven el valor de la primera, la última y la n-ésima fila del marco. Sobre esta misma serie y con el marco completo, FIRST_VALUE da 68,80 € (marzo de 2025), LAST_VALUE da 49,33 € (febrero de 2026) y NTH_VALUE(facturacion, 3) da 58,85 € en las doce filas. Ese "con el marco completo" no es un detalle: el final de este apartado 6 explica por qué sin él el resultado es otro.
- Agregados como ventana, y el marco
Cualquier función de agregación del módulo 4 acepta OVER: SUM, AVG, COUNT, MIN, MAX, STRING_AGG, y también las que llevan FILTER. La sintaxis es idéntica; lo único que cambia es que el resultado se pega a cada fila en lugar de colapsarla.
SELECT e.nombre || ' ' || e.apellidos AS empleado, e.salario,
ROUND(AVG(e.salario) OVER (), 2) AS media_empresa,
ROUND(e.salario - AVG(e.salario) OVER (), 2) AS dif_media,
SUM(e.salario) OVER (ORDER BY e.salario DESC, e.id) AS masa_acumulada
FROM empleados AS e ORDER BY e.salario DESC;| empleado | salario | media_empresa | dif_media | masa_acumulada |
|---|---|---|---|---|
| Rosa Alcázar Vives | 62000.00 | 35037.50 | 26962.50 | 62000.00 |
| Andrés Company Talens | 41000.00 | 35037.50 | 5962.50 | 103000.00 |
| Daniel Vercher Lluch | 35000.00 | 35037.50 | -37.50 | 177500.00 |
(3 de los 8 empleados —falta Beatriz, 39 500 €, en el puesto 3—; la columna acumulada cierra en 280 300,00 €, la masa salarial total.) Las cifras canónicas del curso —suma 280 300 €, media 35 037,50 €— aparecen aquí junto a cada empleado, y la lectura de negocio es inmediata: Daniel gana 37,50 € menos que la media exacta de la empresa, y los tres primeros salarios se comen más de la mitad de la masa salarial.
El marco: ROWS frente a RANGE
El marco define qué filas del grupo entran en el cálculo para cada fila concreta. Se escribe ROWS BETWEEN inicio AND fin o RANGE BETWEEN inicio AND fin, con estos extremos:
| Extremo | Significa |
|---|---|
UNBOUNDED PRECEDING / UNBOUNDED FOLLOWING |
Desde la primera fila del grupo / hasta la última |
n PRECEDING / n FOLLOWING |
n filas antes / después de la actual |
CURRENT ROW |
La actual (con RANGE: la actual y todas sus empatadas) |
Y la diferencia entre las dos palabras clave es exactamente esta: ROWS cuenta filas físicas —2 PRECEDING son las dos filas de encima, se llamen como se llamen—, mientras que RANGE cuenta valores del ORDER BY: todas las filas con el mismo valor de ordenación que la actual son pares y entran o salen juntas.
SELECT producto, SUM(cantidad) AS uds,
SUM(SUM(cantidad)) OVER (ORDER BY SUM(cantidad) DESC) AS acum_range,
SUM(SUM(cantidad)) OVER (ORDER BY SUM(cantidad) DESC, producto_id
ROWS UNBOUNDED PRECEDING) AS acum_rows
FROM v_detalle_ventas GROUP BY producto_id, producto ORDER BY uds DESC, producto_id;| producto | uds | acum_range | acum_rows |
|---|---|---|---|
| Arroz integral ecológico 1 kg | 14 | 28 | 14 |
| Tomate triturado ecológico 400 g | 14 | 28 | 28 |
| Kombucha de jengibre 750 ml | 12 | 40 | 40 |
| Aceite de oliva virgen extra 500 ml | 9 | 67 | 49 |
(4 primeras de 17 filas.) RANGE da 28 a los dos empatados —los suma juntos, porque valen lo mismo— y ROWS da 14 y 28, avanzando fila a fila. Los tres productos de 9 unidades repiten el patrón: 67, 67, 67 con RANGE; 49, 58, 67 con ROWS.
La trampa de LAST_VALUE
El marco por omisión, cuando hay ORDER BY y no escribes marco, es RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Es decir: la ventana termina en la fila actual. Y ahí está la trampa más famosa de todo SQL:
-- ⚠️ INCORRECTA: no devuelve el último mes
SELECT mes, facturacion,
FIRST_VALUE(facturacion) OVER (ORDER BY mes) AS primero,
LAST_VALUE(facturacion) OVER (ORDER BY mes) AS ultimo
FROM mv_ventas_mensuales ORDER BY mes;| mes | facturacion | primero | ultimo |
|---|---|---|---|
| 2025-03 | 68.80 | 68.80 | 68.80 |
| 2025-04 | 61.28 | 68.80 | 61.28 |
(2 primeras de 12 filas.) ultimo es siempre la propia fila, porque el marco acaba en ella: la "última fila de la ventana" es la actual. FIRST_VALUE funciona por casualidad, porque la primera fila del marco sí es la primera de todas. La corrección es escribir el marco entero: LAST_VALUE(facturacion) OVER (ORDER BY mes ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING).
Y entonces ultimo vale 49.33 en las doce filas: la facturación de febrero de 2026. Regla: en cuanto uses LAST_VALUE o NTH_VALUE, escribe el marco explícitamente. Y ojo, tampoco es cierto que "sin ORDER BY no hay marco": sin ORDER BY, el marco por omisión es el grupo entero, que es justo lo que hace que OVER () dé el total general.
WINDOW: dar nombre a una ventana
WINDOW: dar nombre a una ventanaCuando la misma ventana aparece tres veces, se nombra una vez al final de la consulta —entre el HAVING y el ORDER BY— y se referencia por su nombre, como en el apartado 5: SELECT mes, SUM(facturacion) OVER w, AVG(facturacion) OVER w FROM mv_ventas_mensuales WINDOW w AS (ORDER BY mes ROWS BETWEEN 2 PRECEDING AND CURRENT ROW);
Se pueden declarar varias separadas por comas, y una puede heredar de otra: WINDOW w AS (PARTITION BY categoria_id), w2 AS (w ORDER BY precio DESC). Es puro azúcar sintáctico, pero elimina la fuente de errores más aburrida: cambiar el ORDER BY en dos de las tres copias y olvidarse de la tercera.
- Los casos de TiendaVerde
8.1. Acumulado, media móvil y variación mensual
Los tres cálculos que pide cualquier cuadro de mando, en una sola consulta sobre la serie de doce meses:
SELECT mes, facturacion,
SUM(facturacion) OVER w AS acumulado,
ROUND(AVG(facturacion) OVER (ORDER BY mes
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS media_movil_3,
LAG(facturacion) OVER w AS mes_anterior,
ROUND(facturacion - LAG(facturacion) OVER w, 2) AS variacion,
ROUND(100 * (facturacion - LAG(facturacion) OVER w)
/ LAG(facturacion) OVER w, 2) AS variacion_pct
FROM mv_ventas_mensuales
WINDOW w AS (ORDER BY mes) ORDER BY mes;| mes | facturacion | acumulado | media_movil_3 | mes_anterior | variacion | variacion_pct |
|---|---|---|---|---|---|---|
| 2025-03 | 68.80 | 68.80 | 68.80 | (null) | (null) | (null) |
| 2025-04 | 61.28 | 130.08 | 65.04 | 68.80 | -7.52 | -10.93 |
| 2025-05 | 58.85 | 188.93 | 62.98 | 61.28 | -2.43 | -3.97 |
| 2025-06 | 95.48 | 284.41 | 71.87 | 58.85 | 36.63 | 62.24 |
| 2025-07 | 44.60 | 329.01 | 66.31 | 95.48 | -50.88 | -53.29 |
| 2025-09 | 32.76 | 410.04 | 41.88 | 48.27 | -15.51 | -32.13 |
| 2025-10 | 97.20 | 507.24 | 59.41 | 32.76 | 64.44 | 196.70 |
| 2025-11 | 31.70 | 538.94 | 53.89 | 97.20 | -65.50 | -67.39 |
| 2025-12 | 64.58 | 603.52 | 64.49 | 31.70 | 32.88 | 103.72 |
| 2026-01 | 75.10 | 678.62 | 57.13 | 64.58 | 10.52 | 16.29 |
| 2026-02 | 49.33 | 727.95 | 63.00 | 75.10 | -25.77 | -34.31 |
(Falta la fila de 2025-08: 48,27 € de facturación, 377,28 € de acumulado, 62,78 € de media móvil y +3,67 € / +8,23 % respecto a julio.) Tres lecturas y una advertencia. El acumulado cierra en 727,95 € y pasa por 603,52 € en diciembre de 2025: son las dos cifras canónicas del curso, y confirman que la serie está bien. La media móvil de 3 meses alisa el ruido: la facturación real salta entre 31,70 € y 97,20 €, mientras que la media móvil se mueve en una banda mucho más estrecha, entre 41,88 € y 71,87 €. Y la variación porcentual es espectacular pero engañosa: ese +196,70 % de octubre no es un boom comercial, es que septiembre tuvo un solo pedido. Con volúmenes pequeños, los porcentajes mienten — y esa es una lección de análisis, no de SQL.
La advertencia: las tres primeras filas de media_movil_3 no son medias de tres meses, sino de una y de dos, porque el marco 2 PRECEDING no tiene de dónde tirar. Si el informe debe mostrar solo medias completas, hay que anularlas con CASE WHEN COUNT(*) OVER (ORDER BY mes ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) = 3 THEN ... END.
Con
PARTITION BY, el acumulado se reinicia.SUM(facturacion) OVER (PARTITION BY left(mes, 4) ORDER BY mes)da el acumulado del año: enero de 2026 vuelve a empezar en 75,10 € y febrero cierra en 124,43 €, la facturación de 2026.
8.2. Top N por categoría, y las tres formas de hacerlo
Aquí se cierran dos promesas: el DISTINCT ON de 02-04 y el LATERAL de 07-04. El patrón canónico es ROW_NUMBER dentro de una CTE, filtrado fuera:
WITH ventas AS (
SELECT dv.categoria_id, cat.nombre AS categoria, dv.producto_id, dv.producto,
ROUND(SUM(dv.importe), 2) AS facturacion
FROM v_detalle_ventas AS dv JOIN categorias AS cat ON cat.id = dv.categoria_id
GROUP BY dv.categoria_id, cat.nombre, dv.producto_id, dv.producto)
SELECT categoria, producto, facturacion, puesto FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY categoria_id
ORDER BY facturacion DESC, producto_id) AS puesto
FROM ventas) AS r
WHERE puesto <= 2 ORDER BY categoria, puesto;| categoria | producto | facturacion | puesto |
|---|---|---|---|
| Alimentación | Aceite de oliva virgen extra 500 ml | 109.53 | 1 |
| Alimentación | Arroz integral ecológico 1 kg | 54.60 | 2 |
| Bebidas | Té verde matcha ceremonial 30 g | 88.00 | 1 |
| Bebidas | Kombucha de jengibre 750 ml | 56.43 | 2 |
| Cosmética natural | Crema facial de aloe vera 50 ml | 70.42 | 1 |
| Cosmética natural | Bálsamo labial de caléndula 15 ml | 32.20 | 2 |
| Higiene personal | Cepillo de dientes de bambú | 31.50 | 1 |
(6 de 9 filas; cierran Hogar sostenible con las bolsas reutilizables, 39,60 €, y el detergente, 32,48 €.) 9 filas en total: cuatro categorías aportan dos productos y Higiene personal solo uno, porque el desodorante nunca se vendió. Complementos no aparece: no ha vendido nada.
Y la comparación con las otras dos formas, sobre el ejemplo de 02-04 —el producto más caro de cada categoría—, que las tres resuelven con las mismas 6 filas:
DISTINCT ON (02-04) |
LATERAL (07-04) |
ROW_NUMBER (aquí) |
|
|---|---|---|---|
| Cómo se escribe | DISTINCT ON (categoria_id) ... ORDER BY categoria_id, precio DESC |
CROSS JOIN LATERAL (... LIMIT n) |
ROW_NUMBER() OVER (PARTITION BY ...) filtrado fuera |
| ¿Portable? | No: solo PostgreSQL | Sí (CROSS APPLY en SQL Server) |
Sí: estándar SQL, en todos los motores modernos |
| Top 1 / top N con N > 1 | Lo más corto / no puede | Verboso / sí | Verboso / sí |
| Deja ver la posición / grupos vacíos | No / no aparecen | No / sí con LEFT JOIN LATERAL ... ON TRUE |
Sí, columna / no aparecen |
| Rendimiento con índice por grupo | Bueno | El mejor con muchos grupos y LIMIT bajo |
Bueno; recorre todo el grupo |
El criterio: top 1 en PostgreSQL y sin necesidad de portabilidad, DISTINCT ON; top N con muchísimos grupos y un índice que lo soporte, LATERAL; en cualquier otro caso, ROW_NUMBER, que además te regala la columna de posición.
8.3. Ranking de clientes
WITH ventas AS (
SELECT cliente_id, cliente, pais, ROUND(SUM(importe), 2) AS facturacion
FROM v_detalle_ventas GROUP BY cliente_id, cliente, pais)
SELECT cliente, pais, facturacion,
RANK() OVER (ORDER BY facturacion DESC) AS puesto,
NTILE(4) OVER (ORDER BY facturacion DESC) AS cuartil,
ROUND(100 * facturacion / SUM(facturacion) OVER (), 2) AS pct_total,
ROUND(100 * SUM(facturacion) OVER (ORDER BY facturacion DESC)
/ SUM(facturacion) OVER (), 2) AS pct_acumulado
FROM ventas ORDER BY facturacion DESC;| cliente | pais | facturacion | puesto | cuartil | pct_total | pct_acumulado |
|---|---|---|---|---|---|---|
| Sofia Moreira Costa | Portugal | 111.88 | 1 | 1 | 15.37 | 15.37 |
| Lucía Martínez Soler | España | 107.60 | 2 | 1 | 14.78 | 30.15 |
| Camille Dubois | Francia | 70.87 | 3 | 1 | 9.74 | 39.89 |
| Carlos Ferrer Ibáñez | España | 59.46 | 6 | 2 | 8.17 | 65.89 |
(4 de los 12 clientes con compras — faltan Julien Moreau, 4.º con 66,90 €, y Javier Ortega Ruiz, 5.º con 62,93 €; cierran Diego con 31,70 €, Elena con 30,30 € y Marta con 29,53 €.) La columna pct_acumulado es un análisis de Pareto hecho con una función de ventana: los 6 primeros clientes explican el 65,89 % de la facturación. Y fíjate en que NTILE(4) reparte los 12 clientes en cuatro cuartiles de exactamente 3, sin mirar los importes: reparte por posición, no por valor.
8.4. Cada producto frente a la media de su categoría
En 07-02 esto se resolvía con una subconsulta correlacionada que recorría productos una vez por fila. Con PARTITION BY es una sola pasada:
SELECT p.id, p.nombre AS producto, cat.nombre AS categoria, p.precio,
ROUND(AVG(p.precio) OVER w, 2) AS media_categoria,
ROUND(p.precio - AVG(p.precio) OVER w, 2) AS diferencia,
ROUND(100 * p.precio / AVG(p.precio) OVER w, 2) AS pct_sobre_media
FROM productos AS p JOIN categorias AS cat ON cat.id = p.categoria_id
WINDOW w AS (PARTITION BY p.categoria_id) ORDER BY cat.id, p.precio DESC;| id | producto | categoria | precio | media_categoria | diferencia | pct_sobre_media |
|---|---|---|---|---|---|---|
| 1 | Aceite de oliva virgen extra 500 ml | Alimentación | 12.50 | 6.18 | 6.32 | 202.27 |
| 3 | Miel de azahar cruda 500 g | Alimentación | 9.75 | 6.18 | 3.57 | 157.77 |
| 5 | Tomate triturado ecológico 400 g | Alimentación | 1.95 | 6.18 | -4.23 | 31.55 |
| 6 | Crema facial de aloe vera 50 ml | Cosmética natural | 18.90 | 11.54 | 7.36 | 163.81 |
| 15 | Té verde matcha ceremonial 30 g | Bebidas | 22.00 | 8.90 | 13.10 | 247.19 |
(5 de las 20 filas.) Los 20 productos siguen ahí, cada uno con la media de su categoría al lado. El matcha, a 22,00 €, cuesta casi dos veces y media la media de Bebidas (8,90 €); el aceite, algo más del doble de la media de Alimentación (6,18 €). Ninguna subconsulta, ninguna repetición, una sola lectura de productos.
- Rendimiento y dialecto
Una función de ventana obliga al motor a ordenar por PARTITION BY + ORDER BY antes de calcular; en el EXPLAIN verás un nodo WindowAgg casi siempre precedido de un Sort. Tres consecuencias prácticas: un índice sobre (columna_particion, columna_orden) puede eliminar ese Sort; varias funciones que comparten la misma ventana se calculan en un solo WindowAgg, así que reutilizar la ventana (con WINDOW) es también más rápido; y filtra antes con WHERE siempre que puedas, porque la ventana trabajará sobre menos filas. Aun así, una función de ventana casi siempre gana a la alternativa: donde una correlacionada hace N pasadas, la ventana hace una.
Nota de dialecto: las funciones de ventana son estándar SQL:2003 y hoy están en todas partes: PostgreSQL (desde 8.4, con el conjunto más completo), MySQL 8.0, MariaDB 10.2, SQLite 3.25, SQL Server 2012 y Oracle. Diferencias que conviene conocer: SQL Server no admite
RANGEconn PRECEDING(soloROWS) y hasta 2022 no tuvoIGNORE NULLSenLAG/LEAD; MySQL 8 no tiene la cláusulaFILTER; Oracle las llama analytic functions y añadeKEEP (DENSE_RANK FIRST/LAST); yGROUPScomo tercera modalidad de marco (además deROWSyRANGE), junto conEXCLUDE, existe en PostgreSQL 11+ y falta en la mayoría.
Errores Comunes y Consejos
- Filtrar por una función de ventana en el
WHEREo elHAVING.window functions are not allowed in WHERE. Se calculan después. Envuélvela en una CTE o una tabla derivada y filtra fuera. - Esperar que
LAST_VALUEdevuelva el último valor. Con el marco por omisión devuelve la fila actual. EscribeROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING. Y al revés: añadirORDER BYa unSUM() OVERsin querer un acumulado lo convierte en acumulado; si quieres el total del grupo, no pongasORDER BY. - Usar
ROW_NUMBERsin desempate. Con valores repetidos, qué fila recibe el 1 es arbitrario y puede cambiar entre ejecuciones. Añade siempre una columna única alORDER BYde la ventana. - Confundir
RANKconDENSE_RANK—el primero deja huecos tras un empate (1, 1, 3) y el segundo no (1, 1, 2)— o creer queRANGEyROWSson sinónimos: solo lo son sin empates; con ellos,RANGElos mete todos juntos (28 en lugar de 14). - Olvidar que la ventana solo ve lo que pasó el
WHERE: un "porcentaje sobre el total" en una consulta filtrada es el porcentaje sobre el total filtrado. Y poner una función de ventana dentro de un agregado:SUM(ROW_NUMBER() OVER ())no es válido; al revés sí,SUM(SUM(x)) OVER (...)es correcto y frecuente — el agregado se calcula primero, la ventana después. - Consejo: empieza siempre por
OVER ()y ve añadiendo. Primero el total general, luegoPARTITION BY, luegoORDER BY, y solo al final el marco. Ejecuta después de cada paso y mira cómo cambia la columna. - Consejo:
ROWSpor omisión, que no tiene sorpresas con los empates;RANGEsolo cuando el comportamiento por pares sea deliberado. Y nombra la ventana conWINDOWen cuanto se repita dos veces: evitas cambiar elORDER BYen una copia y no en la otra, y el motor la calcula una sola vez.
Ejercicios
Ejercicio 1
Sobre las 47 líneas de pedido, escribe una consulta que muestre, para cada línea: el pedido, el producto, su importe, el total de su pedido, el porcentaje que representa dentro del pedido y su posición dentro del pedido por importe. Sin GROUP BY, sin subconsultas: solo OVER. Comprueba en el pedido 1 que los porcentajes suman 100 y el total es 42,10 €.
Ejercicio 2
Dirección quiere los tres clientes que más facturan de cada país, con su puesto. (1) Escríbelo con ROW_NUMBER y una CTE. (2) ¿Cuántas filas devuelve y por qué no son 9? (3) Si en lugar de "los tres primeros" pidieran "todos los que empatan en el tercer puesto", ¿qué función usarías?
Ejercicio 3
Un compañero quiere marcar los meses en los que la facturación superó la media de los doce meses y ha escrito SELECT mes, facturacion FROM mv_ventas_mensuales WHERE facturacion > AVG(facturacion) OVER ();. (1) ¿Qué error da y por qué? (2) Corrígelo con una CTE, mostrando también la media y la diferencia. (3) ¿Cuántos meses superan la media y cuál es esa media?
Soluciones
Solución 1
SELECT pedido_id, producto, importe,
SUM(importe) OVER w AS total_pedido,
ROUND(100 * importe / SUM(importe) OVER w, 2) AS pct_pedido,
ROW_NUMBER() OVER (PARTITION BY pedido_id
ORDER BY importe DESC, linea_id) AS puesto_en_pedido
FROM v_detalle_ventas
WINDOW w AS (PARTITION BY pedido_id) ORDER BY pedido_id, puesto_en_pedido;| pedido_id | producto | importe | total_pedido | pct_pedido | puesto_en_pedido |
|---|---|---|---|---|---|
| 1 | Aceite de oliva virgen extra 500 ml | 23.90 | 42.10 | 56.77 | 1 |
| 1 | Arroz integral ecológico 1 kg | 11.70 | 42.10 | 27.79 | 2 |
| 1 | Infusión de manzanilla ecológica 20 uds | 6.50 | 42.10 | 15.44 | 3 |
(3 primeras de 47 filas.) 56,77 + 27,79 + 15,44 = 100,00 y el total del pedido 1 es 42,10 €, como en 07-04. Las 47 filas se conservan: PARTITION BY pedido_id calcula el total por pedido y lo pega a cada una de sus líneas. Aquí se ve la ganancia: con GROUP BY habría 20 filas y ningún detalle.
Solución 2 — 1.
WITH ventas AS (
SELECT pais, cliente, ROUND(SUM(importe), 2) AS facturacion
FROM v_detalle_ventas GROUP BY cliente_id, pais, cliente)
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY pais ORDER BY facturacion DESC) AS puesto
FROM ventas) AS r
WHERE puesto <= 3 ORDER BY pais, puesto;| pais | cliente | facturacion | puesto |
|---|---|---|---|
| España | Lucía Martínez Soler | 107.60 | 1 |
| España | Javier Ortega Ruiz | 62.93 | 2 |
| España | Carlos Ferrer Ibáñez | 59.46 | 3 |
| Francia | Camille Dubois | 70.87 | 1 |
| Francia | Julien Moreau | 66.90 | 2 |
| Portugal | Sofia Moreira Costa | 111.88 | 1 |
(6 de 7 filas; cierra Tiago Almeida Nunes, 2.º de Portugal con 44,60 €.) 2. Siete filas, no nueve, porque Francia y Portugal solo tienen dos clientes con compras cada uno. ROW_NUMBER no inventa filas: si un grupo tiene menos de N, aporta las que tenga. (España tiene 8 clientes con compras y aporta 3.)
3. RANK(), no DENSE_RANK ni ROW_NUMBER. Con RANK, todos los empatados en el tercer valor reciben el 3 y WHERE puesto <= 3 los devuelve todos. ROW_NUMBER habría cortado arbitrariamente por uno; DENSE_RANK habría devuelto de más, porque su "3" es el tercer valor distinto, no el tercer puesto.
Solución 3 — 1. Da ERROR: window functions are not allowed in WHERE. El motor evalúa el WHERE antes de calcular las funciones de ventana, así que en ese momento AVG(facturacion) OVER () todavía no existe; y no podría existir, porque la ventana necesita saber qué filas pasan el filtro y el filtro necesita el valor de la ventana. 2 y 3: con WITH mensual AS (SELECT mes, facturacion, ROUND(AVG(facturacion) OVER (), 2) AS media_anual FROM mv_ventas_mensuales) y luego SELECT mes, facturacion, media_anual, ROUND(facturacion - media_anual, 2) AS diferencia FROM mensual WHERE facturacion > media_anual ORDER BY facturacion DESC, salen seis de los doce meses por encima de una media de 60,66 € (727,95 € entre 12): 2025-10 con +36,54 €, 2025-06 con +34,82 €, 2026-01 con +14,44 €, 2025-03 con +8,14 €, 2025-12 con +3,92 € y 2025-04 con +0,62 €. La media se calcula una sola vez en la CTE y queda disponible como columna normal: por eso se puede filtrar por ella y restarla en el mismo paso.
Conclusión
Las funciones de ventana son la herramienta que faltaba desde el módulo 4:
- Una función de ventana calcula sobre un grupo de filas sin colapsarlas: las 47 líneas de TiendaVerde salen intactas, cada una con su total, su porcentaje o su posición al lado.
OVER ()es la ventana vacía —el total general— y es por donde hay que empezar. La anatomía esOVER (PARTITION BY ... ORDER BY ... marco), con las tres partes opcionales, y añadirORDER BYcambia el resultado de un agregado: activa el marco "hasta la fila actual" y convierte el total en acumulado. - Se evalúan después de
WHERE,GROUP BYyHAVING, y por eso no se puede filtrar por ellas en elWHERE(window functions are not allowed in WHERE). La solución universal: calcular en una CTE y filtrar fuera. No se filtra, se envuelve. - Ranking:
ROW_NUMBERnumera sin repetir (1, 2, 3),RANKempata y deja hueco (1, 1, 3),DENSE_RANKempata sin hueco (1, 1, 2) — demostrado con el empate real del arroz y el tomate a 14 unidades. MásNTILEpara cuartiles yPERCENT_RANKpara posiciones relativas. Desplazamiento:LAGyLEADcon sus argumentos(columna, n, por_defecto), yFIRST_VALUE/LAST_VALUE/NTH_VALUE, que exigen marco explícito. - El marco:
ROWScuenta filas físicas,RANGEagrupa las filas con el mismo valor de ordenación (28 en lugar de 14 en el empate del arroz). El marco por omisión conORDER BYesRANGE UNBOUNDED PRECEDING AND CURRENT ROW—de ahí queLAST_VALUEdevuelva la fila actual y no la última—; sinORDER BY, el grupo entero. - Los casos de TiendaVerde, todos verificados: acumulado mensual que cierra en 727,95 € pasando por 603,52 € en diciembre; media móvil de 3 meses entre 41,88 € y 71,87 €; variación mensual con su
+196,70 %engañoso de octubre; top 2 por categoría conROW_NUMBER(9 filas) y la tabla que lo enfrenta aDISTINCT ONy aLATERAL; ranking de clientes con el Pareto que da un 65,89 % acumulado en los seis primeros; y cada producto frente a la media de su categoría en una sola pasada, donde 07-02 hacía una correlacionada.
Con esto ya no hay pregunta analítica sobre TiendaVerde que no sepas escribir. Pero todo lo que has hecho hasta ahora vive en una sentencia: se escribe, se ejecuta y se olvida. Lo siguiente es guardar lógica, no solo consultas. En la lección siguiente, procedimientos almacenados, verás código que vive dentro de la base de datos: la diferencia real entre una función y un procedimiento en PostgreSQL, lo justo de PL/pgSQL para escribir algo útil —variables, IF, bucles, RAISE, manejo de excepciones—, y tres ejemplos en orden de dificultad que acaban en sp_confirmar_pedido, el procedimiento donde por fin vive la lógica transaccional que construiste a mano en 09-03. Y, sobre todo, la discusión honesta sobre qué merece la pena poner ahí dentro y qué no.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
