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

PARTITION BY y ORDER BY

1. Qué es

Las dos cláusulas que dan forma a la ventana. Cada una responde a una pregunta:

  • PARTITION BY — ¿en qué grupos se divide la tabla? Cada grupo es una ventana independiente.
  • ORDER BY — ¿en qué orden se recorre cada ventana? Y con eso, qué filas ha visto ya la función cuando llega a la fila actual.
funcion() OVER (PARTITION BY columna ORDER BY otra)

La segunda es la que sorprende. ORDER BY dentro de OVER no ordena el resultado: cambia lo que la función calcula. Es la diferencia entre un total y un acumulado, y es el 90% de la confusión con este bloque.

PARTITION BY no es GROUP BY. Se parecen y hacen cosas opuestas:

GROUP BY PARTITION BY
Filas de salida una por grupo todas
Alcance toda la consulta solo esa función
Varios criterios a la vez no sí, una ventana distinta por columna

Ese último punto es el que más se aprovecha: en la misma consulta puedes tener la media global, la media de la categoría y la media del país, cada una en su columna.

2. PARTITION BY: partir la tabla

SELECT c.category_name, p.product_name, p.unit_price,
       round(avg(p.unit_price) OVER (PARTITION BY p.category_id), 2)  AS media_categoria,
       round(p.unit_price - avg(p.unit_price) OVER (PARTITION BY p.category_id), 2) AS vs_categoria
FROM products p JOIN categories c USING (category_id)
WHERE c.category_name IN ('Beverages','Produce')
ORDER BY c.category_name, p.unit_price DESC;
 category_name |          product_name           | unit_price | media_categoria | vs_categoria
---------------+---------------------------------+------------+-----------------+--------------
 Beverages     | Côte de Blaye                   |     263.50 |           37.98 |       225.52
 Beverages     | Ipoh Coffee                     |      46.00 |           37.98 |         8.02
 Beverages     | Chang                           |      19.00 |           37.98 |       -18.98
 Beverages     | Chai                            |      18.00 |           37.98 |       -19.98
 Beverages     | Lakkalikööri                    |      18.00 |           37.98 |       -19.98
 ...
 Beverages     | Guaraná Fantástica              |       4.50 |           37.98 |       -33.48
 Produce       | Manjimup Dried Apples           |      53.00 |           32.37 |        20.63
 Produce       | Rössle Sauerkraut               |      45.60 |           32.37 |        13.23
 Produce       | Uncle Bob's Organic Dried Pears |      30.00 |           32.37 |        -2.37
 Produce       | Tofu                            |      23.25 |           32.37 |        -9.12
 Produce       | Longlife Tofu                   |      10.00 |           32.37 |       -22.37

Dos ventanas, dos medias. 37,98 se repite en las 12 filas de Beverages, 32,37 en las 5 de Produce. La función se reinicia en cada partición.

Y mira la columna vs_categoria de Beverages: diez de doce productos están por debajo de su propia media. La media de 37,98 la produce un solo producto, Côte de Blaye a 263,50. Sin él, la media de la categoría baja de 37,98 a 17,48. Una media con un atípico dentro describe al atípico, no al grupo.

2.1 Varias ventanas distintas en la misma consulta

SELECT c.category_name, p.product_name, p.unit_price,
       round(avg(p.unit_price) OVER (), 2)                           AS media_global,
       round(avg(p.unit_price) OVER (PARTITION BY p.category_id), 2) AS media_cat
FROM products p JOIN categories c USING (category_id)
WHERE p.product_name IN ('Côte de Blaye','Manjimup Dried Apples','Longlife Tofu',
                         'Chai','Konbu','Thüringer Rostbratwurst')
ORDER BY p.unit_price DESC;
 category_name |      product_name       | unit_price | media_global | media_cat
