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

  1. La idea central: agregar sin colapsar
  2. La anatomía de OVER
  3. Dónde se ejecuta: el error del WHERE
  4. Ranking: ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK
  5. Desplazamiento: LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE
  6. Agregados como ventana, y el marco: ROWS frente a RANGE
  7. WINDOW: dar nombre a una ventana
  8. Los casos de TiendaVerde
  9. Rendimiento y dialecto
  10. Errores Comunes y Consejos
  11. Ejercicios
  12. Conclusión

  1. 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

  1. La anatomía de OVER

La 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.

  1. Dónde se ejecuta: el error del WHERE

Las funciones de ventana se evalúan casi al final: FROMWHEREGROUP BYHAVINGfunciones de ventanaSELECTDISTINCTORDER BYLIMIT. 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.

  1. Ranking: 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 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.

  1. Desplazamiento: LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE

Estas 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.

  1. 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ísicas2 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.

  1. WINDOW: dar nombre a una ventana

Cuando 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.

  1. 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) : 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.

  1. 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 RANGE con n PRECEDING (solo ROWS) y hasta 2022 no tuvo IGNORE NULLS en LAG/LEAD; MySQL 8 no tiene la cláusula FILTER; Oracle las llama analytic functions y añade KEEP (DENSE_RANK FIRST/LAST); y GROUPS como tercera modalidad de marco (además de ROWS y RANGE), junto con EXCLUDE, existe en PostgreSQL 11+ y falta en la mayoría.

Errores Comunes y Consejos

  • Filtrar por una función de ventana en el WHERE o el HAVING. 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_VALUE devuelva el último valor. Con el marco por omisión devuelve la fila actual. Escribe ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING. Y al revés: añadir ORDER BY a un SUM() OVER sin querer un acumulado lo convierte en acumulado; si quieres el total del grupo, no pongas ORDER BY.
  • Usar ROW_NUMBER sin desempate. Con valores repetidos, qué fila recibe el 1 es arbitrario y puede cambiar entre ejecuciones. Añade siempre una columna única al ORDER BY de la ventana.
  • Confundir RANK con DENSE_RANK —el primero deja huecos tras un empate (1, 1, 3) y el segundo no (1, 1, 2)— o creer que RANGE y ROWS son sinónimos: solo lo son sin empates; con ellos, RANGE los 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, luego PARTITION BY, luego ORDER BY, y solo al final el marco. Ejecuta después de cada paso y mira cómo cambia la columna.
  • Consejo: ROWS por omisión, que no tiene sorpresas con los empates; RANGE solo cuando el comportamiento por pares sea deliberado. Y nombra la ventana con WINDOW en cuanto se repita dos veces: evitas cambiar el ORDER BY en 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 es OVER (PARTITION BY ... ORDER BY ... marco), con las tres partes opcionales, y añadir ORDER BY cambia 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 BY y HAVING, y por eso no se puede filtrar por ellas en el WHERE (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_NUMBER numera sin repetir (1, 2, 3), RANK empata y deja hueco (1, 1, 3), DENSE_RANK empata sin hueco (1, 1, 2) — demostrado con el empate real del arroz y el tomate a 14 unidades. Más NTILE para cuartiles y PERCENT_RANK para posiciones relativas. Desplazamiento: LAG y LEAD con sus argumentos (columna, n, por_defecto), y FIRST_VALUE / LAST_VALUE / NTH_VALUE, que exigen marco explícito.
  • El marco: ROWS cuenta filas físicas, RANGE agrupa las filas con el mismo valor de ordenación (28 en lugar de 14 en el empate del arroz). El marco por omisión con ORDER BY es RANGE UNBOUNDED PRECEDING AND CURRENT ROW —de ahí que LAST_VALUE devuelva la fila actual y no la última—; sin ORDER 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 con ROW_NUMBER (9 filas) y la tabla que lo enfrenta a DISTINCT ON y a LATERAL; 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

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados