Agrupar y resumir en SQL: GROUP BY y HAVING

Lección 3 de 8. Contar, sumar y sacar medias: las funciones de agregado convierten muchas filas en un número, y GROUP BY lo hace por grupos, para tener las ventas por mes o los clientes por país. Además, el orden en que el motor ejecuta de verdad una consulta, que explica casi todos los errores con WHERE y HAVING.

Funciones de agregado

Hasta ahora cada fila del resultado salía de una fila de la tabla. Las funciones de agregado hacen lo contrario: toman muchas filas y devuelven un solo valor. Las cinco básicas son COUNT (cuántas), SUM (suma), AVG (media), MIN y MAX:

SELECT COUNT(*)              AS productos,
       SUM(stock)            AS unidades,
       ROUND(AVG(precio), 2) AS precio_medio,
       MIN(precio)           AS mas_barato,
       MAX(precio)           AS mas_caro
FROM productos;

Ábrela en el editor y cámbiala →

resultado+-----------+----------+--------------+------------+----------+
| productos | unidades | precio_medio | mas_barato | mas_caro |
+-----------+----------+--------------+------------+----------+
| 15        | 551      | 307.11       | 9.99       | 1849     |
+-----------+----------+--------------+------------+----------+

COUNT(*) cuenta filas. COUNT(columna), en cambio, cuenta los valores que no son NULL de esa columna, y la diferencia importa: hay 12 clientes, pero uno no tiene correo y otro no tiene ciudad.

SELECT COUNT(*)               AS clientes,
       COUNT(email)           AS con_email,
       COUNT(ciudad)          AS con_ciudad,
       COUNT(DISTINCT pais)   AS paises
FROM clientes;

Ábrela en el editor y cámbiala →

resultado+----------+-----------+------------+--------+
| clientes | con_email | con_ciudad | paises |
+----------+-----------+------------+--------+
| 12       | 11        | 11         | 6      |
+----------+-----------+------------+--------+

Lo mismo hacen SUM, AVG, MIN y MAX: se saltan los NULL. Casi siempre es lo que quieres, pero no siempre: una media que ignora los que faltan puede engañar, y está en las rarezas.

GROUP BY: un resumen por grupo

Un total para toda la tabla sirve de poco. Lo interesante es el total por algo: clientes por país, ventas por mes, unidades por producto. GROUP BY reparte las filas en grupos que comparten el valor de una o varias columnas, y las funciones de agregado se calculan una vez por grupo:

SELECT pais, COUNT(*) AS clientes
FROM clientes
GROUP BY pais
ORDER BY clientes DESC, pais;

Ábrela en el editor y cámbiala →

resultado+-----------+----------+
|   pais    | clientes |
+-----------+----------+
| México    | 4        |
| España    | 3        |
| Chile     | 2        |
| Argentina | 1        |
| Colombia  | 1        |
| Perú      | 1        |
+-----------+----------+

Ocho clientes se reparten en grupos por país: México con cuatro, España con tres y Colombia con uno; COUNT de cada grupo da una fila por paísGROUP BY junta las filas que comparten valor y resume cada montónclientes (8 de 12)nombrepaisAna TorresMéxicoLuis FernándezEspañaCamila RojasColombiaDiego MartínezMéxicoJorge RamírezMéxicoLucía GómezEspañaPaula MoralesEspañaAndrés NavarroMéxicoGROUP BYMéxicoAnaDiegoJorgeAndrésMéxico | 4EspañaLuisLucíaPaulaEspaña | 3ColombiaCamilaColombia | 1COUNT(*)

GROUP BY pais deja una fila por país; COUNT(*) cuenta las filas de cada grupo. Esquema: decodigo.com.

La regla de oro: en un SELECT con GROUP BY, cada columna tiene que estar en el GROUP BY o dentro de una función de agregado. Si pidieras también nombre, ¿cuál de los cuatro nombres de México tendría que salir? SQLite no se queja y elige uno cualquiera; PostgreSQL da un error. Ese caso está en las rarezas.

La tabla detalle tiene una línea por producto de cada pedido. Agrupando por pedido se obtiene el importe de cada uno:

SELECT pedido_id,
       COUNT(*)                            AS lineas,
       SUM(cantidad)                       AS unidades,
       ROUND(SUM(cantidad * precio_unitario), 2) AS importe
FROM detalle
GROUP BY pedido_id
ORDER BY importe DESC
LIMIT 5;

Ábrela en el editor y cámbiala →

resultado+-----------+--------+----------+---------+
| pedido_id | lineas | unidades | importe |
+-----------+--------+----------+---------+
| 8         | 3      | 6        | 3638.78 |
| 18        | 2      | 2        | 2228.9  |
| 5         | 2      | 2        | 2008.0  |
| 16        | 2      | 2        | 1388.99 |
| 1         | 2      | 2        | 1324.98 |
+-----------+--------+----------+---------+

