1. Qué es
Las funciones de los cuatro temas anteriores respondían "¿en qué puesto está esta fila?". Las de este tema responden dos preguntas distintas:
- ¿En qué parte de la distribución está esta fila? →
percent_rank()ycume_dist(). Devuelven una posición relativa entre 0 y 1, no un número de puesto. - ¿Qué valor ocupa una posición dada de la distribución? →
percentile_cont()ypercentile_disc(). Es la pregunta inversa: le das el 0,5 y te devuelve la mediana.
Las dos primeras son funciones de ventana normales, con OVER. Las dos últimas no lo son: son agregados de conjunto ordenado, se escriben con WITHIN GROUP y colapsan filas como un SUM.
Por qué este tema existe. En que-es-una-funcion-de-ventana calculamos el precio medio del catálogo: 28,83 €. Y anotamos un problema: solo 25 de 77 productos lo superan. En partition-by-y-order-by volvió a pasar con Beverages: media 37,98 €, con 10 de 12 productos por debajo. La media describía una realidad en la que casi nadie vivía.
Este tema trae la herramienta que faltaba.
2. Sintaxis
Como funciones de ventana (una fila de salida por fila de entrada):
percent_rank() OVER (PARTITION BY ... ORDER BY ...)
cume_dist() OVER (PARTITION BY ... ORDER BY ...)
Ninguna de las dos lleva argumentos en su forma de ventana — hay una segunda forma, con argumento y sin OVER, que es de lo que va la sección 9. Y aunque el ORDER BY dentro de OVER es lo único que hace que signifiquen algo, PostgreSQL no lo exige: sin él no da error, devuelve una columna constante. Lo mismo con la cláusula de marco — la acepta y la ignora. Las dos trampas están en la sección 10.
Como agregados de conjunto ordenado (colapsan filas, como avg):
percentile_cont(fraccion) WITHIN GROUP (ORDER BY columna)
percentile_disc(fraccion) WITHIN GROUP (ORDER BY columna)
mode() WITHIN GROUP (ORDER BY columna)
La fraccion va entre 0 y 1: 0.5 es la mediana, 0.9 el percentil 90. El ORDER BY va dentro del WITHIN GROUP, no fuera, y no es el orden del resultado: es el orden sobre el que se mide la distribución.
Como cualquier agregado, respetan GROUP BY:
SELECT categoria, percentile_cont(0.5) WITHIN GROUP (ORDER BY precio)
FROM productos GROUP BY categoria;
3. La mediana: la deuda del tema 32
WITH pedidos AS (
SELECT o.order_id,
sum(od.unit_price * od.quantity * (1 - od.discount))::numeric(10,2) AS total
FROM orders o
JOIN order_details od ON od.order_id = o.order_id
GROUP BY o.order_id
)
SELECT count(*) AS pedidos,
round(avg(total), 2) AS media,
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY total)::numeric, 2) AS mediana,
round(min(total), 2) AS minimo,
round(max(total), 2) AS maximo
FROM pedidos;
pedidos | media | mediana | minimo | maximo
---------+---------+---------+--------+----------
830 | 1525.05 | 943.25 | 12.50 | 16387.50
La media es un 62 % más alta que la mediana. El pedido típico de Northwind es de 943 €, no de 1 525 €.
¿Por qué? Porque la media suma y divide, así que un pedido de 16 387 € pesa lo mismo que diecisiete pedidos de 943 €. La mediana no suma: solo ordena y se planta en el centro. Un pedido de 16 387 € y uno de 160 000 € le hacen exactamente lo mismo: nada.
La misma escena con los precios del catálogo:
SELECT count(*) AS productos,
round(avg(unit_price), 2) AS media,
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY unit_price)::numeric, 2) AS mediana,
mode() WITHIN GROUP (ORDER BY unit_price) AS moda,
min(unit_price) AS minimo, max(unit_price) AS maximo
FROM products;
productos | media | mediana | moda | minimo | maximo
-----------+-------+---------+-------+--------+--------
77 | 28.83 | 19.50 | 14.00 | 2.50 | 263.50
Tres respuestas distintas a "¿cuánto cuesta un producto de Northwind?": 28,83 la media, 19,50 la mediana, 14,00 el valor que más se repite. Ninguna es incorrecta. Solo una es la que usarías en una frase.
⚠️ Ese 14,00 no es de fiar, y merece que lo compruebes. Hay cuatro productos a 14,00 € y también cuatro a 18,00 €: la moda está empatada.
mode()no avisa — cuando hay varios valores igual de frecuentes devuelve uno cualquiera, y no está garantizado que sea siempre el mismo. Con datos que cambian, esa columna puede saltar de un valor a otro sin que nada haya pasado. Antes de poner una moda en un informe, cuenta las frecuencias tú mismo:
sql SELECT unit_price, count(*) FROM products GROUP BY unit_price ORDER BY count(*) DESC, unit_price LIMIT 3; --> 14.00|4, 18.00|4, 10.00|3Fíjate en el desempate del
ORDER BY: sin él, esas dos filas salen en un orden u otro según la ejecución. Es la misma falta de determinismo que vimos conrow_number()en ranking-row-number-rank-ntile, aquí escondida dentro de una función.
3.1 La prueba de que la mediana aguanta y la media no
Côte de Blaye (263,50 €) lleva ocho temas apareciendo. Quítalo y mide otra vez:
SELECT round(avg(unit_price), 2) AS media,
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY unit_price)::numeric, 2) AS mediana
FROM products
WHERE product_id <> 38;
media | mediana
-------+---------
25.75 | 19.48
| Con Côte de Blaye | Sin él | Cambio | |
|---|---|---|---|
| Media | 28,83 | 25,75 | −10,7 % |
| Mediana | 19,50 | 19,48 | −0,1 % |
Un solo producto de 77 movía la media un 10,7 %. A la mediana la movió dos céntimos, y solo porque al pasar de 77 a 76 productos el centro dejó de ser un valor y pasó a ser el promedio de dos.
Esa es la propiedad que define a la mediana: es robusta. No le importa cuánto vale el extremo, solo cuántas filas hay a cada lado.
4. percentile_cont vs percentile_disc
Las dos reciben la misma fracción. La diferencia está en qué hacen cuando el punto exacto cae entre dos filas.
percentile_disc(discreto) devuelve un valor que existe en la tabla. Elige la primera fila cuya proporción acumulada alcanza la fracción pedida.percentile_cont(continuo) interpola entre las dos filas vecinas. El resultado puede ser un número que ningún registro tiene.
Con 77 productos hay un centro exacto —el puesto 39— y las dos coinciden en 19,50. Con 76 ya no:
SELECT round(percentile_cont(0.5) WITHIN GROUP (ORDER BY unit_price)::numeric, 2) AS cont,
percentile_disc(0.5) WITHIN GROUP (ORDER BY unit_price) AS disc,
count(*)
FROM products
WHERE product_id <> 38;
cont | disc | count
-------+-------+-------
19.48 | 19.45 | 76
Los dos productos centrales cuestan 19,45 y 19,50. cont devuelve 19,48, que no es el precio de nada. disc devuelve 19,45, que sí es un producto real.
Cuál usar:
| Situación | Función |
|---|---|
| Métrica estadística (mediana de importes, p90 de latencia) | percentile_cont |
| El resultado tiene que ser un valor real de la tabla | percentile_disc |
| La columna no es numérica (texto, fecha) | percentile_disc — es la única que funciona |
Ese último caso es importante y no es evidente:
SELECT percentile_disc(0.5) WITHIN GROUP (ORDER BY product_name) AS producto_mediano FROM products;
producto_mediano
------------------
Maxilaku
Interpolar entre dos nombres de producto no significa nada, así que percentile_cont ni siquiera acepta texto. percentile_disc sí, porque solo necesita ordenar y contar.
5. percent_rank y cume_dist: la pregunta inversa
Las dos devuelven un número entre 0 y 1, y las dos parecen lo mismo hasta que las pones al lado. Sobre las doce bebidas:
SELECT product_name, unit_price,
row_number() OVER w AS rn,
rank() OVER w AS rnk,
round(percent_rank() OVER w::numeric, 3) AS pct_rank,
round(cume_dist() OVER w::numeric, 3) AS cume_dist,
ntile(4) OVER w AS cuartil
FROM products
WHERE category_id = 1
WINDOW w AS (ORDER BY unit_price)
ORDER BY unit_price;
product_name | unit_price | rn | rnk | pct_rank | cume_dist | cuartil
---------------------------+------------+----+-----+----------+-----------+---------
Guaraná Fantástica | 4.50 | 1 | 1 | 0.000 | 0.083 | 1
Rhönbräu Klosterbier | 7.75 | 2 | 2 | 0.091 | 0.167 | 1
Laughing Lumberjack Lager | 14.00 | 3 | 3 | 0.182 | 0.333 | 1
Sasquatch Ale | 14.00 | 4 | 3 | 0.182 | 0.333 | 2
Outback Lager | 15.00 | 5 | 5 | 0.364 | 0.417 | 2
Lakkalikööri | 18.00 | 6 | 6 | 0.455 | 0.750 | 2
Steeleye Stout | 18.00 | 7 | 6 | 0.455 | 0.750 | 3
Chartreuse verte | 18.00 | 8 | 6 | 0.455 | 0.750 | 3
Chai | 18.00 | 9 | 6 | 0.455 | 0.750 | 3
Chang | 19.00 | 10 | 10 | 0.818 | 0.833 | 4
Ipoh Coffee | 46.00 | 11 | 11 | 0.909 | 0.917 | 4
Côte de Blaye | 263.50 | 12 | 12 | 1.000 | 1.000 | 4
Las fórmulas explican todo lo que se ve:
| Fórmula | Primera fila | Última fila | |
|---|---|---|---|
percent_rank() |
(rank − 1) / (n − 1) |
siempre 0 | siempre 1 |
cume_dist() |
filas ≤ actual / n |
1/n, nunca 0 | siempre 1 |
percent_rankde Laughing Lumberjack:(3−1)/(12−1) = 0,182. Se apoya enrank(), así que hereda sus saltos.cume_distde los 18,00 €: hay 9 productos con precio ≤ 18,00, y9/12 = 0,750. Los cuatro comparten el valor porque comparten la respuesta: el 75 % del catálogo de bebidas cuesta 18 € o menos.
Cuál usar: percent_rank para "¿qué porcentaje del grupo dejo por debajo?" — la lectura de tipo ranking. cume_dist para "¿qué porcentaje del grupo cae en mi valor o por debajo?" — la lectura de tipo distribución. Si necesitas que la mejor fila marque 100 %, es percent_rank; si necesitas que la peor no marque 0 %, es cume_dist.
6. Cortar por valor, no por tamaño: la deuda del tema 35
En ranking-row-number-rank-ntile quedó abierto un problema: ntile parte los empates. En la tabla de arriba se ve en dos sitios — los dos productos a 14,00 € caen en cuartiles distintos (1 y 2), y los cuatro a 18,00 € se reparten entre el 2 y el 3.
Con precios molesta. Con clientes es un problema de negocio. Segmenta los 89 clientes por número de pedidos:
WITH c AS (
SELECT customer_id, count(*) AS pedidos FROM orders GROUP BY customer_id
), r AS (
SELECT customer_id, pedidos, ntile(4) OVER (ORDER BY pedidos) AS cuartil FROM c
)
SELECT pedidos, cuartil, count(*) AS clientes,
string_agg(customer_id, ', ' ORDER BY customer_id) AS quienes
FROM r
WHERE pedidos IN (5, 12)
GROUP BY pedidos, cuartil
ORDER BY pedidos, cuartil;
pedidos | cuartil | clientes | quienes
---------+---------+----------+-------------------------------------------------
5 | 1 | 7 | FOLIG, GALED, HUNGC, MORGK, OCEAN, PRINI, VINET
5 | 2 | 3 | COMMI, RANCH, ROMEY
12 | 3 | 2 | LINOD, SUPRD
12 | 4 | 1 | REGGC
FOLIG y ROMEY hicieron exactamente cinco pedidos cada uno y caen en cuartiles distintos. No hay ningún dato que los separe: los separa el orden en que el motor los encontró. Si esa segmentación alimenta una campaña, dos clientes idénticos reciben trato distinto por azar.
La alternativa es cortar por posición en la distribución en lugar de por tamaño de grupo:
WITH c AS (
SELECT customer_id, count(*) AS pedidos FROM orders GROUP BY customer_id
), r AS (
SELECT customer_id, pedidos, cume_dist() OVER (ORDER BY pedidos) AS cd FROM c
)
SELECT CASE WHEN cd > 0.75 THEN 'Q4'
WHEN cd > 0.50 THEN 'Q3'
WHEN cd > 0.25 THEN 'Q2'
ELSE 'Q1' END AS grupo,
count(*) AS clientes, min(pedidos) AS desde, max(pedidos) AS hasta
FROM r
GROUP BY grupo ORDER BY grupo;
grupo | clientes | desde | hasta
-------+----------+-------+-------
Q1 | 16 | 1 | 4
Q2 | 25 | 5 | 7
Q3 | 24 | 8 | 11
Q4 | 24 | 12 | 31
Los grupos ya no son iguales —16, 25, 24, 24— pero cada corte es un número que puedes decir en voz alta: Q2 es "de 5 a 7 pedidos". Y todo el que tenga 5 pedidos está en Q2, sin excepción.
La regla, y no tiene escapatoria: con empates puedes tener grupos del mismo tamaño o grupos que respeten el valor. Nunca las dos cosas.
| Quieres | Usa |
|---|---|
| Grupos del mismo tamaño (deciles de una muestra, tests A/B) | ntile(n) |
| Cortes que respeten el valor, con umbrales que puedas comunicar | cume_dist() o percentile_cont() |
7. Detectar sesgo sin mirar los datos
La diferencia entre media y mediana es una medida: dice hacia qué lado tira la cola. Una columna, ocho categorías:
SELECT c.category_name,
count(*) AS productos,
round(avg(p.unit_price), 2) AS media,
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY p.unit_price)::numeric, 2) AS mediana,
round((avg(p.unit_price) - percentile_cont(0.5) WITHIN GROUP (ORDER BY p.unit_price))::numeric, 2) AS sesgo
FROM products p
JOIN categories c ON c.category_id = p.category_id
GROUP BY c.category_name
ORDER BY sesgo DESC;
category_name | productos | media | mediana | sesgo
----------------+-----------+-------+---------+-------
Beverages | 12 | 37.98 | 18.00 | 19.98
Meat/Poultry | 6 | 54.01 | 35.90 | 18.11
Confections | 13 | 25.16 | 16.25 | 8.91
Seafood | 12 | 20.68 | 16.70 | 3.98
Produce | 5 | 32.37 | 30.00 | 2.37
Condiments | 12 | 22.85 | 21.20 | 1.65
Grains/Cereals | 7 | 20.25 | 19.50 | 0.75
Dairy Products | 10 | 28.73 | 33.00 | -4.27
Sin abrir una sola fila de producto, la columna sesgo ya te dice dónde mirar:
- Beverages (+19,98) es la más deformada. Ya sabemos por qué: Côte de Blaye a 263,50 €.
- Meat/Poultry (+18,11) con solo 6 productos. Dos de ellos —Mishi Kobe Niku a 97,00 y Thüringer Rostbratwurst a 123,79— arrastran a los otros cuatro, que están entre 7,45 y 39,00.
- Dairy Products es la única negativa (−4,27). Media por debajo de la mediana significa que la cola larga está en el lado barato: ocho quesos entre 21 y 55 €, y dos anomalías baratas, Geitost a 2,50 y Gorgonzola Telino a 12,50.
- Grains/Cereals (+0,75) y Condiments (+1,65) son las categorías sanas: media y mediana casi pegadas, sin nadie que distorsione.
La regla práctica: cuando media y mediana se separan, la media ha dejado de ser un resumen y se ha convertido en un síntoma. Y el signo te dice de qué lado está el bicho.
8. Un histograma en SQL: width_bucket
Los percentiles dicen dónde están los cortes. Un histograma dice cómo se reparte todo lo demás. width_bucket(valor, minimo, maximo, n) trocea un rango en n bandas iguales y devuelve el número de banda:
WITH pedidos 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 width_bucket(total, 0, 5000, 10) AS banda,
count(*) AS pedidos,
repeat('#', (count(*) / 5)::int) AS barra
FROM pedidos
GROUP BY banda ORDER BY banda;
banda | pedidos | barra
-------+---------+------------------------------------------------
1 | 234 | ##############################################
2 | 193 | ######################################
3 | 111 | ######################
4 | 107 | #####################
5 | 55 | ###########
6 | 37 | #######
7 | 18 | ###
8 | 15 | ###
9 | 12 | ##
10 | 17 | ###
11 | 31 | ######
Bandas de 500 € cada una. La banda 11 no existía en el 10 que pedimos: width_bucket devuelve n+1 para todo lo que se pasa del máximo y 0 para lo que queda por debajo del mínimo. Esos 31 pedidos por encima de 5 000 € son el 3,7 % que estira la media.
La forma es inconfundible: 427 de 830 pedidos —más de la mitad— caben en las dos primeras bandas, y a partir de ahí la curva cae. Es la distribución que produce una media inútil.
9. Preguntar por un valor que no está en la tabla
Hay una cuarta forma, poco conocida y muy útil: rank, percent_rank y cume_dist también se pueden usar con WITHIN GROUP pasándoles un valor. Responden "si esta fila existiera, ¿dónde caería?".
SELECT p.precio,
rank(p.precio) WITHIN GROUP (ORDER BY pr.unit_price) AS puesto,
round((100 * percent_rank(p.precio) WITHIN GROUP (ORDER BY pr.unit_price))::numeric, 1) AS pct_mas_caro_que
FROM (VALUES (9.99), (20.00), (35.00), (60.00)) AS p(precio), products pr
GROUP BY p.precio
ORDER BY p.precio;
precio | puesto | pct_mas_caro_que
--------+--------+------------------
9.99 | 12 | 14.3
20.00 | 40 | 50.6
35.00 | 61 | 77.9
60.00 | 73 | 93.5
Lanzar un producto a 35 € lo colocaría en el puesto 61 del catálogo, más caro que el 77,9 % de lo que ya vendes. Es la respuesta a una pregunta de pricing, escrita sin insertar nada.
Dos reglas que sí hay que respetar. La primera: el argumento tiene que ser comparable con la columna del ORDER BY. Con literales numéricos contra una columna numérica no hay problema —el ::numeric de arriba es cosmético, funciona igual sin él— pero un tipo que no case falla:
SELECT rank('barato'::text) WITHIN GROUP (ORDER BY unit_price) FROM products;
-- ERROR: WITHIN GROUP types numeric and text cannot be matched
La segunda: tantos argumentos como columnas de orden. Si añades un desempate al ORDER BY, tienes que pasar también su valor hipotético:
SELECT rank(20.00) WITHIN GROUP (ORDER BY unit_price, product_name) FROM products;
-- ERROR: function rank(numeric, numeric, character varying) does not exist
-- SUGERENCIA: ... the number of hypothetical direct arguments (here 1)
-- must match the number of ordering columns (here 2).
10. Errores comunes
- Poner
OVERenpercentile_cont. Es el error natural: viene de un tema de ventanas, parece una ventana.
sql
SELECT product_name, unit_price,
percentile_cont(0.5) WITHIN GROUP (ORDER BY unit_price) OVER (PARTITION BY category_id)
FROM products;
ERROR: OVER is not supported for ordered-set aggregate percentile_cont
No existe. Para tener la mediana de la categoría junto a cada producto hay que calcularla en un CTE agrupado y unirla:
sql
WITH medianas AS (
SELECT category_id, percentile_cont(0.5) WITHIN GROUP (ORDER BY unit_price) AS mediana
FROM products GROUP BY category_id
)
SELECT p.product_name, p.unit_price, round(m.mediana::numeric, 2) AS mediana_cat
FROM products p JOIN medianas m ON m.category_id = p.category_id;
- Usar un agregado dentro de
FILTER. Contar cuántas filas superan la media del grupo, en una sola pasada, no se puede:
sql
SELECT anio, count(*) FILTER (WHERE total <= avg(total)) FROM pedidos GROUP BY anio;
ERROR: aggregate functions are not allowed in FILTER
Mismo motivo que window functions are not allowed in WHERE en el tema 32: cuando se evalúa el FILTER, la media todavía no existe. La solución es la misma: calcúlala antes, en un CTE o con una función de ventana, y filtra sobre esa columna.
percentile_contsobre fecha o texto.
sql
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY order_date) FROM orders;
ERROR: function percentile_cont(numeric, date) does not exist
percentile_cont solo acepta lo que se puede interpolar: numéricos e interval. date y timestamp tampoco valen, aunque parezca que sí. Para la fecha mediana, percentile_disc:
sql
SELECT percentile_disc(0.5) WITHIN GROUP (ORDER BY order_date) FROM orders; --> 1997-09-09
-
Confundir
percent_rankcon "percentil".percent_rankdevuelve una posición entre 0 y 1;percentile_contdevuelve un valor. Son inversas, no sinónimas. El nombre parecido es una trampa del estándar SQL, no tuya. -
Olvidar el
ORDER BYdentro deOVER. Este es el peor de los cinco, porque no da error:
sql
SELECT DISTINCT percent_rank() OVER () AS pr, cume_dist() OVER () AS cd FROM products;
pr | cd
----+----
0 | 1
Sin orden todas las filas están empatadas entre sí, así que todas comparten el mismo rango: percent_rank devuelve 0 para las 77 filas y cume_dist devuelve 1 para las 77. Dos columnas constantes con pinta de resultado. Si aparecen en un informe, el error puede vivir ahí meses.
- Añadir un marco a
percent_rankocume_dist. Tampoco falla, y también engaña:
sql
SELECT percent_rank() OVER (ORDER BY unit_price ROWS BETWEEN 1 PRECEDING AND CURRENT ROW)
FROM products;
PostgreSQL lo acepta y calcula el resultado como si el marco no estuviera: la segunda fila devuelve 0,0132, que es (2−1)/(77−1) sobre la partición entera, no sobre las dos filas del marco. Igual que rank, dense_rank, ntile y row_number, estas funciones miran toda la partición y el marco no las afecta. Escribirlo no rompe nada, pero deja en el código una intención que el motor no está cumpliendo.
11. Diferencias con SQL Server
| PostgreSQL | SQL Server |
|---|---|
percent_rank() OVER (ORDER BY x) |
Igual |
cume_dist() OVER (ORDER BY x) |
Igual |
percentile_cont(0.5) WITHIN GROUP (ORDER BY x) — agregado, colapsa filas, funciona con GROUP BY |
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY x) OVER (PARTITION BY ...) — función de ventana, obliga a OVER y repite el valor en cada fila |
mode() WITHIN GROUP (ORDER BY x) |
No existe. Se emula con TOP 1 ... GROUP BY x ORDER BY count(*) DESC |
width_bucket(v, min, max, n) |
No existe. Se emula con aritmética o CASE |
rank(valor) WITHIN GROUP (...) — conjunto hipotético |
No existe |
La diferencia del PERCENTILE_CONT es la que muerde en una entrevista, y va en las dos direcciones. En SQL Server el OVER es obligatorio y no hay forma de agrupar; en PostgreSQL el OVER está prohibido y para repetir el valor en cada fila hay que unir contra un CTE. La consulta que funciona en un motor da error en el otro, con la misma sintaxis en el WITHIN GROUP.
12. Ejercicios
-
El transportista caro. Para cada transportista (
shippers), calcula el número de pedidos, la media, la mediana y el percentil 90 delfreight, y el máximo. Ordena por mediana descendente. ¿Cuál es el más caro? Responde con cuidado: la respuesta cambia según la métrica. -
El producto caro de cada categoría. Calcula el percentil 90 del precio dentro de cada categoría, y lista qué productos lo superan. Cuidado: el error 2 de la sección 10 te está esperando.
-
La distribución del equipo comercial. Para los nueve empleados, muestra la facturación total, su
percent_ranky sucume_distsobre el equipo, en porcentaje. Explica por qué el último de la lista tienepercent_rank0,0 perocume_dist11,1. -
Preguntas sin insertar nada. Usando
cume_distcon conjunto hipotético, responde en una sola fila qué porcentaje de los pedidos está por debajo de 500 €, de 1 000 €, de 2 000 € y de 5 000 €. -
Histograma de plazos de envío. Con
width_bucket, agrupa los pedidos enviados en bandas de 5 días segúnshipped_date - order_date, de 0 a 30 días, y dibuja la barra. ¿Cuántos pedidos tardaron más de 30 días?
13. Pregunta de negocio
"¿A partir de qué importe consideramos que un pedido es grande? Y una segunda cosa: dime si nuestros pedidos están creciendo."
Las dos preguntas se contestan con la misma consulta, y la segunda tiene trampa.
WITH pedidos AS (
SELECT o.order_id,
extract(year FROM o.order_date)::int AS anio,
sum(od.unit_price * od.quantity * (1 - od.discount))::numeric(10,2) AS total
FROM orders o
JOIN order_details od ON od.order_id = o.order_id
GROUP BY o.order_id, anio
)
SELECT anio, count(*) AS pedidos,
round(percentile_cont(0.25) WITHIN GROUP (ORDER BY total)::numeric, 2) AS p25,
round(percentile_cont(0.50) WITHIN GROUP (ORDER BY total)::numeric, 2) AS p50,
round(percentile_cont(0.75) WITHIN GROUP (ORDER BY total)::numeric, 2) AS p75,
round(percentile_cont(0.95) WITHIN GROUP (ORDER BY total)::numeric, 2) AS p95,
round(avg(total), 2) AS media,
round(max(total), 2) AS maximo
FROM pedidos
GROUP BY anio ORDER BY anio;
anio | pedidos | p25 | p50 | p75 | p95 | media | maximo
------+---------+--------+---------+---------+---------+---------+----------
1996 | 152 | 437.35 | 1054.50 | 1815.70 | 3564.19 | 1368.97 | 9210.90
1997 | 408 | 465.28 | 1025.85 | 1917.24 | 4618.94 | 1512.46 | 11188.40
1998 | 270 | 475.12 | 898.20 | 1873.25 | 5089.26 | 1631.94 | 16387.50
⚠️ 1998 está incompleto: los datos llegan al 6 de mayo. Para conteos y sumas eso invalida la comparación. Aquí no tanto — hablamos de la forma de la distribución, no de su volumen— pero cuatro meses y medio no cubren la estacionalidad de un año, así que la fila de 1998 se lee como indicio, no como conclusión.
Primera pregunta: dónde está el umbral. Los candidatos, medidos sobre los 830 pedidos:
| Umbral | Valor | Pedidos que lo superan | % de pedidos | % de facturación |
|---|---|---|---|---|
| Mediana (p50) | 943,25 | 415 | 50,0 % | 84,8 % |
| Media | 1 525,05 | 282 | 34,0 % | 71,7 % |
| p75 | 1 882,20 | 208 | 25,1 % | 61,7 % |
| p90 | 3 160,86 | 83 | 10,0 % | 38,1 % |
| p95 | 4 706,62 | 42 | 5,1 % | 25,4 % |
La media no sirve como umbral, y la tabla dice exactamente por qué: cae en el percentil 66. No es una posición que nadie eligiera — es donde el promedio aterrizó dado el aspecto de la cola. Si mañana entra un pedido de 50 000 €, el umbral se mueve solo, y nadie sabrá decir a qué posición corresponde.
Un percentil no tiene ese problema: p90 significa "el 10 % más grande" el año pasado, este año y en cualquier tabla. La respuesta es 3 160,86 € si "grande" quiere decir el top 10 %, o 1 882,20 € si quiere decir el top 25 %. Elegir es de negocio; lo que SQL aporta es que el criterio se enuncie antes que el número, y no al revés.
Segunda pregunta: ¿están creciendo? La media dice que sí. La distribución dice que no.
| 1996 | 1998 | Cambio | |
|---|---|---|---|
| p25 | 437,35 | 475,12 | +8,6 % |
| p50 (pedido típico) | 1 054,50 | 898,20 | −14,8 % |
| p75 | 1 815,70 | 1 873,25 | +3,2 % |
| p95 | 3 564,19 | 5 089,26 | +42,8 % |
| Máximo | 9 210,90 | 16 387,50 | +77,9 % |
| Media | 1 368,97 | 1 631,94 | +19,2 % |
-
El pedido típico se encogió un 15 % mientras la media subía un 19 %. Las dos cifras son correctas y cuentan historias opuestas. Quien informe solo la media estará diciendo la verdad y transmitiendo lo contrario de lo que pasa.
-
Todo el movimiento está en la cola. El centro de la distribución está quieto —p25 y p75 se mueven menos de un 9 %— y el p95 sube un 42,8 %. No es que los clientes compren más: es que unos pocos pedidos enormes se han hecho más enormes y arrastran el promedio.
-
La media se ha ido desplazando dentro de su propia distribución. El porcentaje de pedidos por debajo de la media fue 60,5 % en 1996, 64,5 % en 1997 y 69,3 % en 1998. Ese es el daño real: "la media" no es una posición fija. Cada año describe a menos gente. En 1998 hablar del pedido medio es hablar de algo que siete de cada diez pedidos no alcanzan.
-
Qué hacer con esto. El umbral operativo debería fijarse en un percentil —p90, 3 160,86 €— y recalcularse cada año. Y el cuadro de mando debería llevar p50 y p95 juntos: uno dice cómo le va al cliente normal, el otro dice cuánto dependemos de los excepcionales. Una sola cifra en el medio no puede decir las dos cosas.
Este es el mismo hilo que en partition-by-y-order-by: Côte de Blaye deformaba una categoría. Aquí se ve que el fenómeno no era del catálogo, sino de la empresa entera. El bloque 12 lo cierra en analisis-abc-de-clientes, cuantificando qué porción de clientes sostiene qué porción de la facturación.