Curso SQL · Northwind
Tema 33 de 64
Bloque 7 · Funciones de ventana PostgreSQL 17 northwind

Qué es una función de ventana

Requierelateral

1. Qué es

Una función de ventana calcula un agregado sin colapsar las filas.

Ese es el tema entero, y se entiende mejor con el error que lo motiva. Quieres el precio de cada producto y al lado la media de la empresa, para ver quién se sale de la norma:

SELECT product_name, unit_price, avg(unit_price) FROM products;
ERROR:  column "products.product_name" must appear in the GROUP BY clause
        or be used in an aggregate function

El error dice la verdad: no puedes tener las dos cosas. avg() es una función de agregación, y una agregación reduce muchas filas a una. En cuanto la pides, PostgreSQL exige que todo lo demás también se reduzca. Si añades GROUP BY product_name obtienes 77 medias de un solo producto cada una, que es un número inútil.

El problema real es que quieres dos granos a la vez: el detalle (un producto) y el resumen (la empresa entera). GROUP BY solo sabe darte uno.

-- GROUP BY: 8 filas. El detalle desaparece.
SELECT category_id, avg(unit_price) FROM products GROUP BY category_id;   -- 8 filas

-- OVER (): 77 filas. El detalle se conserva y el agregado viaja al lado.
SELECT product_name, unit_price, avg(unit_price) OVER () FROM products;   -- 77 filas

OVER es la palabra que convierte una función de agregación en una función de ventana. Misma avg(), comportamiento distinto:

avg(unit_price) avg(unit_price) OVER ()
Filas de salida una por grupo las mismas que entraron
El detalle se pierde se conserva
Necesita GROUP BY sí no

2. Sintaxis

funcion(argumentos) OVER (
    PARTITION BY ...      -- opcional: en qué grupos se divide
    ORDER BY ...          -- opcional: en qué orden se recorre
    ROWS/RANGE ...        -- opcional: qué trozo de la partición entra
)

Los tres son opcionales. OVER () vacío significa "la ventana es toda la tabla", y con eso ya se resuelve la mitad de los casos reales.

Las otras tres piezas son los tres temas siguientes: partition-by-y-order-by para las dos primeras, el-marco-rows-vs-range para la tercera.

La palabra "ventana" es literal: para cada fila, la función mira por una ventana a un conjunto de filas y calcula algo sobre ellas. Lo que cambia de una función a otra es por dónde mira. Con OVER () mira a todas.

3. Ejemplos sobre Northwind

3.1 El precio contra la media de la empresa

SELECT product_name, unit_price,
       round(avg(unit_price) OVER (), 2)                AS media_global,
       round(unit_price - avg(unit_price) OVER (), 2)   AS diferencia
FROM products ORDER BY unit_price DESC LIMIT 5;
      product_name       | unit_price | media_global | diferencia
-------------------------+------------+--------------+------------
 Côte de Blaye           |     263.50 |        28.83 |     234.67
 Thüringer Rostbratwurst |     123.79 |        28.83 |      94.96
 Mishi Kobe Niku         |      97.00 |        28.83 |      68.17
 Sir Rodney's Marmalade  |      81.00 |        28.83 |      52.17
 Carnarvon Tigers        |      62.50 |        28.83 |      33.67

La columna media_global repite 28,83 en las 77 filas. Eso no es un defecto: es exactamente lo que se pedía. El valor es el mismo porque la ventana es la misma para todas — la tabla entera.

Y con la media al lado, la comparación es una resta. Côte de Blaye está 234,67 por encima de la media: más de nueve veces el precio medio de la casa. Ese producto lleva apareciendo desde el bloque 4 como el valor atípico de Northwind, y esta es la primera consulta que lo dice en una sola línea sin subconsultas.

3.2 El peso de cada parte sobre el total

Aquí está el uso que justifica el tema en cualquier informe: el porcentaje sobre el total.

SELECT c.category_name,
       round(sum(od.unit_price*od.quantity*(1-od.discount)), 2) AS facturacion,
       round(100.0 * sum(od.unit_price*od.quantity*(1-od.discount))
             / sum(sum(od.unit_price*od.quantity*(1-od.discount))) OVER (), 1) AS pct
FROM order_details od
JOIN products p   ON p.product_id = od.product_id
JOIN categories c ON c.category_id = p.category_id
GROUP BY c.category_name ORDER BY facturacion DESC;
 category_name  | facturacion | pct
----------------+-------------+------
 Beverages      |   267868.18 | 21.2
 Dairy Products |   234507.29 | 18.5
 Confections    |   167357.23 | 13.2
 Meat/Poultry   |   163022.36 | 12.9
 Seafood        |   131261.74 | 10.4
 Condiments     |   106047.09 |  8.4
 Produce        |    99984.58 |  7.9
 Grains/Cereals |    95744.59 |  7.6