El ROUND no es decorativo. Los precios con decimales se guardan como números en coma flotante, que no representan exactamente todos los decimales, y una suma puede acabar en algo como 0.30000000000000004. En SQLite, para dinero de verdad conviene guardar los importes en centavos, como enteros; PostgreSQL y MySQL tienen además un tipo decimal exacto, NUMERIC. En un ejemplo para aprender, redondear el resultado basta.

HAVING: filtrar grupos

WHERE filtra filas antes de agrupar, así que no puede usar el resultado de un agregado. Para quedarse solo con algunos grupos está HAVING, que se aplica después:

-- Pedidos de más de 1,000 USD
SELECT pedido_id, ROUND(SUM(cantidad * precio_unitario), 2) AS importe
FROM detalle
GROUP BY pedido_id
HAVING SUM(cantidad * precio_unitario) > 1000
ORDER BY importe DESC;

Ábrela en el editor y cámbiala →

resultado+-----------+---------+
| pedido_id | importe |
+-----------+---------+
| 8         | 3638.78 |
| 18        | 2228.9  |
| 5         | 2008.0  |
| 16        | 1388.99 |
| 1         | 1324.98 |
+-----------+---------+

Si intentas poner el agregado en el WHERE, SQLite te lo impide:

SELECT pedido_id, SUM(cantidad * precio_unitario) AS importe
FROM detalle
WHERE SUM(cantidad * precio_unitario) > 1000
GROUP BY pedido_id;

Ábrela en el editor y cámbiala →

resultadoError: misuse of aggregate: SUM()

Las dos cláusulas se combinan bien. Aquí, WHERE se queda con las líneas de los productos que cuestan menos de 100 USD, y HAVING, con los productos de los que se vendieron al menos cuatro unidades:

SELECT producto_id, SUM(cantidad) AS unidades
FROM detalle
WHERE precio_unitario < 100
GROUP BY producto_id
HAVING SUM(cantidad) >= 4
ORDER BY unidades DESC, producto_id;

Ábrela en el editor y cámbiala →

resultado+-------------+----------+
| producto_id | unidades |
+-------------+----------+
| 10          | 12       |
| 6           | 7        |
| 8           | 7        |
| 5           | 5        |
| 12          | 5        |
+-------------+----------+

El orden en que se ejecuta una consulta

Casi todos los errores con WHERE, HAVING y los alias se entienden de golpe con una idea: las cláusulas se escriben en un orden, pero el motor las ejecuta en otro. Empieza por FROM, filtra con WHERE, agrupa, filtra grupos con HAVING, y solo entonces calcula el SELECT.

A la izquierda, las cláusulas en el orden en que se escriben, empezando por SELECT; a la derecha, el orden en que las ejecuta el motor: FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY y LIMITSe escribe en un orden y se ejecuta en otroComo se escribeComo lo ejecuta el motorSELECTFROMWHEREGROUP BYHAVINGORDER BYLIMIT1. FROMde qué tabla salen las filas2. WHEREfiltra filas sueltas3. GROUP BYforma los grupos4. HAVINGfiltra grupos5. SELECTcalcula las columnas y los alias6. ORDER BYordena (ya ve los alias)7. LIMITcorta el resultado

El SELECT se escribe primero pero se calcula casi al final. Por eso WHERE no ve los agregados, y por eso un alias definido en el SELECT no debería poder usarse en el WHERE. Esquema: decodigo.com.

Por eso, según el estándar, un alias del SELECT no existe todavía cuando se evalúa el WHERE, y en cambio sí se puede usar en ORDER BY, que va después. SQLite es permisivo y deja usar el alias en el WHERE:

SELECT nombre, ROUND(precio * 1.16, 2) AS con_iva
FROM productos
WHERE con_iva > 400;

Ábrela en el editor y cámbiala →

resultado+------------------+---------+
|      nombre      | con_iva |
+------------------+---------+
| Portátil Aero 14 | 1507.99 |
| Portátil Nova 16 | 2144.84 |
| Monitor 27" 4K   | 498.68  |
+------------------+---------+

En PostgreSQL y MySQL

Los otros dos motores siguen el estándar y no encuentran la columna. PostgreSQL:

SELECT nombre, ROUND(precio * 1.16, 2) AS con_iva
FROM productos
WHERE con_iva > 400;
PostgreSQL 17ERROR:  column "con_iva" does not exist
LINE 3: WHERE con_iva > 400;
              ^

MySQL:

MySQL 8.4ERROR 1054 (42S22) at line 1: Unknown column 'con_iva' in 'where clause'

Si quieres que tu SQL funcione en cualquier motor, repite la expresión en el WHERE o usa una subconsulta, que veremos en la lección 5.

CASE: clasificar sobre la marcha

CASE es el «si… entonces» de SQL: devuelve un valor u otro según una condición, y se puede usar en cualquier sitio donde iría una columna. Sirve para clasificar, y combinado con GROUP BY, para contar cuántos hay de cada clase:

SELECT CASE
         WHEN precio < 50   THEN '1. menos de 50'
         WHEN precio < 200  THEN '2. de 50 a 200'
         ELSE                    '3. 200 o más'
       END AS gama,
       COUNT(*) AS productos