---------------+-------------------------+------------+--------------+-----------
 Beverages     | Côte de Blaye           |     263.50 |        79.05 |    140.75
 Meat/Poultry  | Thüringer Rostbratwurst |     123.79 |        79.05 |    123.79
 Produce       | Manjimup Dried Apples   |      53.00 |        79.05 |     31.50
 Beverages     | Chai                    |      18.00 |        79.05 |    140.75
 Produce       | Longlife Tofu           |      10.00 |        79.05 |     31.50
 Seafood       | Konbu                   |       6.00 |        79.05 |      6.00

Dos OVER distintos, dos ventanas independientes, una sola pasada. Con GROUP BY esto necesitaría dos consultas y un JOIN.

Y aquí hay una trampa que hay que ver ahora: las medias no son las de la tabla completa. media_global sale 79,05 y no 28,83, porque el WHERE filtró a 6 productos antes de que la ventana existiera. media_cat de Meat/Poultry sale 123,79 porque en la selección solo sobrevivió un producto de esa categoría — es su propia media.

La ventana opera sobre lo que sobrevive al WHERE, no sobre la tabla. Es la misma regla del tema anterior, y con PARTITION BY engaña más porque el número parece plausible.

2.2 Particionar por varias columnas

SELECT o.ship_country, extract(year FROM o.order_date)::int AS anio,
       count(*) AS pedidos,
       sum(count(*)) OVER (PARTITION BY o.ship_country) AS pedidos_pais
FROM orders o WHERE o.ship_country IN ('Brazil','UK')
GROUP BY o.ship_country, extract(year FROM o.order_date) ORDER BY 1, 2;
 ship_country | anio | pedidos | pedidos_pais
--------------+------+---------+--------------
 Brazil       | 1996 |      13 |           83
 Brazil       | 1997 |      42 |           83
 Brazil       | 1998 |      28 |           83
 UK           | 1996 |      10 |           56
 UK           | 1997 |      30 |           56
 UK           | 1998 |      16 |           56

El GROUP BY agrupa por país y año; la ventana particiona solo por país. Así conviven el detalle anual y el total del país en la misma fila. Es el patrón de "detalle y su subtotal al lado", y sin ventanas exige una subconsulta.

3. ORDER BY: recorrer la ventana

Aquí está el giro del tema. Añadir ORDER BY dentro de OVER cambia el resultado de la función.

SELECT p.product_name, p.unit_price,
       sum(p.unit_price) OVER ()                      AS sin_order,
       sum(p.unit_price) OVER (ORDER BY p.unit_price) AS con_order
FROM products p WHERE p.category_id = 7 ORDER BY p.unit_price;
          product_name           | unit_price | sin_order | con_order
---------------------------------+------------+-----------+-----------
 Longlife Tofu                   |      10.00 |    161.85 |     10.00
 Tofu                            |      23.25 |    161.85 |     33.25
 Uncle Bob's Organic Dried Pears |      30.00 |    161.85 |     63.25
 Rössle Sauerkraut               |      45.60 |    161.85 |    108.85
 Manjimup Dried Apples           |      53.00 |    161.85 |    161.85

Sin ORDER BY: el total, repetido. Con ORDER BY: el acumulado. La última fila coincide, porque al llegar al final ya ha sumado todo.

El motivo: sin ORDER BY, la ventana de cada fila es la partición entera. Con ORDER BY, la ventana por defecto pasa a ser "desde el principio hasta la fila actual". La función ya no ve el futuro.

Eso convierte cualquier agregación en su versión acumulada: sum en running total, count en cuántas van, max en el máximo hasta ahora.

3.1 El acumulado que se usa de verdad

SELECT date_trunc('month', o.order_date)::date AS mes,
       round(sum(od.unit_price*od.quantity*(1-od.discount)), 2) AS mes_facturacion,
       round(sum(sum(od.unit_price*od.quantity*(1-od.discount)))
             OVER (ORDER BY date_trunc('month', o.order_date)), 2) AS acumulado