Fíjate en sum(sum(...)) OVER (). No es una errata. Es lo que más choca del tema y conviene entenderlo ahora:

  1. El sum() de dentro es la agregación normal del GROUP BY: la facturación de esa categoría.
  2. El sum(...) OVER () de fuera es la ventana, y opera sobre el resultado del GROUP BY, no sobre las filas originales. Suma las 8 facturaciones ya calculadas.

Las funciones de ventana se ejecutan después del GROUP BY. Por eso pueden agregar un agregado. Esto encaja con el orden lógico de orden-logico-de-ejecucion, que ahora se amplía:

FROM → WHERE → GROUP BY → HAVING → VENTANAS → SELECT → DISTINCT → ORDER BY → LIMIT

Sin ventanas, ese porcentaje necesitaría calcular el total en una subconsulta aparte y volver a recorrer las tablas. Con OVER () sale del mismo cálculo.

Lo que dice el resultado: ninguna categoría pasa del 21,2%. Las ocho reparten el negocio de forma bastante plana — no hay una categoría que sea el negocio. Es lo contrario de lo que pasaba con productos y con clientes, donde siempre había un dominante.

4. Dónde encaja en el orden de ejecución

Aquí la tabla de orden-logico-de-ejecucion crece. Las funciones de ventana no aparecían en ella porque tienen un paso propio, y va en un sitio muy concreto:

FROM → WHERE → GROUP BY → HAVING → [ventanas] → SELECT → ORDER BY

Se evalúan después de agrupar y filtrar grupos, y antes de que el SELECT termine de construir la salida. Todo el comportamiento del tema sale de ahí.

Consecuencia inmediata: solo se pueden usar en SELECT y en ORDER BY. En ningún otro sitio:

SELECT product_name, unit_price FROM products
WHERE unit_price > avg(unit_price) OVER ();
ERROR:  window functions are not allowed in WHERE

No es un capricho, se lee directamente del orden de arriba: el WHERE es el paso 2 y las ventanas van después del paso 4. Cuando el WHERE decide, la ventana todavía no existe. Lo mismo con GROUP BY y HAVING, que también corren antes.

El arreglo es siempre el mismo: calcular la ventana en un CTE y filtrar fuera.

WITH t AS (
    SELECT product_name, unit_price, avg(unit_price) OVER () AS media
    FROM products
)
SELECT count(*) FROM t WHERE unit_price > media;   -- 25

25 productos de 77 están por encima de la media de precio: el 32,5%. Que sea menos de la mitad es la firma de una distribución con cola: unos pocos productos caros tiran de la media hacia arriba y dejan a la mayoría por debajo. La media de 28,83 no describe al producto típico.

Este patrón —ventana en un CTE, filtro fuera— es el que resuelve el 90% de los casos donde uno querría poner una ventana en el WHERE. Vale la pena memorizarlo ahora.

4.1 La ventana ve lo que sobrevive al WHERE

SELECT round(avg(unit_price) OVER (), 2) FROM products LIMIT 1;
-- 28.83

SELECT round(avg(unit_price) OVER (), 2) FROM products WHERE NOT discontinued LIMIT 1;
-- 26.94

La ventana no es "toda la tabla": es "todas las filas que llegaron hasta aquí". El WHERE se aplica antes, así que la media cambia. Si el informe dice "media de la empresa", hay que tener claro de qué empresa: con productos descatalogados sale 28,83, sin ellos 26,94.

5. Errores comunes

  1. Esperar que OVER () agrupe. No agrupa nada. Devuelve el mismo número repetido en todas las filas. Si querías un resumen por categoría, lo que falta es PARTITION BY, no OVER ().

  2. Poner la ventana en el WHERE o el HAVING. El error es explícito. La solución es el CTE.

  3. Olvidar OVER y no entender el mensaje. column ... must appear in the GROUP BY clause es el mismo error que sale al escribir mal un GROUP BY. Cuando aparezca, la pregunta es: ¿quiero colapsar las filas o quiero el agregado al lado? Si es lo segundo, falta OVER.

  4. Creer que OVER () y GROUP BY dan lo mismo si envuelves. SELECT avg(unit_price) OVER () FROM products devuelve 77 filas idénticas, no una. Para una sola fila hace falta DISTINCT o un LIMIT 1.

  5. Suponer que cuesta más que un GROUP BY. Suele ser al revés: OVER () recorre los datos una vez. La alternativa sin ventanas —subconsulta con el total y JOIN de vuelta— los recorre dos.

6. Diferencias con SQL Server

PostgreSQL SQL Server
Sintaxis básica OVER () igual igual
Desde qué versión 8.4 (2009) 2005
FILTER (WHERE ...) sobre ventanas sí no — se hace con CASE dentro
Cláusula WINDOW con nombre sí sí (2022+)

