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

  1. GROUP BY: partir en grupos y agregar cada uno
  2. El orden lógico de ejecución, ampliado
  3. La regla de oro: agrupada o agregada
  4. Agrupar por una columna
  5. Agrupar por varias columnas
  6. Agrupar por una expresión
  7. GROUP BY y el alias del SELECT
  8. Grupos y NULL
  9. GROUP BY con JOIN: el patrón central del análisis
  10. Grupos vacíos: por qué la categoría 6 no aparece
  11. Ordenar por el agregado y quedarse con el top N
  12. ROLLUP, GROUPING SETS y CUBE
  13. Errores Comunes y Consejos
  14. Ejercicios
  15. Conclusión

  1. GROUP BY: partir en grupos y agregar cada uno

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

SELECT estado,
       COUNT(*) AS pedidos
FROM pedidos
GROUP BY estado
ORDER BY pedidos DESC, estado;
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.

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

  1. La regla de oro: agrupada o agregada

Es una sola frase, y de ella se deduce todo lo demás:

Toda expresión del SELECT debe estar (a) en el GROUP 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_BY desactivado Un valor arbitrario de cualquier fila del grupo
SQLite , 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_BY está activo y no lo desactives.

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

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

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

  1. La expresión aparece dos veces, idéntica, en el SELECT y en el GROUP BY. Es fea y es necesaria, por el motivo de siempre: el alias rango_precio todavía no existe cuando se ejecuta el GROUP 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.
  2. El prefijo numérico de las etiquetas (1 · , 2 · …) no es decorativo. ORDER BY rango_precio ordena 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.

  1. GROUP BY y el alias del SELECT

Este 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;
ERROR:  column "precio_entero" does not exist
LINE 3: 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;
ERROR:  column "productos" does not exist
LINE 4: 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 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. en MySQL y SQLite
ORDER BY 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 el ORDER BY, donde son estándar y no tienen sorpresas.

  1. Grupos y NULL

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

  1. GROUP BY con JOIN: el patrón central del análisis

Llegamos 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, y DISTINCT es la forma de deshacerla al contar.
  • Todas las columnas no agregadas están en el GROUP BY. Como c.id es la PK de clientes, PostgreSQL nos permitiría escribir solo GROUP 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.

  1. 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 JOIN responde a "¿cuánto ha vendido cada categoría que ha vendido algo?"; un LEFT JOIN responde 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.

  1. 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 BY termina con p.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) DESC es 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 BY y LIMIT no bastan: hace falta una función de ventana (ROW_NUMBER() OVER (PARTITION BY ...)), y eso es el módulo 10.

  1. ROLLUP, GROUPING SETS y CUBE

Un 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, CUBE y GROUPING SETS existen en PostgreSQL 9.5+, SQL Server y Oracle. MySQL solo tiene GROUP BY ... WITH ROLLUP (sintaxis distinta y sin CUBE ni GROUPING SETS). SQLite no tiene ninguno: hay que emularlos con UNION ALL.

Errores Comunes y Consejos

  • Poner en el SELECT una 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 un GROUP BY sobre un LEFT 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 por categorias con LEFT JOIN.
  • Meter un INNER JOIN después del LEFT JOIN en la misma cadena. Anula el LEFT y 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 de lineas_pedido. Cuenta líneas. Usa COUNT(DISTINCT pe.id).
  • Usar un alias del SELECT en el HAVING. column "..." does not exist en PostgreSQL. Repite el agregado.
  • Dar por hecho que GROUP BY genera 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 estado deben salir como mucho 5 filas (el dominio del CHECK); 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:

  1. ¿Cuántas filas devuelve y por qué?
  2. ¿Qué habría pasado con un INNER JOIN?
  3. ¿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.

  1. 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.)
  2. Identifica el mejor mes y el peor.
  3. 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 no COUNT(p.id). Tras el segundo LEFT 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, y DISTINCT es la corrección.
  • AVG(p.precio) también está afectada, y esa sí no tiene arreglo con DISTINCT. 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 sobre productos, 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.95

Y 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/JOINWHERE (filas) → GROUP BY (grupos) → HAVING (grupos) → SELECT (alias) → DISTINCTORDER BYLIMIT. De ahí sale que WHERE no pueda usar agregados y HAVING sí.
  • La regla de oro: toda columna del SELECT está agrupada o agregada. PostgreSQL, SQL Server y Oracle lo exigen; MySQL con ONLY_FULL_GROUP_BY desactivado 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 el GROUP BY.
  • PostgreSQL acepta un alias del SELECT en el GROUP BY como extensión, pero solo desnudo, con la columna real ganando en caso de ambigüedad, y nunca en HAVING. Repetir la expresión es siempre más seguro.
  • Los NULL forman 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 BY con JOIN es 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 un LEFT JOIN desde categorias y COUNT(lp.id) en lugar de COUNT(*), que es lo que la convierte en un 0 honesto en vez de un falso 1.
  • 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, CUBE y GROUPING SETS añaden subtotales y totales en una sola pasada: el ROLLUP de 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

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

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

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados