En la lección anterior obtuviste una cifra para toda la empresa: 727,95 € de facturación. Es un dato, pero no es un análisis. Lo que la dirección pregunta de verdad no es "¿cuánto hemos vendido?", sino "¿cuánto hemos vendido de cada cosa?": por categoría, por cliente, por país, por mes, por comercial. Esa palabra —por— es la señal inequívoca de que toca GROUP BY.
GROUP BY parte el conjunto de filas en grupos y aplica el agregado a cada grupo por separado, devolviendo una fila por grupo en vez de una para todo. Es, sin exagerar, la cláusula que convierte SQL en una herramienta de análisis. En esta lección aprenderás a agrupar por una columna, por varias y por una expresión; ampliarás por fin el diagrama del orden lógico de ejecución con GROUP BY y HAVING —lo prometimos en 02-01—; verás por qué los NULL forman su propio grupo; dominarás el patrón central del análisis, que es GROUP BY combinado con JOIN; y descubrirás por qué la categoría 6 de TiendaVerde desaparece de tus informes y qué hay que hacer para que aparezca con un honesto 0.
Contenido
GROUP BY: partir en grupos y agregar cada uno- El orden lógico de ejecución, ampliado
- La regla de oro: agrupada o agregada
- Agrupar por una columna
- Agrupar por varias columnas
- Agrupar por una expresión
GROUP BYy el alias delSELECT- Grupos y
NULL GROUP BYconJOIN: el patrón central del análisis- Grupos vacíos: por qué la categoría 6 no aparece
- Ordenar por el agregado y quedarse con el top N
ROLLUP,GROUPING SETSyCUBE- Errores Comunes y Consejos
- Ejercicios
- Conclusión
GROUP BY: partir en grupos y agregar cada uno
GROUP BY: partir en grupos y agregar cada unoLa idea es visual. Sin GROUP BY, todas las filas van a un único montón. Con GROUP BY, se reparten en montones según el valor de una columna, y el agregado se calcula en cada montón:
flowchart LR
subgraph A["20 pedidos"]
direction TB
A1["entregado ×14"]
A2["enviado ×2"]
A3["pagado ×2"]
A4["cancelado ×1"]
A5["pendiente ×1"]
end
A --> B["GROUP BY estado"]
B --> C["5 grupos"]
C --> D["COUNT(*) en cada uno<br/>→ 5 filas de resultado"]
Y la consulta:
| estado | pedidos |
|---|---|
| entregado | 14 |
| enviado | 2 |
| pagado | 2 |
| cancelado | 1 |
| pendiente | 1 |
5 filas, una por estado. Compara con lo que habrías tenido que hacer sin GROUP BY: cinco consultas con WHERE estado = '…', o una con cinco COUNT(*) FILTER (...). Y si mañana apareciera un sexto estado, GROUP BY lo mostraría solo, mientras que las otras dos versiones habría que reescribirlas.
El principio general: GROUP BY produce una fila por cada combinación distinta de valores de las columnas agrupadas. El número de filas del resultado es exactamente el número de valores distintos, ni uno más ni uno menos.
- El orden lógico de ejecución, ampliado
Desde 02-01 vienes construyendo un diagrama del orden en que SQL entiende una consulta. El módulo 2 lo dejó en cinco pasos y el módulo 3 mostró que los JOIN ocurren dentro del FROM. Ahora insertamos las dos piezas que faltaban, entre WHERE y SELECT:
flowchart LR
A["1 · FROM / JOIN<br/>de dónde salen las filas"] --> B["2 · WHERE<br/>filtra FILAS"]
B --> C["3 · GROUP BY<br/>forma los GRUPOS"]
C --> D["4 · HAVING<br/>filtra GRUPOS"]
D --> E["5 · SELECT<br/>proyecta y calcula<br/>nacen los alias"]
E --> F["5b · DISTINCT<br/>elimina duplicados"]
F --> G["6 · ORDER BY<br/>ordena el resultado"]
G --> H["7 · LIMIT / OFFSET<br/>recorta"]
| Paso | Cláusula | Qué hace | Trabaja sobre | Lección |
|---|---|---|---|---|
| 1 | FROM / JOIN |
Determina el conjunto de filas de partida | Tablas | 02-01 / módulo 3 |
| 2 | WHERE |
Descarta filas | Filas individuales | 02-03 |
| 3 | GROUP BY |
Reparte las filas supervivientes en grupos | Filas | 04-05 |
| 4 | HAVING |
Descarta grupos enteros | Grupos | 04-06 |
| 5 | SELECT |
Calcula y proyecta las columnas. Aquí nacen los alias | Grupos (o filas, si no hay GROUP BY) |
02-01 / 02-02 |
| 5b | DISTINCT |
Elimina filas duplicadas del resultado | Resultado | 02-04 |
| 6 | ORDER BY |
Ordena | Resultado | 02-05 |
| 7 | LIMIT / OFFSET |
Recorta | Resultado | 02-06 |
Este diagrama no es decoración: explica por sí solo casi todo lo que viene después. Tres consecuencias inmediatas:
| Consecuencia | Por qué |
|---|---|
WHERE no puede usar funciones de agregación |
Se ejecuta en el paso 2, antes de que existan los grupos. No hay nada que agregar todavía. De ahí el error aggregate functions are not allowed in WHERE |
HAVING sí puede usarlas |
Se ejecuta en el paso 4, cuando los grupos ya están formados y sus agregados calculados. Es la lección 04-06 entera |
Ni WHERE ni GROUP BY ni HAVING ven los alias del SELECT |
Los alias nacen en el paso 5. ORDER BY, que va después, sí los ve (02-05) |
Esa última tiene un matiz importante en PostgreSQL, y le dedicamos la sección 7 entera.
El razonamiento a tener siempre presente: una vez que se ejecuta GROUP BY, las filas individuales dejan de existir como tales. A partir del paso 3, el conjunto de trabajo ya no son 47 líneas de pedido: son 5 grupos. Todo lo que escribas de ahí en adelante debe tener sentido a nivel de grupo.
- La regla de oro: agrupada o agregada
Es una sola frase, y de ella se deduce todo lo demás:
Toda expresión del
SELECTdebe estar (a) en elGROUP BY, o (b) dentro de una función de agregación. Sin excepciones.
Verla fallar es la mejor forma de entenderla:
-- ⚠️ INCORRECTA
SELECT cat.nombre AS categoria,
p.nombre AS producto,
COUNT(*) AS productos
FROM productos AS p
JOIN categorias AS cat ON p.categoria_id = cat.id
GROUP BY cat.nombre;ERROR: column "p.nombre" must appear in the GROUP BY clause or be used in an aggregate function
LINE 3: p.nombre AS producto,
^Y el motor tiene razón. La fila del grupo "Alimentación" representa cinco productos: el aceite, el arroz, la miel, la pasta y el tomate. ¿Cuál de los cinco nombres debería aparecer en esa celda? No hay respuesta posible, así que PostgreSQL se niega a inventarse una.
Las tres salidas válidas:
-- ✅ a) Agrupar también por el nombre del producto (pero entonces no hay grupos: cada uno es único)
GROUP BY cat.nombre, p.nombre
-- ✅ b) Agregar el nombre del producto
STRING_AGG(p.nombre, ', ' ORDER BY p.id) AS productos
-- ✅ c) Quitar la columna del SELECT
SELECT cat.nombre, COUNT(*) ...La opción b es especialmente útil, y demuestra que la regla no es un capricho:
SELECT cat.nombre AS categoria,
COUNT(*) AS productos,
STRING_AGG(p.nombre, ' · ' ORDER BY p.id) AS listado
FROM productos AS p
JOIN categorias AS cat ON p.categoria_id = cat.id
GROUP BY cat.nombre
ORDER BY productos DESC, categoria;| categoria | productos | listado |
|---|---|---|
| Alimentación | 5 | Aceite de oliva virgen extra 500 ml · Arroz integral ecológico 1 kg · Miel de azahar cruda 500 g · Pasta de espelta 500 g · Tomate triturado ecológico 400 g |
| Bebidas | 4 | Infusión de manzanilla ecológica 20 uds · Té verde matcha ceremonial 30 g · Kombucha de jengibre 750 ml · Zumo de naranja prensado en frío 1 L |
| Cosmética natural | 4 | Crema facial de aloe vera 50 ml · Champú sólido de romero 80 g · Aceite corporal de almendras 200 ml · Bálsamo labial de caléndula 15 ml |
| Hogar sostenible | 4 | Detergente ecológico concentrado 1 L · Estropajo vegetal de luffa (pack 3) · Bolsas reutilizables de algodón (pack 5) · Velas de cera de soja (pack 2) |
| Higiene personal | 2 | Cepillo de dientes de bambú · Desodorante natural en barra 50 g |
| Complementos | 1 | Cápsulas de espirulina 120 uds |
6 filas, una por categoría, con los 20 productos repartidos: 5 + 4 + 4 + 4 + 2 + 1 = 20. STRING_AGG ha respondido a "¿cuáles son?" sin romper la regla, porque es un agregado.
Una matización sobre la regla
PostgreSQL es algo más listo de lo que sugiere el enunciado: si agrupas por la clave primaria de una tabla, te deja seleccionar cualquier otra columna de esa misma tabla, porque la PK determina funcionalmente el resto de valores.
-- ✅ CORRECTA: cat.id es la PK, así que cat.nombre queda determinado
SELECT cat.id,
cat.nombre AS categoria,
COUNT(p.id) AS productos
FROM categorias AS cat
LEFT JOIN productos AS p ON p.categoria_id = cat.id
GROUP BY cat.id
ORDER BY cat.id;| id | categoria | productos |
|---|---|---|
| 1 | Alimentación | 5 |
| 2 | Cosmética natural | 4 |
| 3 | Hogar sostenible | 4 |
| 4 | Bebidas | 4 |
| 5 | Higiene personal | 2 |
| 6 | Complementos | 1 |
Funciona porque cat.id es PRIMARY KEY: dentro de un grupo con el mismo id solo puede haber un nombre, así que no hay ambigüedad. Es una comodidad muy práctica —evita tener que repetir cinco columnas en el GROUP BY— y forma parte del estándar SQL desde 1999.
Nota de dialecto — y por qué la permisividad de MySQL es una trampa.
Motor ¿Permite una columna suelta con un agregado? Qué devuelve PostgreSQL No (salvo dependencia funcional de la PK) Error explícito MySQL con ONLY_FULL_GROUP_BY(por defecto desde 5.7.5)No Error explícito MySQL con ONLY_FULL_GROUP_BYdesactivadoSí Un valor arbitrario de cualquier fila del grupo SQLite Sí, siempre Un valor arbitrario (con la excepción documentada de MIN/MAX, donde devuelve el de esa fila)SQL Server No Error explícito Oracle No Error explícito El caso de MySQL permisivo es la trampa: la consulta no falla, y en pruebas con pocos datos incluso parece devolver "el primero", que suele ser el que uno esperaba. En producción, con otro plan de ejecución, devuelve otro. Un informe que decía "Alimentación — Aceite de oliva — 5 productos" empieza a decir "Alimentación — Tomate triturado — 5 productos" sin que nadie haya tocado nada. Si trabajas con MySQL, comprueba que
ONLY_FULL_GROUP_BYestá activo y no lo desactives.
- Agrupar por una columna
4.1. Pedidos por método de pago
SELECT metodo_pago,
COUNT(*) AS pedidos,
SUM(gastos_envio) AS portes_totales,
ROUND(AVG(gastos_envio), 2) AS portes_medios
FROM pedidos
GROUP BY metodo_pago
ORDER BY pedidos DESC, metodo_pago;| metodo_pago | pedidos | portes_totales | portes_medios |
|---|---|---|---|
| tarjeta | 11 | 52.10 | 4.74 |
| paypal | 4 | 29.70 | 7.43 |
| transferencia | 3 | 17.45 | 5.82 |
| contrareembolso | 2 | 19.00 | 9.50 |
4 filas que suman 20 pedidos y 118,25 € de portes: las mismas cifras maestras de 04-04, ahora desglosadas. Y ya se lee una historia: la tarjeta domina (11 de 20) y el contrareembolso es el método con portes más caros (9,50 € de media), lo cual tiene sentido porque son los envíos más lejanos.
4.2. Clientes por país
SELECT pais,
COUNT(*) AS clientes,
COUNT(DISTINCT ciudad) AS ciudades,
MIN(fecha_registro) AS primer_alta,
MAX(fecha_registro) AS ultima_alta
FROM clientes
GROUP BY pais
ORDER BY clientes DESC, pais;| pais | clientes | ciudades | primer_alta | ultima_alta |
|---|---|---|---|---|
| España | 11 | 7 | 2025-01-10 | 2026-01-08 |
| Francia | 2 | 2 | 2025-04-18 | 2025-05-02 |
| Portugal | 2 | 2 | 2025-03-21 | 2025-04-04 |
3 filas. Fíjate en COUNT(DISTINCT ciudad): 11 clientes españoles repartidos en solo 7 ciudades, porque Valencia concentra a cuatro y Barcelona a dos.
4.3. Productos por categoría, con estadísticas de precio
SELECT categoria_id,
COUNT(*) AS productos,
ROUND(AVG(precio), 2) AS precio_medio,
MIN(precio) AS mas_barato,
MAX(precio) AS mas_caro,
SUM(stock) AS stock_total
FROM productos
GROUP BY categoria_id
ORDER BY categoria_id;| categoria_id | productos | precio_medio | mas_barato | mas_caro | stock_total |
|---|---|---|---|---|---|
| 1 | 5 | 6.18 | 1.95 | 12.50 | 850 |
| 2 | 4 | 11.54 | 4.60 | 18.90 | 330 |
| 3 | 4 | 10.09 | 5.50 | 13.75 | 265 |
| 4 | 4 | 8.90 | 3.25 | 22.00 | 370 |
| 5 | 2 | 5.65 | 3.50 | 7.80 | 315 |
| 6 | 1 | 16.40 | 16.40 | 16.40 | 55 |
6 filas, las 6 categorías, porque estamos agrupando la tabla productos y todos los productos tienen categoría. Guarda este detalle: en la sección 10 verás que agrupar las ventas por categoría solo da 5 filas, y entender por qué es la diferencia entre un informe honesto y uno incompleto.
Nota lo que ocurre con la categoría 6: un solo producto, así que AVG, MIN y MAX coinciden. Los agregados sobre un grupo de una fila devuelven ese mismo valor.
- Agrupar por varias columnas
Al listar varias columnas en el GROUP BY, el motor forma un grupo por cada combinación distinta de valores.
SELECT c.pais,
pe.estado,
COUNT(*) AS pedidos,
SUM(pe.gastos_envio) AS portes
FROM pedidos AS pe
JOIN clientes AS c ON pe.cliente_id = c.id
GROUP BY c.pais, pe.estado
ORDER BY c.pais, pe.estado;| pais | estado | pedidos | portes |
|---|---|---|---|
| España | cancelado | 1 | 4.95 |
| España | enviado | 1 | 4.95 |
| España | entregado | 10 | 31.25 |
| España | pagado | 2 | 9.90 |
| Francia | entregado | 2 | 25.00 |
| Francia | pendiente | 1 | 12.50 |
| Portugal | enviado | 1 | 9.90 |
| Portugal | entregado | 2 | 19.80 |
8 filas. Ojo a un punto importante: no son 3 países × 5 estados = 15 filas. GROUP BY produce una fila por cada combinación que existe en los datos, no por cada combinación posible. No hay ningún pedido francés cancelado, así que esa fila no aparece — no aparece con un cero, es que directamente no existe. (Si necesitaras la rejilla completa, incluidas las combinaciones vacías, el camino sería un CROSS JOIN de 03-06 con un LEFT JOIN encima.)
El orden de las columnas en el GROUP BY no cambia el resultado, solo la interpretación mental. GROUP BY c.pais, pe.estado y GROUP BY pe.estado, c.pais devuelven los mismos 8 grupos. Lo que sí cambia el aspecto del informe es el ORDER BY.
Y el caso clásico de análisis: ventas por categoría y año.
SELECT cat.nombre AS categoria,
EXTRACT(YEAR FROM pe.fecha_pedido) AS anio,
COUNT(*) AS lineas,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id
JOIN productos AS p ON lp.producto_id = p.id
JOIN categorias AS cat ON p.categoria_id = cat.id
GROUP BY cat.nombre, EXTRACT(YEAR FROM pe.fecha_pedido)
ORDER BY categoria, anio;| categoria | anio | lineas | facturacion |
|---|---|---|---|
| Alimentación | 2025 | 14 | 215.67 |
| Alimentación | 2026 | 2 | 40.60 |
| Bebidas | 2025 | 9 | 146.55 |
| Bebidas | 2026 | 2 | 48.73 |
| Cosmética natural | 2025 | 8 | 128.22 |
| Cosmética natural | 2026 | 2 | 28.10 |
| Higiene personal | 2025 | 2 | 24.50 |
| Higiene personal | 2026 | 1 | 7.00 |
| Hogar sostenible | 2025 | 7 | 88.58 |
9 filas, no 10: Hogar sostenible no ha vendido nada en 2026, así que esa combinación no existe. Es el mismo fenómeno de antes, y en un informe de evolución es exactamente la clase de hueco que hay que saber leer.
(EXTRACT(YEAR FROM …) extrae el año de una fecha. Las funciones de fecha se estudian a fondo en 06-03; aquí es solo un instrumento.)
- Agrupar por una expresión
No estás limitado a columnas: puedes agrupar por cualquier expresión calculada a partir de ellas. Es lo que acabas de hacer con EXTRACT, y es lo que permite construir tramos, cohortes y segmentaciones.
Agrupemos el catálogo por rango de precio:
SELECT CASE
WHEN precio < 5 THEN '1 · menos de 5 €'
WHEN precio < 10 THEN '2 · de 5 a 10 €'
WHEN precio < 15 THEN '3 · de 10 a 15 €'
ELSE '4 · 15 € o más'
END AS rango_precio,
COUNT(*) AS productos,
ROUND(AVG(precio), 2) AS precio_medio,
MIN(precio) AS minimo,
MAX(precio) AS maximo
FROM productos
GROUP BY CASE
WHEN precio < 5 THEN '1 · menos de 5 €'
WHEN precio < 10 THEN '2 · de 5 a 10 €'
WHEN precio < 15 THEN '3 · de 10 a 15 €'
ELSE '4 · 15 € o más'
END
ORDER BY rango_precio;| rango_precio | productos | precio_medio | minimo | maximo |
|---|---|---|---|---|
| 1 · menos de 5 € | 7 | 3.56 | 1.95 | 4.95 |
| 2 · de 5 a 10 € | 6 | 7.79 | 5.40 | 9.90 |
| 3 · de 10 a 15 € | 4 | 12.93 | 11.20 | 14.25 |
| 4 · 15 € o más | 3 | 19.10 | 16.40 | 22.00 |
4 tramos que suman los 20 productos. El catálogo de TiendaVerde está claramente escorado a producto barato: 13 de 20 referencias cuestan menos de 10 €.
Dos cosas de esta consulta merecen comentario:
- La expresión aparece dos veces, idéntica, en el
SELECTy en elGROUP BY. Es fea y es necesaria, por el motivo de siempre: el aliasrango_preciotodavía no existe cuando se ejecuta elGROUP BY. (Salvo en PostgreSQL, que hace una concesión — sección 7.) Las formas de evitar la repetición son las subconsultas del módulo 7 y las CTE del módulo 10. - El prefijo numérico de las etiquetas (
1 ·,2 ·…) no es decorativo.ORDER BY rango_precioordena alfabéticamente, y sin el prefijo el orden sería "de 10 a 15 €", "de 5 a 10 €", "15 € o más", "menos de 5 €": un desastre. Es un truco habitual al construir tramos.
CASE se estudia a fondo en 06-05; aquí se usa como herramienta para clasificar.
GROUP BY y el alias del SELECT
GROUP BY y el alias del SELECTEste punto se explica mal en muchos sitios, así que vamos a ser precisos.
Según el orden lógico, el GROUP BY (paso 3) se ejecuta antes que el SELECT (paso 5), donde nacen los alias. Por tanto, el estándar SQL no permite usar un alias del SELECT en el GROUP BY.
En la práctica, PostgreSQL hace una concesión: acepta que un elemento del GROUP BY sea el nombre de una columna de salida (un alias) o su número ordinal. Es una extensión del motor, documentada y muy cómoda:
-- ✅ Funciona en PostgreSQL: 'anio' es un alias del SELECT
SELECT EXTRACT(YEAR FROM fecha_pedido) AS anio,
COUNT(*) AS pedidos,
SUM(gastos_envio) AS portes
FROM pedidos
GROUP BY anio
ORDER BY anio;| anio | pedidos | portes |
|---|---|---|
| 2025 | 16 | 85.95 |
| 2026 | 4 | 32.30 |
También funciona por número ordinal, GROUP BY 1, aunque esa forma es frágil: si alguien añade una columna al principio del SELECT, el 1 pasa a referirse a otra cosa.
Ahora las tres limitaciones de esa concesión, que son las que producen los errores confusos.
Limitación 1: solo un alias desnudo, no una expresión que lo use
-- ⚠️ INCORRECTA
SELECT ROUND(precio) AS precio_entero, COUNT(*)
FROM productos
GROUP BY precio_entero + 0;En cuanto el alias entra en una expresión mayor, PostgreSQL deja de resolverlo como nombre de salida y lo busca como columna de la tabla, donde no existe.
Limitación 2: en caso de ambigüedad, gana la columna de entrada
Esta es la peligrosa. Si un alias del SELECT coincide con el nombre de una columna real de la tabla, PostgreSQL usa la columna de la tabla, no tu alias:
-- ⚠️ INCORRECTA: 'categoria_id' es a la vez alias y columna real
SELECT proveedor_id AS categoria_id,
COUNT(*) AS productos
FROM productos
GROUP BY categoria_id;ERROR: column "productos.proveedor_id" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT proveedor_id AS categoria_id,
^El mensaje parece absurdo —"pero si he escrito GROUP BY categoria_id, que es justo el alias de proveedor_id"— hasta que entiendes la regla: el GROUP BY ha resuelto categoria_id como productos.categoria_id, la columna real. Y entonces proveedor_id queda suelta en el SELECT, que es exactamente lo que el error denuncia. Un alias que sombrea el nombre de una columna es siempre mala idea.
Limitación 3: HAVING no acepta alias
-- ⚠️ INCORRECTA
SELECT categoria_id, COUNT(*) AS productos
FROM productos
GROUP BY categoria_id
HAVING productos > 3;La concesión de PostgreSQL cubre GROUP BY y ORDER BY, pero no HAVING. Ahí hay que repetir el agregado entero: HAVING COUNT(*) > 3. Lo desarrolla la lección 04-06.
Resumen por cláusula y por motor
| Cláusula | ¿Ve los alias del SELECT? |
|---|---|
WHERE |
No, en ningún motor |
GROUP BY |
Sí en PostgreSQL, MySQL y SQLite (extensión). No en SQL Server ni en Oracle anterior a 23ai |
HAVING |
No en PostgreSQL, SQL Server ni Oracle. Sí en MySQL y SQLite |
ORDER BY |
Sí en todos |
Recomendación del curso: aunque PostgreSQL te lo permita, repite la expresión completa en el
GROUP BY. Es más largo, sí, pero es portable, no depende de reglas de resolución de nombres y funciona igual en las cuatro cláusulas. Reserva los alias para elORDER BY, donde son estándar y no tienen sorpresas.
- Grupos y
NULL
NULLEn 04-03 viste la tabla de dónde los NULL se consideran iguales entre sí, y GROUP BY estaba en la lista. Aquí está en acción, y es una de las cosas más útiles de toda la lección:
SELECT empleado_id,
COUNT(*) AS pedidos,
SUM(gastos_envio) AS portes
FROM pedidos
GROUP BY empleado_id
ORDER BY empleado_id NULLS LAST;| empleado_id | pedidos | portes |
|---|---|---|
| 4 | 4 | 22.40 |
| 5 | 4 | 32.30 |
| 6 | 2 | 17.45 |
| (null) | 10 | 46.10 |
4 grupos, y el cuarto es el de los NULL: 10 pedidos del canal web con 46,10 € de portes. GROUP BY ha juntado los diez nulos en un solo grupo, pese a que NULL = NULL sea UNKNOWN.
Y esto es exactamente lo que quieres: el canal web es una categoría de negocio real y merece su fila. Compáralo con lo que habría pasado si el diseño hubiera usado un centinela (empleado_id = 0): el grupo existiría igual, pero además se colaría en COUNT(DISTINCT empleado_id) como si fuera un comercial de verdad, dando 4 en lugar de 3.
Para que el informe se lea bien, la fila del nulo necesita una etiqueta. Con COALESCE (06-04) o CASE (06-05) se resuelve; por ahora, el NULLS LAST del ORDER BY (02-05) al menos la coloca donde toca.
Lo mismo con los referidos:
SELECT referido_por_id,
COUNT(*) AS clientes
FROM clientes
GROUP BY referido_por_id
ORDER BY clientes DESC, referido_por_id NULLS LAST;| referido_por_id | clientes |
|---|---|
| (null) | 7 |
| 1 | 3 |
| 2 | 1 |
| 5 | 1 |
| 6 | 1 |
| 7 | 1 |
| 9 | 1 |
7 filas. El grupo nulo (7 clientes que llegaron por su cuenta) es el mayor, y Lucía Martínez Soler (id 1) es la mejor prescriptora con 3 recomendados. Ese es un dato accionable que ninguna consulta anterior del curso podía dar.
GROUP BY con JOIN: el patrón central del análisis
GROUP BY con JOIN: el patrón central del análisisLlegamos a lo que de verdad se hace todos los días en cualquier empresa. La consulta canónica de cuatro tablas de 03-02 sigue siendo la base; lo único que cambia es que ahora le ponemos GROUP BY encima.
9.1. Ventas por categoría
SELECT cat.id,
cat.nombre AS categoria,
COUNT(*) AS lineas,
SUM(lp.cantidad) AS unidades,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM lineas_pedido AS lp
JOIN productos AS p ON lp.producto_id = p.id
JOIN categorias AS cat ON p.categoria_id = cat.id
GROUP BY cat.id, cat.nombre
ORDER BY facturacion DESC, cat.id;| id | categoria | lineas | unidades | facturacion |
|---|---|---|---|---|
| 1 | Alimentación | 16 | 49 | 256.27 |
| 4 | Bebidas | 11 | 29 | 195.28 |
| 2 | Cosmética natural | 10 | 16 | 156.32 |
| 3 | Hogar sostenible | 7 | 10 | 88.58 |
| 5 | Higiene personal | 3 | 9 | 31.50 |
Estas son las cifras de facturación por categoría de TiendaVerde. Suman 727,95 €, el total de 04-04. Y ya cuentan una historia comercial: Alimentación lidera en facturación y en unidades (49 de 113), mientras que Cosmética natural factura 156,32 € con solo 16 unidades — su ticket por unidad es casi cinco veces mayor.
Cinco filas, no seis. Falta Complementos. Volveremos a ello en la sección 10.
9.2. Ventas por cliente
SELECT c.id,
c.nombre || ' ' || c.apellidos AS cliente,
c.pais,
COUNT(DISTINCT pe.id) AS pedidos,
COUNT(*) AS lineas,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS total_gastado
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id
JOIN clientes AS c ON pe.cliente_id = c.id
JOIN productos AS p ON lp.producto_id = p.id
GROUP BY c.id, c.nombre, c.apellidos, c.pais
ORDER BY total_gastado DESC, c.id;| id | cliente | pais | pedidos | lineas | total_gastado |
|---|---|---|---|---|---|
| 7 | Sofia Moreira Costa | Portugal | 2 | 5 | 111.88 |
| 1 | Lucía Martínez Soler | España | 3 | 9 | 107.60 |
| 9 | Camille Dubois | Francia | 2 | 4 | 70.87 |
| 10 | Julien Moreau | Francia | 1 | 3 | 66.90 |
| 4 | Javier Ortega Ruiz | España | 2 | 4 | 62.93 |
| 2 | Carlos Ferrer Ibáñez | España | 2 | 4 | 59.46 |
| 6 | Pau Llorens Vidal | España | 2 | 3 | 57.33 |
| 5 | Ana Belmonte Roca | España | 2 | 4 | 54.85 |
| 8 | Tiago Almeida Nunes | Portugal | 1 | 3 | 44.60 |
| 12 | Diego Ramos Herrera | España | 1 | 3 | 31.70 |
| 11 | Elena Navarro Puig | España | 1 | 2 | 30.30 |
| 3 | Marta Sanchis Gil | España | 1 | 3 | 29.53 |
12 filas —los 12 clientes que han comprado— y el ranking de clientes de TiendaVerde: Sofia Moreira Costa lidera con 111,88 €, seguida muy de cerca por Lucía Martínez Soler con 107,60 € repartidos en tres pedidos.
Dos detalles técnicos que hay que ver:
COUNT(DISTINCT pe.id)es imprescindible.COUNT(*)cuenta líneas, no pedidos: Lucía tiene 9 líneas en 3 pedidos. Es la multiplicación de filas de 03-02, yDISTINCTes la forma de deshacerla al contar.- Todas las columnas no agregadas están en el
GROUP BY. Comoc.ides la PK declientes, PostgreSQL nos permitiría escribir soloGROUP BY c.id; se han listado todas para que la consulta sea portable.
Y el aviso, ahora resuelto: si añadieras SUM(pe.gastos_envio) a esta consulta obtendrías un número inflado, porque cada pedido aparece tantas veces como líneas tenga. Lucía pagaría sus portes nueve veces. Es exactamente el problema de 04-04 sección 11, y la solución es la misma: agregar los portes en una consulta aparte (o, desde el módulo 7, con una subconsulta que colapse las líneas antes de unir).
9.3. Unidades por producto
SELECT p.id,
p.nombre AS producto,
SUM(lp.cantidad) AS unidades,
COUNT(*) AS veces_vendido,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM lineas_pedido AS lp
JOIN productos AS p ON lp.producto_id = p.id
GROUP BY p.id, p.nombre
ORDER BY unidades DESC, p.id
LIMIT 8;| id | producto | unidades | veces_vendido | facturacion |
|---|---|---|---|---|
| 2 | Arroz integral ecológico 1 kg | 14 | 4 | 54.60 |
| 5 | Tomate triturado ecológico 400 g | 14 | 2 | 23.79 |
| 16 | Kombucha de jengibre 750 ml | 12 | 3 | 56.43 |
| 1 | Aceite de oliva virgen extra 500 ml | 9 | 5 | 109.53 |
| 14 | Infusión de manzanilla ecológica 20 uds | 9 | 3 | 29.25 |
| 18 | Cepillo de dientes de bambú | 9 | 3 | 31.50 |
(6 primeras de 17 filas.)
17 filas en total, no 20: los productos 13, 19 y 20 nunca se han vendido y no aparecen. Y observa el contraste entre las dos primeras columnas: el arroz y el tomate empatan en unidades (14), pero el arroz factura más del doble porque cuesta el doble. El producto más vendido en unidades y el que más factura casi nunca son el mismo, y por eso un informe de ventas necesita ambas métricas.
- Grupos vacíos: por qué la categoría 6 no aparece
Vuelve a la sección 9.1: cinco categorías en el informe de ventas, seis en el catálogo. Complementos ha desaparecido.
No es un error del GROUP BY. Es el INNER JOIN de 03-02 haciendo lo que hace: descartar lo que no casa. El único producto de Complementos (la espirulina) no aparece en lineas_pedido, así que ninguna fila del FROM pertenece a esa categoría, y un grupo que no tiene filas no existe.
GROUP BY no inventa grupos: solo reparte las filas que le llegan.
flowchart TD
A["categorias: 6 filas"] --> B["INNER JOIN con las ventas"]
B --> C["Complementos no casa<br/>❌ se descarta en el FROM"]
C --> D["GROUP BY solo ve 5 categorías<br/>→ 5 filas"]
A --> E["LEFT JOIN desde categorias"]
E --> F["Complementos se conserva<br/>con las ventas a NULL"]
F --> G["GROUP BY ve 6 categorías<br/>→ 6 filas"]
La solución: LEFT JOIN desde la tabla que debe salir entera
SELECT cat.id,
cat.nombre AS categoria,
COUNT(lp.id) AS lineas,
COALESCE(SUM(lp.cantidad), 0) AS unidades,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM categorias AS cat
LEFT JOIN productos AS p ON p.categoria_id = cat.id
LEFT JOIN lineas_pedido AS lp ON lp.producto_id = p.id
GROUP BY cat.id, cat.nombre
ORDER BY cat.id;| id | categoria | lineas | unidades | facturacion |
|---|---|---|---|---|
| 1 | Alimentación | 16 | 49 | 256.27 |
| 2 | Cosmética natural | 10 | 16 | 156.32 |
| 3 | Hogar sostenible | 7 | 10 | 88.58 |
| 4 | Bebidas | 11 | 29 | 195.28 |
| 5 | Higiene personal | 3 | 9 | 31.50 |
| 6 | Complementos | 0 | 0 | (null) |
6 filas. Ahí está Complementos, con la verdad: 0 líneas, 0 unidades y una facturación nula.
Tres decisiones de esa consulta merecen explicación, y son la parte más importante de la sección:
1. COUNT(lp.id) y no COUNT(*). Esto es crítico. La fila de Complementos existe en el resultado del LEFT JOIN —con todas las columnas de lineas_pedido a NULL—, así que COUNT(*) la contaría y devolvería 1, no 0. COUNT(lp.id) cuenta solo los valores no nulos y devuelve el honesto 0.
-- La diferencia, sobre la misma consulta
COUNT(*) -- Complementos: 1 ❌ hay una fila, pero no hay ninguna venta
COUNT(lp.id) -- Complementos: 0 ✅Es la regla de 04-04 sección 3 aplicada al caso que más importa. En cualquier GROUP BY sobre un LEFT JOIN, cuenta siempre una columna de la tabla de la derecha, nunca *.
2. La facturación sale *(null)* y no 0.00. Porque SUM de un conjunto vacío es NULL (04-04, sección 7). Para presentarlo como 0.00 haría falta COALESCE(SUM(...), 0), que es lo que hemos hecho con las unidades. COALESCE es de 06-04; aquí se ha usado una vez para que veas el contraste entre las dos columnas.
3. Los dos JOIN son LEFT. Recuerda la regla de 03-03 sección 7: un INNER JOIN después de un LEFT JOIN anula su efecto. Si el segundo salto fuera JOIN lineas_pedido, Complementos volvería a desaparecer.
El mismo patrón sobre productos
SELECT p.id,
p.nombre AS producto,
p.stock,
p.activo,
COUNT(lp.id) AS veces_vendido,
COALESCE(SUM(lp.cantidad), 0) AS unidades
FROM productos AS p
LEFT JOIN lineas_pedido AS lp ON lp.producto_id = p.id
GROUP BY p.id, p.nombre, p.stock, p.activo
HAVING COUNT(lp.id) = 0
ORDER BY p.id;| id | producto | stock | activo | veces_vendido | unidades |
|---|---|---|---|---|---|
| 13 | Velas de cera de soja (pack 2) | 0 | true | 0 | 0 |
| 19 | Desodorante natural en barra 50 g | 75 | true | 0 | 0 |
| 20 | Cápsulas de espirulina 120 uds | 55 | false | 0 | 0 |
3 filas, los mismos tres productos nunca vendidos que encontraste con el anti-join de 03-03, ahora con el diagnóstico al lado: sin stock, con problema comercial, descatalogado. (El HAVING es la lección siguiente; aquí aparece de pasada porque es la forma natural de filtrar por un recuento.)
La regla que hay que llevarse: un
INNER JOINresponde a "¿cuánto ha vendido cada categoría que ha vendido algo?"; unLEFT JOINresponde a "¿cuánto ha vendido cada categoría?". La segunda es casi siempre la pregunta del negocio, porque un cero también es información: le dice a la dirección que Complementos no está funcionando. Un informe que oculta los ceros oculta precisamente los problemas.
- Ordenar por el agregado y quedarse con el top N
ORDER BY se ejecuta después del SELECT (paso 6 del diagrama), así que puede ordenar por un agregado o por su alias sin problema:
SELECT p.id,
p.nombre AS producto,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM lineas_pedido AS lp
JOIN productos AS p ON lp.producto_id = p.id
GROUP BY p.id, p.nombre
ORDER BY facturacion DESC, p.id
LIMIT 5;| id | producto | facturacion |
|---|---|---|
| 1 | Aceite de oliva virgen extra 500 ml | 109.53 |
| 15 | Té verde matcha ceremonial 30 g | 88.00 |
| 6 | Crema facial de aloe vera 50 ml | 70.42 |
| 16 | Kombucha de jengibre 750 ml | 56.43 |
| 2 | Arroz integral ecológico 1 kg | 54.60 |
El top 5 de productos por facturación de TiendaVerde. El aceite de oliva es el producto estrella con 109,53 €, el 15 % de toda la facturación.
Fíjate en dos cosas:
- El
ORDER BYtermina conp.id, una columna única, como manda la convención del curso desde 02-05. Sin ella, dos productos con la misma facturación podrían salir en orden distinto en cada ejecución y la paginación sería inestable. - También se puede ordenar por un agregado que no está en el
SELECT:ORDER BY SUM(lp.cantidad) DESCes perfectamente legal aunque no muestres las unidades. Es legal y a veces confuso, así que úsalo con cuidado.
La combinación GROUP BY + ORDER BY agregado DESC + LIMIT n es el patrón top N, y es probablemente la consulta analítica más pedida que existe: los 10 clientes que más compran, los 5 productos que menos rotan, los 3 comerciales con más ventas.
Nota: si lo que quieres es un top N dentro de cada grupo —"los 3 productos más vendidos de cada categoría"—,
GROUP BYyLIMITno bastan: hace falta una función de ventana (ROW_NUMBER() OVER (PARTITION BY ...)), y eso es el módulo 10.
ROLLUP, GROUPING SETS y CUBE
ROLLUP, GROUPING SETS y CUBEUn informe real casi siempre necesita subtotales y un total general junto al detalle. Escribir eso con UNION ALL de varias consultas es tedioso y lento, porque obliga a recorrer la tabla varias veces. SQL ofrece tres extensiones del GROUP BY para resolverlo en una sola pasada:
| Construcción | Qué añade |
|---|---|
ROLLUP (a, b) |
Los grupos (a,b), más los subtotales por a, más el total general. Jerárquico |
CUBE (a, b) |
Todas las combinaciones: (a,b), (a), (b) y el total general |
GROUPING SETS ((a,b), (a), ()) |
Exactamente los conjuntos que tú enumeres. Es la forma general; ROLLUP y CUBE son atajos |
Un ejemplo mínimo con ROLLUP, sobre las ventas por categoría y año de la sección 5:
SELECT cat.nombre AS categoria,
EXTRACT(YEAR FROM pe.fecha_pedido) AS anio,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id
JOIN productos AS p ON lp.producto_id = p.id
JOIN categorias AS cat ON p.categoria_id = cat.id
GROUP BY ROLLUP (cat.nombre, EXTRACT(YEAR FROM pe.fecha_pedido))
ORDER BY categoria NULLS LAST, anio NULLS LAST;| categoria | anio | facturacion |
|---|---|---|
| Alimentación | 2025 | 215.67 |
| Alimentación | 2026 | 40.60 |
| Alimentación | (null) | 256.27 |
| Bebidas | 2025 | 146.55 |
| Bebidas | 2026 | 48.73 |
| Bebidas | (null) | 195.28 |
| Cosmética natural | 2025 | 128.22 |
| Cosmética natural | 2026 | 28.10 |
| Cosmética natural | (null) | 156.32 |
| Higiene personal | 2025 | 24.50 |
| Higiene personal | 2026 | 7.00 |
| Higiene personal | (null) | 31.50 |
| Hogar sostenible | 2025 | 88.58 |
| Hogar sostenible | (null) | 88.58 |
| (null) | (null) | 727.95 |
15 filas: las 9 combinaciones reales, 5 subtotales por categoría y el total general de 727,95 € en la última. Las filas de subtotal se reconocen porque las columnas agrupadas de nivel inferior valen NULL.
Y ahí está la única complicación de ROLLUP: esos NULL de subtotal son indistinguibles de un NULL de dato. La función GROUPING(columna) sirve para diferenciarlos (devuelve 1 si la fila es un subtotal por esa columna), y combinada con CASE (06-05) permite etiquetar las filas como "Total categoría" o "TOTAL GENERAL".
No profundizamos más: ROLLUP y compañía se usan mucho en informes de dirección y en herramientas de business intelligence, pero su lugar natural es después de dominar CASE y las subconsultas.
Nota de dialecto:
ROLLUP,CUBEyGROUPING SETSexisten en PostgreSQL 9.5+, SQL Server y Oracle. MySQL solo tieneGROUP BY ... WITH ROLLUP(sintaxis distinta y sinCUBEniGROUPING SETS). SQLite no tiene ninguno: hay que emularlos conUNION ALL.
Errores Comunes y Consejos
- Poner en el
SELECTuna columna que no está agrupada ni agregada.column ... must appear in the GROUP BY clause. Es el error más frecuente de la lección, y en MySQL permisivo o en SQLite no da error: devuelve un valor arbitrario. - Usar
COUNT(*)en unGROUP BYsobre unLEFT JOIN. Devuelve 1 donde debería devolver 0. Cuenta una columna de la tabla derecha:COUNT(lp.id). - Esperar que aparezcan los grupos vacíos con un
INNER JOIN. La categoría 6 no sale. Si el informe debe listar todas las categorías, empieza porcategoriasconLEFT JOIN. - Meter un
INNER JOINdespués delLEFT JOINen la misma cadena. Anula elLEFTy los grupos vacíos vuelven a desaparecer (03-03). - Sumar una columna de cabecera tras unir con el detalle. Sigue siendo el error de 04-04: los portes de Lucía se contarían nueve veces.
- Contar pedidos con
COUNT(*)en una consulta que parte delineas_pedido. Cuenta líneas. UsaCOUNT(DISTINCT pe.id). - Usar un alias del
SELECTen elHAVING.column "..." does not existen PostgreSQL. Repite el agregado. - Dar por hecho que
GROUP BYgenera todas las combinaciones posibles. Solo genera las que existen en los datos: 8 filas de país × estado, no 15. - Ordenar tramos alfabéticamente sin prefijo numérico. "de 10 a 15 €" va antes que "de 5 a 10 €". Numera las etiquetas.
- Olvidar la columna única al final del
ORDER BY. Con empates, el orden deja de ser determinista. - Consejo: escribe primero la consulta sin agregar y mira las filas. Si el detalle no es el que esperas, el agregado que le pongas encima estará mal aunque compile.
- Consejo: valida que los grupos sumen el total. 256,27 + 195,28 + 156,32 + 88,58 + 31,50 = 727,95 €. Si no cuadra con la cifra global, hay filas perdidas o duplicadas.
- Consejo: cuenta los grupos que esperas antes de ejecutar. Si agrupas por
estadodeben salir como mucho 5 filas (el dominio delCHECK); si salen 6, hay un valor inesperado en los datos.
Ejercicios
Ejercicio 1
Logística quiere un informe de actividad por comercial. Escribe una consulta sobre pedidos y empleados que devuelva, para los 8 empleados (aparezcan o no en pedidos): su id, nombre completo, puesto, número de pedidos gestionados y suma de gastos de envío de esos pedidos.
Ordena por número de pedidos descendente. Después responde:
- ¿Cuántas filas devuelve y por qué?
- ¿Qué habría pasado con un
INNER JOIN? - ¿Por qué no puede aparecer aquí el canal web?
Ejercicio 2
Marketing quiere segmentar el catálogo por proveedor. Escribe una consulta que devuelva, para cada proveedor: su nombre, su país, si está activo, cuántos productos suministra, el precio medio de esos productos (dos decimales) y las unidades vendidas de todos ellos.
Debe aparecer también el proveedor inactivo. Después responde: ¿cuántas unidades ha vendido EcoNordic Supplies pese a estar inactivo, y qué te dice eso del negocio?
Ejercicio 3
Dirección quiere el informe de ventas por mes de todo el histórico, con estas columnas: mes (en formato AAAA-MM), número de pedidos distintos, número de líneas, unidades y facturación.
- Escríbelo. (Pista: puedes agrupar por la expresión
TO_CHAR(pe.fecha_pedido, 'YYYY-MM'), una función de cadena que verás a fondo en 06-03.) - Identifica el mejor mes y el peor.
- Explica por qué la suma de las facturaciones mensuales debe dar 727,95 € y compruébalo.
Soluciones
Solución 1
SELECT e.id,
e.nombre || ' ' || e.apellidos AS empleado,
e.puesto,
COUNT(pe.id) AS pedidos,
COALESCE(SUM(pe.gastos_envio), 0.00) AS portes
FROM empleados AS e
LEFT JOIN pedidos AS pe ON pe.empleado_id = e.id
GROUP BY e.id, e.nombre, e.apellidos, e.puesto
ORDER BY pedidos DESC, e.id;| id | empleado | puesto | pedidos | portes |
|---|---|---|---|---|
| 4 | Óscar Peris Blasco | Comercial | 4 | 22.40 |
| 5 | Laia Puig Sanchis | Comercial | 4 | 32.30 |
| 6 | Marc Estévez Roig | Atención al cliente | 2 | 17.45 |
| 1 | Rosa Alcázar Vives | Directora general | 0 | 0.00 |
| 2 | Andrés Company Talens | Responsable de ventas | 0 | 0.00 |
| 3 | Beatriz Nadal Ripoll | Responsable de logística | 0 | 0.00 |
| 7 | Irene Salvador Mira | Operaria de almacén | 0 | 0.00 |
| 8 | Daniel Vercher Lluch | Analista de datos | 0 | 0.00 |
1. 8 filas, los 8 empleados. El LEFT JOIN desde empleados conserva a los cinco que nunca han gestionado un pedido, y COUNT(pe.id) les da un 0 correcto (con COUNT(*) habrían salido con 1). COALESCE convierte el NULL de SUM en un 0.00 presentable.
2. Con INNER JOIN saldrían 3 filas: solo Óscar, Laia y Marc. Desaparecerían Rosa, Andrés, Beatriz, Irene y Daniel — que es exactamente lo que ocurría en 03-04, donde los conociste como "los empleados que nunca han gestionado un pedido". Un informe de recursos humanos con 3 de 8 personas no es un informe.
3. El canal web no puede aparecer porque estos 10 pedidos tienen empleado_id a NULL y no casan con ninguna fila de empleados. Al partir de empleados con LEFT JOIN, esas filas quedan del lado derecho sin pareja y se descartan: la suma de la columna pedidos da 10, no 20. Para verlos habría que partir de pedidos (GROUP BY empleado_id, sección 8) o usar un FULL OUTER JOIN (03-05). Es la asimetría del LEFT JOIN en estado puro: decide qué lado sale entero, y el otro pierde a sus huérfanos.
Solución 2
SELECT pr.id,
pr.nombre AS proveedor,
pr.pais,
pr.activo,
COUNT(DISTINCT p.id) AS productos,
ROUND(AVG(p.precio), 2) AS precio_medio,
COALESCE(SUM(lp.cantidad), 0) AS unidades_vendidas
FROM proveedores AS pr
LEFT JOIN productos AS p ON p.proveedor_id = pr.id
LEFT JOIN lineas_pedido AS lp ON lp.producto_id = p.id
GROUP BY pr.id, pr.nombre, pr.pais, pr.activo
ORDER BY unidades_vendidas DESC, pr.id;| id | proveedor | pais | activo | productos | precio_medio | unidades_vendidas |
|---|---|---|---|---|---|---|
| 1 | Huerta del Turia | España | true | 5 | 6.73 | 53 |
| 2 | BioSierra Ibérica | España | true | 3 | 5.58 | 21 |
| 4 | Maison Nature | Francia | true | 4 | 10.57 | 14 |
| 3 | Verde Atlántico | Portugal | true | 4 | 13.52 | 13 |
| 5 | EcoNordic Supplies | Alemania | false | 4 | 9.01 | 12 |
5 filas, los cinco proveedores, con los 20 productos repartidos: 5 + 3 + 4 + 4 + 4 = 20. Dos detalles importantes de esta consulta:
COUNT(DISTINCT p.id)y noCOUNT(p.id). Tras el segundoLEFT JOIN, cada producto aparece tantas veces como veces se haya vendido: el aceite estaría cinco veces.COUNT(p.id)daría 16 para Huerta del Turia en lugar de 5. Es la multiplicación de filas de 03-02, yDISTINCTes la corrección.AVG(p.precio)también está afectada, y esa sí no tiene arreglo conDISTINCT. El precio medio de Huerta del Turia sale 6,73 €, no la media simple de sus cinco productos (que es 5,74 €), porque la media queda ponderada por el número de veces que se ha vendido cada referencia: el aceite de 12,50 € entra cinco veces y el tomate de 1,95 € solo dos. Es una media legítima —"precio medio de lo que se factura"— pero no es la que pedía el enunciado. Para el precio medio de catálogo hay que calcularlo en una consulta aparte sobreproductos, o con una subconsulta (módulo 7). Es exactamente la trampa de granularidad de 04-04, ahora sobre una media en lugar de una suma, y es más insidiosa porque el número resultante parece plausible.
EcoNordic Supplies ha vendido 12 unidades pese a estar inactivo, porque proveedores.activo = false significa "ya no le compramos", no "sus productos desaparecen del catálogo". Sus cuatro referencias son el detergente (10), las velas (13), el cepillo de bambú (18) y la espirulina (20); dos de ellas nunca se han vendido, pero el cepillo de bambú ha colocado 9 unidades y el detergente 3. El dato accionable es claro: hay dos productos que se venden con normalidad y cuyo proveedor ya no está operativo. Es un problema de aprovisionamiento a la vuelta de la esquina, y esta consulta es exactamente la que lo detecta.
Solución 3
SELECT TO_CHAR(pe.fecha_pedido, 'YYYY-MM') AS mes,
COUNT(DISTINCT pe.id) AS pedidos,
COUNT(*) AS lineas,
SUM(lp.cantidad) AS unidades,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id
GROUP BY TO_CHAR(pe.fecha_pedido, 'YYYY-MM')
ORDER BY mes;| mes | pedidos | lineas | unidades | facturacion |
|---|---|---|---|---|
| 2025-03 | 2 | 5 | 10 | 68.80 |
| 2025-04 | 2 | 5 | 14 | 61.28 |
| 2025-05 | 2 | 5 | 6 | 58.85 |
| 2025-06 | 2 | 5 | 14 | 95.48 |
| 2025-07 | 1 | 3 | 9 | 44.60 |
| 2025-08 | 1 | 2 | 3 | 48.27 |
| 2025-09 | 1 | 2 | 13 | 32.76 |
| 2025-10 | 2 | 5 | 13 | 97.20 |
| 2025-11 | 1 | 3 | 6 | 31.70 |
| 2025-12 | 2 | 5 | 7 | 64.58 |
| 2026-01 | 2 | 4 | 6 | 75.10 |
| 2026-02 | 2 | 3 | 12 | 49.33 |
2. El mejor mes es octubre de 2025 con 97,20 €, seguido muy de cerca por junio de 2025 con 95,48 €. El peor es noviembre de 2025 con 31,70 €, un mes con un solo pedido. Con volúmenes tan pequeños, un mes bueno o malo depende de si cayó un pedido grande, y eso es una lección de análisis: con 20 pedidos no se puede hablar de estacionalidad.
3. La suma debe dar 727,95 € porque cada una de las 47 líneas pertenece exactamente a un pedido, cada pedido tiene exactamente una fecha, y cada fecha pertenece exactamente a un mes. Los grupos son una partición del conjunto de líneas: no se solapan y no dejan nada fuera. Comprobación:
68.80 + 61.28 + 58.85 + 95.48 + 44.60 + 48.27
+ 32.76 + 97.20 + 31.70 + 64.58 + 75.10 + 49.33 = 727.95Y el recuento de líneas: 5+5+5+5+3+2+2+5+3+5+4+3 = 47. Ambas cuadran.
Esta comprobación —¿los grupos suman el total?— es la mejor validación que existe para una consulta agregada, y merece convertirse en un reflejo. Si no cuadra, o has perdido filas (un INNER JOIN que descarta) o las has duplicado (un JOIN que multiplica).
Conclusión
GROUP BY es la cláusula que convierte SQL en análisis:
- Parte las filas en grupos y aplica el agregado a cada uno, devolviendo una fila por cada combinación distinta que exista en los datos — nunca por combinaciones que no existan.
- El orden lógico de ejecución queda completo:
FROM/JOIN→WHERE(filas) →GROUP BY(grupos) →HAVING(grupos) →SELECT(alias) →DISTINCT→ORDER BY→LIMIT. De ahí sale queWHEREno pueda usar agregados yHAVINGsí. - La regla de oro: toda columna del
SELECTestá agrupada o agregada. PostgreSQL, SQL Server y Oracle lo exigen; MySQL conONLY_FULL_GROUP_BYdesactivado y SQLite devuelven un valor arbitrario, y eso es una trampa, no una comodidad. - Puedes agrupar por una columna, por varias (una fila por combinación existente) y por una expresión (
EXTRACT,CASE,TO_CHAR), repitiéndola íntegra en elGROUP BY. - PostgreSQL acepta un alias del
SELECTen elGROUP BYcomo extensión, pero solo desnudo, con la columna real ganando en caso de ambigüedad, y nunca enHAVING. Repetir la expresión es siempre más seguro. - Los
NULLforman su propio grupo: los 10 pedidos del canal web y los 7 clientes espontáneos aparecen como una fila con*(null)*, y esa fila es información valiosa. GROUP BYconJOINes el patrón central del análisis. Tienes ya las cifras clave de TiendaVerde: facturación por categoría (Alimentación 256,27 € · Bebidas 195,28 € · Cosmética 156,32 € · Hogar 88,58 € · Higiene 31,50 €), ranking de clientes (Sofia 111,88 € · Lucía 107,60 €) y top de productos (aceite 109,53 € · matcha 88,00 €).- Los grupos vacíos no existen para un
INNER JOIN. La categoría 6 solo aparece con unLEFT JOINdesdecategoriasyCOUNT(lp.id)en lugar deCOUNT(*), que es lo que la convierte en un0honesto en vez de un falso1. - El patrón top N es
GROUP BY+ORDER BY agregado DESC+LIMIT; el top N por grupo necesita funciones de ventana (módulo 10), igual que cualquier cálculo que deba agregar sin colapsar las filas. ROLLUP,CUBEyGROUPING SETSañaden subtotales y totales en una sola pasada: elROLLUPde categoría y año dio 15 filas con los 5 subtotales y el total general de 727,95 €.
En la última lección del módulo, la cláusula HAVING, cerrarás el círculo. Ya sabes formar grupos; ahora aprenderás a filtrarlos: categorías con más de N productos, clientes con más de un pedido, productos que superan cierto volumen. Verás por qué HAVING puede usar agregados y WHERE no —el diagrama de la sección 2 te lo dirá solo—, por qué WHERE es siempre preferible cuando la condición se puede evaluar fila a fila, y tendrás por fin la tabla que compara los tres sitios donde se puede filtrar en SQL: ON, WHERE y HAVING, cerrando el hilo que 03-03 dejó abierto.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