Es de lo que mejor porta entre los dos motores. El estándar SQL:2003 definió las funciones de ventana y ambos lo siguieron de cerca. Casi todo lo de este bloque se copia y pega sin tocar nada.

La excepción práctica es FILTER, que en PostgreSQL se combina con OVER —count(*) FILTER (WHERE x) OVER (...)— y en SQL Server hay que escribirlo como sum(CASE WHEN x THEN 1 ELSE 0 END) OVER (...).

7. Ejercicios

  1. Cada producto con su precio, la media global y una columna que diga caro o barato según el lado en que caiga.
  2. Cada pedido con su importe total y el importe medio de todos los pedidos, y la diferencia entre ambos.
  3. Cuántos pedidos superan el importe medio, y qué porcentaje son del total.
  4. Cada categoría con su número de productos y qué porcentaje representa sobre los 77.
  5. ¿Por qué esto no devuelve una fila? SELECT avg(unit_price) OVER () FROM products. ¿Cómo se consigue una sola?

8. Pregunta de negocio

Dirección quiere la aportación de cada comercial: pedidos, facturación, qué porcentaje del negocio representa y cuánto se desvía del comercial medio. Todo en una tabla.

Las dos últimas columnas necesitan el total y la media de la propia tabla que estás construyendo.

? RespuestaRespóndela antes de abrir
SELECT e.first_name||' '||e.last_name AS empleado,
       count(DISTINCT o.order_id) AS pedidos,
       round(sum(od.unit_price*od.quantity*(1-od.discount)), 2) AS facturacion,
       round(100.0 * sum(od.unit_price*od.quantity*(1-od.discount))
             / sum(sum(od.unit_price*od.quantity*(1-od.discount))) OVER (), 1) AS pct_total,
       round(sum(od.unit_price*od.quantity*(1-od.discount))
             - avg(sum(od.unit_price*od.quantity*(1-od.discount))) OVER (), 2) AS vs_media
FROM employees e
JOIN orders o        ON o.employee_id = e.employee_id
JOIN order_details od ON od.order_id = o.order_id
GROUP BY e.employee_id, e.first_name, e.last_name
ORDER BY facturacion DESC;
     empleado     | pedidos | facturacion | pct_total | vs_media
------------------+---------+-------------+-----------+-----------
 Margaret Peacock |     156 |   232890.85 |      18.4 |  92247.17
 Janet Leverling  |     127 |   202812.84 |      16.0 |  62169.17
 Nancy Davolio    |     123 |   192107.60 |      15.2 |  51463.93
 Andrew Fuller    |      96 |   166537.76 |      13.2 |  25894.08
 Laura Callahan   |     104 |   126862.28 |      10.0 | -13781.39
 Robert King      |      72 |   124568.24 |       9.8 | -16075.44
 Anne Dodsworth   |      43 |    77308.07 |       6.1 | -63335.60
 Michael Suyama   |      67 |    73913.13 |       5.8 | -66730.54
 Steven Buchanan  |      42 |    68792.28 |       5.4 | -71851.39

Tres cosas que hacen esta tabla, y una advertencia:

  1. sum(sum(...)) OVER () y avg(sum(...)) OVER () en la misma consulta. El total de la empresa y la media por comercial salen del mismo GROUP BY sin recorrer nada dos veces. Sin ventanas harían falta dos subconsultas y dos pasadas más.
  2. pct_total suma 100 y vs_media suma 0, por definición: son el mismo dato visto como proporción y como desviación. Es la comprobación de que la consulta está bien.

Ojo con hacerla sobre los valores ya redondeados: sumados dan 99,9 y −0,01, no 100 y 0. El redondeo se aplica a cada fila y los restos no se compensan. Si el cuadre importa —y en un informe a Dirección importa— se comprueba antes de redondear, o se acepta y se documenta la diferencia. Es el mismo problema de precisión de tipos-de-datos-en-postgresql, ahora por redondeo de presentación en vez de por el tipo. 3. Los cuatro primeros están por encima de la media y los cinco últimos por debajo, y entre Fuller (+25 894) y Callahan (−13 782) hay un escalón limpio. La plantilla se parte en dos mitades de cuatro y cinco.

Y la advertencia que hay que entregar con la tabla: no es un ranking de rendimiento. Buchanan aparece el último con un 5,4%, pero en cte-recursivas vimos que su rama mueve 344 581,71 — el segundo mayor de la empresa. Es Sales Manager: su trabajo es que vendan Suyama, King y Dodsworth, no vender él.

Una tabla ordenada por facturación individual, entregada sin contexto, señala como peor comercial a quien gestiona el segundo mayor bloque de negocio. El porcentaje es correcto; la lectura, no. Si la tabla va a Dirección, la columna de rama va al lado.

✓ SolucionesResuélvelos antes de abrir

1.