FROM orders o JOIN order_details od ON od.order_id = o.order_id
WHERE o.order_date >= '1997-01-01' AND o.order_date < '1997-07-01'
GROUP BY date_trunc('month', o.order_date) ORDER BY 1;
    mes     | mes_facturacion | acumulado
------------+-----------------+-----------
 1997-01-01 |        61258.07 |  61258.07
 1997-02-01 |        38483.64 |  99741.71
 1997-03-01 |        38547.22 | 138288.93
 1997-04-01 |        53032.95 | 191321.88
 1997-05-01 |        53781.29 | 245103.17
 1997-06-01 |        36362.80 | 281465.97

Es el gráfico de "objetivo acumulado del año" de cualquier informe comercial, en tres líneas.

Comprobación: los 138 288,93 de marzo son exactamente la facturación de 1997-Q1 que salió en cte-with. El acumulado de un trimestre tiene que dar el trimestre.

4. El gotcha que hay que ver antes de seguir

Repite el acumulado sobre una categoría con precios repetidos:

SELECT p.product_name, p.unit_price,
       sum(p.unit_price) OVER (ORDER BY p.unit_price) AS acum
FROM products p WHERE p.category_id = 1 ORDER BY p.unit_price;
       product_name        | unit_price |  acum
---------------------------+------------+--------
 Guaraná Fantástica        |       4.50 |   4.50
 Rhönbräu Klosterbier      |       7.75 |  12.25
 Laughing Lumberjack Lager |      14.00 |  40.25
 Sasquatch Ale             |      14.00 |  40.25
 Outback Lager             |      15.00 |  55.25
 Lakkalikööri              |      18.00 | 127.25
 Steeleye Stout            |      18.00 | 127.25
 Chartreuse verte          |      18.00 | 127.25
 Chai                      |      18.00 | 127.25
 Chang                     |      19.00 | 146.25
 Ipoh Coffee               |      46.00 | 192.25
 Côte de Blaye             |     263.50 | 455.75

Los cuatro productos de 18,00 muestran el mismo acumulado: 127,25. No es 73,25 / 91,25 / 109,25 / 127,25 como esperarías de un acumulado fila a fila. Los dos de 14,00 hacen lo mismo: los dos marcan 40,25.

Es correcto, y es la causa número uno de "mi acumulado sale mal". Con ORDER BY y sin más, la ventana por defecto es RANGE, que trabaja por valor y no por fila: todas las filas empatadas comparten ventana, así que la función salta el bloque entero de golpe.

Si quieres el acumulado fila a fila hay que decirlo:

sum(p.unit_price) OVER (ORDER BY p.unit_price ROWS UNBOUNDED PRECEDING)

Ese ROWS es el tema siguiente, el-marco-rows-vs-range, y esta consulta es el motivo de que exista. Por ahora basta con la regla: si el ORDER BY de la ventana tiene empates, RANGE los agrupa. Con una fecha única o un order_id no se nota nunca; con un precio o una categoría, sí.

5. Errores comunes

  1. Creer que el ORDER BY de dentro ordena la salida. No. Ordena el recorrido de la ventana. Para ordenar el resultado hace falta el ORDER BY final de la consulta, y son independientes: puedes acumular por fecha y presentar por importe.

  2. Ponerlo dentro y esperar el total. Si querías el total y sale un acumulado, sobra el ORDER BY.

  3. Usar GROUP BY <posición> con una ventana que ordena por la misma expresión. sql ... OVER (ORDER BY date_trunc('month', o.order_date)) ... GROUP BY 1 ERROR: column "o.order_date" must appear in the GROUP BY clause GROUP BY 1 funciona en el SELECT pero no vale como referencia para el ORDER BY de una ventana. PostgreSQL no reconoce que la expresión de la ventana es la misma que la agrupada. Se arregla escribiendo el GROUP BY con la expresión completa: GROUP BY date_trunc('month', o.order_date).

  4. Suponer que PARTITION BY reduce filas. No reduce nada. Si el resultado tiene más filas de las esperadas, el problema está en los JOIN, no en la ventana.

  5. Olvidar que el WHERE va antes. La media de la categoría se calcula sobre los productos que pasaron el filtro. Si el informe dice "media de la categoría", tiene que ser la de la categoría completa: entonces la ventana va en un CTE antes de filtrar.

  6. Repetir la misma definición de ventana en cinco columnas. Funciona, pero se puede nombrar una vez: sql SELECT product_name, avg(unit_price) OVER w, max(unit_price) OVER w, min(unit_price) OVER w FROM products WINDOW w AS (PARTITION BY category_id); La cláusula WINDOW va después del HAVING y antes del ORDER BY. Con tres o más columnas compartiendo ventana, merece la pena.