FROM productos
GROUP BY gama
ORDER BY gama;

Ábrela en el editor y cámbiala →

resultado+----------------+-----------+
|      gama      | productos |
+----------------+-----------+
| 1. menos de 50 | 5         |
| 2. de 50 a 200 | 6         |
| 3. 200 o más   | 4         |
+----------------+-----------+

Las condiciones se prueban en orden y gana la primera que se cumple, por eso la segunda no necesita decir precio >= 50. El número delante de cada gama es un truco para que ORDER BY las deje en su orden natural y no en el alfabético. Con CASE dentro de un SUM o un COUNT también se pueden convertir filas en columnas, que es lo que hace el ejemplo de tabla dinámica.

Agrupar por fechas

SQLite no tiene un tipo de fecha: las guarda como texto y las trabaja con funciones como strftime. Con ella se saca el año y el mes de cada pedido para agruparlos:

SELECT strftime('%Y-%m', fecha) AS mes,
       COUNT(*)                 AS pedidos
FROM pedidos
WHERE estado <> 'cancelado'
GROUP BY mes
ORDER BY mes;

Ábrela en el editor y cámbiala →

resultado+---------+---------+
|   mes   | pedidos |
+---------+---------+
| 2026-01 | 3       |
| 2026-02 | 2       |
| 2026-03 | 3       |
| 2026-04 | 2       |
| 2026-05 | 2       |
| 2026-06 | 1       |
| 2026-07 | 2       |
| 2026-08 | 2       |
| 2026-09 | 2       |
| 2026-10 | 2       |
+---------+---------+

Cada motor tiene sus funciones de fecha (en PostgreSQL, date_trunc y to_char; en MySQL, DATE_FORMAT), y es de las partes de SQL que menos se parecen entre motores. Están comparadas en el ejemplo de fechas y periodos.

Resumen

ParaEscribeRecuerda
Contar filasCOUNT(*)COUNT(columna) no cuenta los NULL
Sumar, media, mínimo, máximoSUM, AVG, MIN, MAXtodos se saltan los NULL
Un resumen por grupoGROUP BY paiscada columna del SELECT: en el GROUP BY o en un agregado
Filtrar filasWHEREantes de agrupar: no ve los agregados
Filtrar gruposHAVING SUM(...) > 1000después de agrupar
ClasificarCASE WHEN ... THEN ... ELSE ... ENDgana la primera condición que se cumple

Retos

Reto 1. ¿Cuántos pedidos hay en cada estado? Ordénalos del estado con más pedidos al que menos.

Ver la solución
SELECT estado, COUNT(*) AS pedidos
FROM pedidos
GROUP BY estado
ORDER BY pedidos DESC, estado;

Ábrela en el editor y cámbiala →

resultado+-----------+---------+
|  estado   | pedidos |
+-----------+---------+
| entregado | 16      |
| enviado   | 2       |
| pendiente | 2       |
| cancelado | 1       |
| devuelto  | 1       |
+-----------+---------+

Reto 2. ¿Qué clientes han hecho más de dos pedidos? Muestra su cliente_id y cuántos pedidos tienen.

Ver la solución
SELECT cliente_id, COUNT(*) AS pedidos
FROM pedidos
GROUP BY cliente_id
HAVING COUNT(*) > 2
ORDER BY pedidos DESC;

Ábrela en el editor y cámbiala →

resultado+------------+---------+
| cliente_id | pedidos |
+------------+---------+
| 1          | 4       |
| 2          | 3       |
+------------+---------+

Para ver el nombre en vez del número hay que unir pedidos con clientes: es justo lo que empieza en la lección 4.

Reto 3. Calcula el valor del inventario de cada categoría (precio * stock), con los productos sin categoría en su propio grupo. ¿Qué pasa con ellos?

Ver la solución
SELECT categoria_id,
       COUNT(*)                     AS productos,
       ROUND(SUM(precio * stock), 2) AS valor_inventario
FROM productos
GROUP BY categoria_id
ORDER BY valor_inventario DESC;

Ábrela en el editor y cámbiala →

resultado+--------------+-----------+------------------+
| categoria_id | productos | valor_inventario |
+--------------+-----------+------------------+
| 1            | 2         | 15946.92         |
| 2            | 2         | 8338.8           |
| 4            | 3         | 7031.0           |
| 3            | 4         | 5882.9           |
| NULL         | 2         | 2723.0           |
| 5            | 2         | 2212.2           |
+--------------+-----------+------------------+

GROUP BY trata todos los NULL como un mismo grupo: la base para portátil y el cable HDMI, que no tienen categoría, salen juntos en la fila de NULL.

Todas las consultas de esta lección se ejecutaron en SQLite 3.46.1 sobre la base de ejemplo «tienda» antes de publicarla, y los resultados están copiados de esa ejecución; las diferencias con PostgreSQL 17 y MySQL 8.4 se comprobaron en esos motores. ¿Quieres probar algo? Usa el editor de SQL, que ya trae la base cargada.