SELECT product_name, unit_price,
       round(avg(unit_price) OVER (), 2) AS media,
       CASE WHEN unit_price > avg(unit_price) OVER () THEN 'caro' ELSE 'barato' END AS lado
FROM products ORDER BY unit_price DESC LIMIT 5;
      product_name       | unit_price | media | lado
-------------------------+------------+-------+------
 Côte de Blaye           |     263.50 | 28.83 | caro
 Thüringer Rostbratwurst |     123.79 | 28.83 | caro
 Mishi Kobe Niku         |      97.00 | 28.83 | caro
 Sir Rodney's Marmalade  |      81.00 | 28.83 | caro
 Carnarvon Tigers        |      62.50 | 28.83 | caro

Una función de ventana se puede usar dentro de un CASE, porque el CASE vive en el SELECT y ahí las ventanas ya están calculadas. Lo que no se puede es al revés: meter la comparación en el WHERE.

2.

WITH t AS (
    SELECT o.order_id, sum(od.unit_price*od.quantity*(1-od.discount)) AS total
    FROM orders o JOIN order_details od ON od.order_id = o.order_id
    GROUP BY o.order_id
)
SELECT order_id, round(total, 2) AS total,
       round(avg(total) OVER (), 2)         AS pedido_medio,
       round(total - avg(total) OVER (), 2) AS diferencia
FROM t ORDER BY total DESC LIMIT 5;
 order_id |  total   | pedido_medio | diferencia
----------+----------+--------------+------------
    10865 | 16387.50 |      1525.05 |   14862.45
    10981 | 15810.00 |      1525.05 |   14284.95
    11030 | 12615.05 |      1525.05 |   11090.00
    10889 | 11380.00 |      1525.05 |    9854.95
    10417 | 11188.40 |      1525.05 |    9663.35

Hace falta el CTE, porque el importe de un pedido ya es un agregado y hay que calcularlo antes de poder promediarlo. Los 1525,05 son el mismo ticket medio de funciones-de-agregacion, donde salió por otro camino.

El pedido mayor es casi 11 veces el pedido medio. Otra distribución con cola.

3.

WITH t AS (
    SELECT o.order_id, sum(od.unit_price*od.quantity*(1-od.discount)) AS total
    FROM orders o JOIN order_details od ON od.order_id = o.order_id
    GROUP BY o.order_id
),
m AS (SELECT order_id, total, avg(total) OVER () AS media FROM t)
SELECT count(*) FILTER (WHERE total > media) AS por_encima,
       count(*)                              AS total_pedidos,
       round(100.0 * count(*) FILTER (WHERE total > media) / count(*), 1) AS pct
FROM m;
 por_encima | total_pedidos | pct
------------+---------------+------
        282 |           830 | 34.0

Solo el 34% de los pedidos supera el pedido medio. Dos tercios están por debajo. Es la misma lección que con los precios: cuando la media deja a dos tercios de la muestra por debajo, la media no es el valor típico. La mediana lo sería, y llega en distribucion-y-percentiles.

Dos CTE encadenados y no uno: t calcula el total por pedido, m añade la ventana. No caben juntos porque el FILTER del SELECT final necesita media como columna ya existente.

4.

SELECT c.category_name, count(*) AS productos,
       round(100.0 * count(*) / sum(count(*)) OVER (), 1) AS pct
FROM products p JOIN categories c USING (category_id)
GROUP BY c.category_name ORDER BY productos DESC;
 category_name  | productos | pct
----------------+-----------+------
 Confections    |        13 | 16.9
 Condiments     |        12 | 15.6
 Beverages      |        12 | 15.6
 Seafood        |        12 | 15.6
 Dairy Products |        10 | 13.0
 Grains/Cereals |         7 |  9.1
 Meat/Poultry   |         6 |  7.8
 Produce        |         5 |  6.5

Mismo patrón que el ejemplo de la sección 3: sum(count(*)) OVER () suma las cuentas de todos los grupos, que es 77. El count(*) de dentro es del GROUP BY; el sum(...) OVER () es la ventana sobre los grupos ya formados.

Y compáralo con el reparto de facturación de la sección 3: Confections tiene 13 productos (16,9%) y factura el 13,2%; Meat/Poultry tiene 6 (7,8%) y factura el 12,9%. Cuántos productos tiene una categoría no dice cuánto vende.

5. Devuelve 77 filas idénticas porque OVER () no colapsa: pone el mismo 28,83 al lado de cada producto. Para una sola fila:

SELECT DISTINCT avg(unit_price) OVER () FROM products;   -- 1 fila
SELECT avg(unit_price) FROM products;                    -- 1 fila, sin ventana

La segunda es la correcta. Si el resultado que quieres es una sola fila de resumen, no necesitas una ventana: necesitas una agregación normal. La ventana se usa cuando quieres conservar el detalle.