6. Diferencias con SQL Server

PostgreSQL SQL Server
PARTITION BY / ORDER BY igual igual
Cláusula WINDOW con nombre desde 8.4 desde 2022
RANGE por defecto con ORDER BY sí sí
Agregadas con ORDER BY sin marco sí desde 2012

Porta sin cambios, salvo la cláusula WINDOW si el servidor destino es anterior a SQL Server 2022: hay que repetir la definición en cada columna.

7. Ejercicios

  1. Cada producto de Condiments con el precio máximo de su categoría y qué porcentaje representa de ese máximo.
  2. Cada pedido con cuántos pedidos ha hecho ese cliente en total, sin subconsultas.
  3. Facturación por país y año, con el total del país al lado y qué porcentaje aporta cada año dentro de su país. Para Germany y Brazil.
  4. El acumulado de facturación mensual de 1998.
  5. Explica por qué las cuatro cervezas de 18,00 comparten acumulado, y escribe la versión que acumula fila a fila.

8. Pregunta de negocio

Marketing quiere saber si el catálogo está equilibrado: dentro de cada categoría, qué porcentaje de la facturación aporta cada producto.

La pregunta real que hay detrás es cuánto depende cada categoría de un solo producto.

? RespuestaRespóndela antes de abrir
SELECT c.category_name, p.product_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 (PARTITION BY c.category_id), 1) AS pct_cat
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_id, c.category_name, p.product_id, p.product_name
ORDER BY c.category_name, pct_cat DESC;

Son 77 filas. El resumen —el líder de cada categoría y cuánto pesa— es lo que se entrega:

 category_name  |          líder          | pct_líder | productos
----------------+-------------------------+-----------+-----------
 Beverages      | Côte de Blaye           |      52.8 |        12
 Meat/Poultry   | Thüringer Rostbratwurst |      49.3 |         6
 Grains/Cereals | Gnocchi di nonna Alice  |      44.5 |         7
 Produce        | Manjimup Dried Apples   |      41.8 |         5
 Dairy Products | Raclette Courdavault    |      30.3 |        10
 Confections    | Tarte au sucre          |      28.2 |        13
 Seafood        | Carnarvon Tigers        |      22.2 |        12
 Condiments     | Vegie-spread            |      15.7 |        12

La respuesta es que el catálogo no está equilibrado, y se parte en dos mitades muy distintas:

  1. Cuatro categorías dependen de un producto. Côte de Blaye es el 52,8% de Beverages él solo: si ese producto desaparece, media categoría desaparece con él. Meat/Poultry (49,3%) y Grains/Cereals (44,5%) están casi igual.
  2. Cuatro están repartidas. En Condiments el líder es el 15,7% y los doce productos van del 15,7% al 1,7% en una curva suave. Perder cualquiera de ellos es asumible.
  3. El número de productos no explica la concentración. Beverages y Condiments tienen doce productos cada una y una está al 52,8% y la otra al 15,7%. Un catálogo amplio no protege de nada si uno de los productos se lleva la mitad.

Lo que hay que decir al entregarlo: la pregunta era sobre el catálogo y la respuesta útil es sobre riesgo. Las cuatro categorías concentradas son las que hay que mirar en una negociación con proveedores o ante una rotura de stock — y Côte de Blaye, que lleva todo el curso apareciendo como una curiosidad estadística, resulta ser el punto único de fallo más grande de la empresa.

Y el matiz que cambia la conclusión. Esto mide facturación, no unidades. La misma consulta con sum(od.quantity) sobre Beverages da otra lista:

     product_name     | unidades
----------------------+----------
 Rhönbräu Klosterbier |     1155
 Guaraná Fantástica   |     1125
 Chang                |     1057
 Lakkalikööri         |      981

Côte de Blaye no aparece. Domina el 52,8% de los euros de su categoría sin estar entre los cuatro primeros en unidades: manda porque cuesta 263,50, no porque se venda mucho. Los dos productos que más unidades mueven son justo los dos más baratos de la categoría, a 7,75 y 4,50.

Son dos riesgos distintos y hay que separarlos: perder Côte de Blaye es perder facturación; perder Rhönbräu Klosterbier es perder clientes que entran por el producto barato. Un informe de concentración que solo mire euros ve el primero y es ciego al segundo.

✓ SolucionesResuélvelos antes de abrir

1.

SELECT c.category_name, p.product_name, p.unit_price,
       max(p.unit_price) OVER (PARTITION BY p.category_id) AS max_cat,
       round(100.0 * p.unit_price
             / max(p.unit_price) OVER (PARTITION BY p.category_id), 1) AS pct_del_max
FROM products p JOIN categories c USING (category_id)
WHERE c.category_name = 'Condiments' ORDER BY p.unit_price DESC;
 category_name |           product_name           | unit_price | max_cat | pct_del_max
---------------+----------------------------------+------------+---------+-------------
 Condiments    | Vegie-spread                     |      43.90 |   43.90 |       100.0
 Condiments    | Northwoods Cranberry Sauce       |      40.00 |   43.90 |        91.1
 Condiments    | Sirop d'érable                   |      28.50 |   43.90 |        64.9
 Condiments    | Grandma's Boysenberry Spread     |      25.00 |   43.90 |        56.9
 Condiments    | Chef Anton's Cajun Seasoning     |      22.00 |   43.90 |        50.1
 Condiments    | Chef Anton's Gumbo Mix           |      21.35 |   43.90 |        48.6
 Condiments    | Louisiana Fiery Hot Pepper Sauce |      21.05 |   43.90 |        47.9
 Condiments    | Gula Malacca                     |      19.45 |   43.90 |        44.3
 Condiments    | Louisiana Hot Spiced Okra        |      17.00 |   43.90 |        38.7
 Condiments    | Original Frankfurter grüne Soße  |      13.00 |   43.90 |        29.6
 Condiments    | Genen Shouyu                     |      13.00 |   43.90 |        29.6
 Condiments    | Aniseed Syrup                    |      10.00 |   43.90 |        22.8

El más caro marca 100,0 por construcción. Es la señal de que la ventana está bien: si ningún producto marcara 100, el PARTITION BY estaría mal escrito.

Condiments es una categoría sana: del máximo al mínimo hay un factor 4,4, y el segundo está al 91% del primero. Compárala con Beverages, donde el segundo producto está al 17,5% del primero.

2.

SELECT o.order_id, o.customer_id, o.order_date::date,
       count(*) OVER (PARTITION BY o.customer_id) AS pedidos_del_cliente
FROM orders o WHERE o.customer_id IN ('SAVEA','CENTC')
ORDER BY o.customer_id, o.order_date LIMIT 8;
 order_id | customer_id | order_date | pedidos_del_cliente
----------+-------------+------------+---------------------
    10259 | CENTC       | 1996-07-18 |                   1
    10324 | SAVEA       | 1996-10-08 |                  31
    10393 | SAVEA       | 1996-12-25 |                  31
    10398 | SAVEA       | 1996-12-30 |                  31
    ...

count(*) OVER (PARTITION BY ...) es la alternativa directa a la subconsulta correlacionada de subconsultas-correlacionadas, y a la LATERAL de lateral. Mismo resultado, una pasada, sin correlación.

Y confirma lo de lateral: CENTC tiene un solo pedido en toda su vida, de 1996.

3.

SELECT o.ship_country, extract(year FROM o.order_date)::int AS anio,
       round(sum(od.unit_price*od.quantity*(1-od.discount)), 2) AS facturacion,
       round(sum(sum(od.unit_price*od.quantity*(1-od.discount)))
             OVER (PARTITION BY o.ship_country), 2) AS total_pais,
       round(100.0 * sum(od.unit_price*od.quantity*(1-od.discount))
             / sum(sum(od.unit_price*od.quantity*(1-od.discount)))
               OVER (PARTITION BY o.ship_country), 1) AS pct
FROM orders o JOIN order_details od ON od.order_id = o.order_id
WHERE o.ship_country IN ('Germany','Brazil')
GROUP BY o.ship_country, extract(year FROM o.order_date) ORDER BY 1, 2;
 ship_country | anio | facturacion | total_pais | pct
--------------+------+-------------+------------+------
 Brazil       | 1996 |    20148.82 |  106925.78 | 18.8
 Brazil       | 1997 |    41941.19 |  106925.78 | 39.2
 Brazil       | 1998 |    44835.77 |  106925.78 | 41.9
 Germany      | 1996 |    35407.15 |  230284.63 | 15.4
 Germany      | 1997 |   117320.16 |  230284.63 | 50.9
 Germany      | 1998 |    77557.32 |  230284.63 | 33.7

Los porcentajes suman 100 dentro de cada país, no entre países. Esa es la lectura de PARTITION BY: cada partición es un mundo cerrado.

Y no compares 1998 con 1997 sin acordarte de que 1998 son cuatro meses. Brazil crece hasta el 41,9% en un tercio del tiempo, lo que es un crecimiento real y fuerte; Germany baja al 33,7%, que con el año incompleto no significa caída. El mismo año parcial que estropeaba el trimestre en cte-with.

4.

SELECT date_trunc('month', o.order_date)::date AS mes,
       round(sum(od.unit_price*od.quantity*(1-od.discount)), 2) AS facturacion,
       round(sum(sum(od.unit_price*od.quantity*(1-od.discount)))
             OVER (ORDER BY date_trunc('month', o.order_date)), 2) AS acumulado
FROM orders o JOIN order_details od ON od.order_id = o.order_id
WHERE o.order_date >= '1998-01-01'
GROUP BY date_trunc('month', o.order_date) ORDER BY 1;
    mes     | facturacion | acumulado
------------+-------------+-----------
 1998-01-01 |    94222.11 |  94222.11
 1998-02-01 |    99415.29 | 193637.40
 1998-03-01 |   104854.16 | 298491.55
 1998-04-01 |   123798.68 | 422290.24
 1998-05-01 |    18333.63 | 440623.87

Cuatro meses en línea recta y luego un desplome. Mayo son 18 333,63 contra 123 798,68 de abril, pero mayo solo tiene seis días de datos: la base termina el 1998-05-06.

En un gráfico acumulado esto no se ve, porque la curva sigue subiendo; solo se aplana. Por eso el acumulado es la forma honesta de presentar un periodo en curso, y la comparación mes contra mes la peligrosa. Y los 298 491,55 de marzo son el 1998-Q1 de cte-with: cuadra.

5. Comparten acumulado porque el marco por defecto con ORDER BY es RANGE, que agrupa por valor: las cuatro filas de 18,00 son un único punto de la ventana. Fila a fila:

SELECT p.product_name, p.unit_price,
       sum(p.unit_price) OVER (ORDER BY p.unit_price ROWS UNBOUNDED PRECEDING) AS acum
FROM products p WHERE p.category_id = 1 ORDER BY p.unit_price;

Con ROWS el resultado depende del desempate, que sin especificar es arbitrario. Si el orden importa, hay que fijarlo: ORDER BY p.unit_price, p.product_id.