GROUP BY y HAVING en SQL
En esta lección
Con las funciones de agregación calculas un total de toda la tabla: cuántos clientes hay, cuánto se ha vendido. Pero casi siempre la pregunta real es por algo: clientes por ciudad, ventas por mes, gasto por cliente. Para eso está GROUP BY: junta las filas que tienen el mismo valor en una o varias columnas y aplica las funciones de agregación a cada grupo por separado.
En esta lección aprenderás a agrupar por una y por varias columnas, a filtrar grupos con HAVING, a agrupar por meses y a evitar el error más típico de GROUP BY en MySQL.
Contar por grupo
¿Cuántos clientes hay en cada ciudad?
SELECT ciudad, COUNT(*) AS clientes
FROM clientes
GROUP BY ciudad;
| ciudad | clientes |
|---|---|
| Bilbao | 1 |
| Madrid | 2 |
| Santander | 2 |
Lo que hace la base de datos:
- Separa las filas en montones según la ciudad.
- Cuenta las filas de cada montón.
- Devuelve una fila por montón.
clientes GROUP BY ciudad resultado
Ana García Santander ─┐
Marta Ruiz Santander ─┴──► [Santander] 2 filas ──► Santander | 2
Luis Pérez Bilbao ────► [Bilbao] 1 fila ──► Bilbao | 1
Pablo Díaz Madrid ─┐
Eva Martín Madrid ─┴──► [Madrid] 2 filas ──► Madrid | 2
En cada grupo puedes usar todas las funciones de agregación que quieras:
SELECT ciudad,
COUNT(*) AS clientes,
ROUND(AVG(edad), 1) AS edad_media,
MIN(edad) AS menor,
MAX(edad) AS mayor
FROM clientes
GROUP BY ciudad;
| ciudad | clientes | edad_media | menor | mayor |
|---|---|---|---|---|
| Bilbao | 1 | 22.0 | 22 | 22 |
| Madrid | 2 | 23.0 | 17 | 29 |
| Santander | 2 | 39.5 | 34 | 45 |
Sumar por grupo
Total gastado por cada cliente:
SELECT cliente_id, COUNT(*) AS pedidos, SUM(importe) AS total
FROM pedidos
GROUP BY cliente_id
ORDER BY total DESC;
| cliente_id | pedidos | total |
|---|---|---|
| 3 | 1 | 120.50 |
| 2 | 1 | 60.00 |
| 1 | 2 | 57.99 |
| 5 | 1 | 33.25 |
Ana (cliente 1) tiene dos pedidos: 45.00 + 12.99 = 57.99. Pablo (cliente 4) no aparece porque no tiene ninguna fila en pedidos: GROUP BY solo crea grupos con las filas que existen. En la lección de JOIN verás cómo mostrar el nombre en lugar del id y cómo incluir a los clientes sin pedidos.
Una tabla con más datos
Las tablas del curso son pequeñas y todos los pedidos son de septiembre. Para practicar con varias columnas y con meses, crearemos las ventas de una tienda de montaña durante el verano:
CREATE TABLE ventas (
id INT PRIMARY KEY,
fecha DATE NOT NULL,
categoria VARCHAR(50) NOT NULL,
canal VARCHAR(10) NOT NULL,
importe DECIMAL(8, 2) NOT NULL
);
INSERT INTO ventas VALUES
(1, '2026-07-03', 'Escalada', 'Tienda', 89.90),
(2, '2026-07-15', 'Camping', 'Web', 120.00),
(3, '2026-07-28', 'Escalada', 'Web', 59.00),
(4, '2026-08-02', 'Montaña', 'Tienda', 45.50),
(5, '2026-08-09', 'Escalada', 'Tienda', 89.90),
(6, '2026-08-21', 'Camping', 'Web', 24.95),
(7, '2026-08-30', 'Camping', 'Tienda', 24.95),
(8, '2026-09-05', 'Escalada', 'Web', 59.00),
(9, '2026-09-18', 'Montaña', 'Tienda', 45.50);
Ventas por categoría:
SELECT categoria, COUNT(*) AS ventas, SUM(importe) AS total
FROM ventas
GROUP BY categoria;
| categoria | ventas | total |
|---|---|---|
| Camping | 3 | 169.90 |
| Escalada | 4 | 297.80 |
| Montaña | 2 | 91.00 |
Agrupar por varias columnas
Si pones varias columnas en el GROUP BY, se forma un grupo por cada combinación distinta de valores. Ventas por categoría y por canal:
SELECT categoria, canal, COUNT(*) AS ventas, SUM(importe) AS total
FROM ventas
GROUP BY categoria, canal
ORDER BY categoria, canal;
| categoria | canal | ventas | total |
|---|---|---|---|
| Camping | Tienda | 1 | 24.95 |
| Camping | Web | 2 | 144.95 |
| Escalada | Tienda | 2 | 179.80 |
| Escalada | Web | 2 | 118.00 |
| Montaña | Tienda | 2 | 91.00 |
No aparece “Montaña / Web” porque no hay ninguna venta con esa combinación. El orden de las columnas en el GROUP BY no cambia los grupos (GROUP BY canal, categoria da las mismas cinco filas); lo que decide cómo se ven es el ORDER BY.
La regla de oro
En una consulta con GROUP BY, cada columna del SELECT debe ser:
- una columna que aparece en el
GROUP BY, o - una función de agregación (
COUNT,SUM,AVG…).
SELECT ciudad, nombre, COUNT(*) -- ✗ ¿qué nombre, si en Madrid hay dos?
FROM clientes
GROUP BY ciudad;
El grupo de Madrid es una fila del resultado, pero dentro tiene dos nombres: Pablo y Eva. No hay forma correcta de elegir uno. Lo que hace cada sistema con esta consulta lo verás en los errores frecuentes.
Filtrar grupos: HAVING
¿Qué ciudades tienen más de un cliente?
SELECT ciudad, COUNT(*) AS clientes
FROM clientes
GROUP BY ciudad
HAVING COUNT(*) > 1;
| ciudad | clientes |
|---|---|
| Madrid | 2 |
| Santander | 2 |
HAVING es como un WHERE para grupos: se aplica después de calcular los agregados, así que puede preguntar por ellos. Otro ejemplo, las categorías que han vendido más de 150 €:
SELECT categoria, SUM(importe) AS total
FROM ventas
GROUP BY categoria
HAVING SUM(importe) > 150;
| categoria | total |
|---|---|
| Camping | 169.90 |
| Escalada | 297.80 |
Nota: MySQL y SQLite te dejan usar el alias en el
HAVING(HAVING total > 150), pero PostgreSQL no. Si repites la expresión, funciona en todos.
WHERE o HAVING
Los dos filtran, pero en momentos distintos:
WHERE | HAVING | |
|---|---|---|
| Qué filtra | Filas | Grupos |
| Cuándo | Antes de agrupar | Después de agrupar |
¿Puede usar COUNT, SUM…? | No | Sí |
¿Necesita GROUP BY? | No | Casi siempre se usa con él |
| Ejemplo | WHERE edad >= 18 | HAVING COUNT(*) > 1 |
Muchas consultas usan los dos:
-- Ciudades con más de un cliente mayor de edad
SELECT ciudad, COUNT(*) AS adultos
FROM clientes
WHERE edad >= 18 -- 1.º quita a Pablo (17)
GROUP BY ciudad -- 2.º agrupa
HAVING COUNT(*) > 1; -- 3.º se queda con los grupos de más de 1
| ciudad | adultos |
|---|---|
| Santander | 2 |
Madrid ya no aparece: al quitar a Pablo, solo le queda Eva.
Si una condición se refiere a columnas normales, ponla en el WHERE aunque también funcionase en el HAVING: descartar filas antes de agrupar es más rápido, porque hay menos filas que repartir.
El orden de ejecución
Aunque se escriba empezando por SELECT, la base de datos lo ejecuta en este orden. Entenderlo explica muchos errores:
FROM: coge la tabla.WHERE: filtra filas.GROUP BY: agrupa.HAVING: filtra grupos.SELECT: elige las columnas y calcula.ORDER BY: ordena.LIMIT: recorta.
Por ejemplo, por eso en WHERE no puedes usar un alias que has creado en el SELECT: cuando se ejecuta el WHERE, el SELECT todavía no ha ocurrido. Y por eso tampoco puedes poner COUNT(*) en el WHERE: los grupos aún no existen.
Agrupar por una expresión: ventas por mes
No tienes por qué agrupar por una columna tal cual: puedes agrupar por el resultado de una expresión. El caso más típico es agrupar por mes. La columna fecha tiene un valor distinto casi en cada fila, así que hay que quedarse solo con el año y el mes y agrupar por eso.
Aquí cambian los sistemas, porque cada uno tiene su función para formatear fechas:
-- MySQL / MariaDB
SELECT DATE_FORMAT(fecha, '%Y-%m') AS mes, COUNT(*) AS ventas, SUM(importe) AS total
FROM ventas
GROUP BY DATE_FORMAT(fecha, '%Y-%m')
ORDER BY mes;
-- SQLite
SELECT strftime('%Y-%m', fecha) AS mes, COUNT(*) AS ventas, SUM(importe) AS total
FROM ventas
GROUP BY strftime('%Y-%m', fecha)
ORDER BY mes;
Las dos dan el mismo resultado:
| mes | ventas | total |
|---|---|---|
| 2026-07 | 3 | 268.90 |
| 2026-08 | 4 | 185.30 |
| 2026-09 | 2 | 104.50 |
La idea es idéntica; cambian el nombre de la función y el orden de los argumentos:
| MySQL | SQLite | PostgreSQL | |
|---|---|---|---|
| Función | DATE_FORMAT(fecha, formato) | strftime(formato, fecha) | TO_CHAR(fecha, 'YYYY-MM') |
| Primer argumento | La fecha | El formato | La fecha |
| Año con 4 cifras | %Y | %Y | YYYY |
| Mes con 2 cifras | %m | %m | MM |
¿Por qué '%Y-%m' y no solo el mes? Por dos motivos. Primero, si hubiera ventas de julio de 2025 y de julio de 2026, agrupar solo por mes las mezclaría en un mismo grupo. Segundo, el formato 2026-07 se ordena bien como texto: '2026-07' va antes que '2026-08', y '2025-12' antes que '2026-01'.
Consejo: en MySQL también puedes agrupar por dos columnas,
GROUP BY YEAR(fecha), MONTH(fecha), que devuelven números. Es igual de válido y útil si luego quieres operar con el mes.
Y como puedes mezclar expresiones y columnas, tienes las ventas por mes y categoría, con la más vendida primero dentro de cada mes (en SQLite):
SELECT strftime('%Y-%m', fecha) AS mes, categoria, SUM(importe) AS total
FROM ventas
GROUP BY mes, categoria
ORDER BY mes, total DESC;
| mes | categoria | total |
|---|---|---|
| 2026-07 | Escalada | 148.90 |
| 2026-07 | Camping | 120.00 |
| 2026-08 | Escalada | 89.90 |
| 2026-08 | Camping | 49.90 |
| 2026-08 | Montaña | 45.50 |
| 2026-09 | Escalada | 59.00 |
| 2026-09 | Montaña | 45.50 |
Aquí se agrupa por el alias mes, cosa que MySQL, PostgreSQL y SQLite permiten. Si tu sistema no lo admite, repite la expresión en el GROUP BY. También puedes agrupar por un CASE para crear tus propias categorías, como tramos de edad o de importe.
Ordenar los grupos
Sin ORDER BY, el orden de los grupos no está garantizado (aunque a menudo salgan ordenados por la columna agrupada). Para un ranking, ordena por el agregado:
SELECT categoria, SUM(importe) AS total
FROM ventas
GROUP BY categoria
ORDER BY total DESC;
| categoria | total |
|---|---|
| Escalada | 297.80 |
| Camping | 169.90 |
| Montaña | 91.00 |
En el ORDER BY sí puedes usar el alias, porque se ejecuta después del SELECT. Con LIMIT te quedas con el primero del ranking, por ejemplo la categoría con más ventas:
SELECT categoria, COUNT(*) AS ventas
FROM ventas
GROUP BY categoria
ORDER BY ventas DESC
LIMIT 1; -- Escalada, 4
Errores frecuentes
Columnas que no están en el GROUP BY (ONLY_FULL_GROUP_BY)
Es el error estrella. Con la consulta de la regla de oro:
SELECT ciudad, nombre, COUNT(*)
FROM clientes
GROUP BY ciudad;
- MySQL 5.7 y posteriores tienen activado por defecto el modo
ONLY_FULL_GROUP_BYy dan el error: Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column ‘nombre’ … this is incompatible with sql_mode=only_full_group_by. - PostgreSQL da un error parecido: column “clientes.nombre” must appear in the GROUP BY clause or be used in an aggregate function.
- SQLite y las versiones antiguas de MySQL no protestan y devuelven un nombre cualquiera de cada grupo. Parece que funciona, pero el resultado no significa nada.
Encontrarás en Internet el consejo de desactivar ONLY_FULL_GROUP_BY. No lo hagas: el modo te protege de resultados aleatorios. La solución correcta es una de estas:
- Añadir la columna al
GROUP BYsi de verdad quieres un grupo por cada combinación. - Usar un agregado sobre ella:
MIN(nombre),COUNT(DISTINCT nombre)… - Agrupar por la clave primaria. Si agrupas por
c.id, MySQL y PostgreSQL saben que cada id tiene un único nombre y te dejan mostrarc.nombre. Aun así, lo más claro es escribirGROUP BY c.id, c.nombre, como verás en JOIN.
Otros errores
- Poner un agregado en el
WHERE:WHERE COUNT(*) > 1da error (misuse of aggregate). Va en elHAVING. - Usar
HAVINGpara filtrar filas normales: funciona en algunos casos, pero es más lento y confuso. Lo que no sea un agregado, alWHERE. - Agrupar por mes sin el año: mezcla meses de años distintos.
- Esperar que salgan los grupos vacíos:
GROUP BYno inventa filas. Si una categoría no tiene ventas, no aparece. - Confiar en el orden sin
ORDER BY.
Nota: si la columna por la que agrupas tiene
NULL, todas esas filas forman un único grupo con valorNULL. Lo tienes explicado en valores NULL.
Resumen
| Quieres… | Escribe |
|---|---|
| Un resultado por cada valor | GROUP BY columna |
| Un resultado por cada combinación | GROUP BY col1, col2 |
| Agrupar por mes (MySQL) | GROUP BY DATE_FORMAT(fecha, '%Y-%m') |
| Agrupar por mes (SQLite) | GROUP BY strftime('%Y-%m', fecha) |
| Filtrar filas antes de agrupar | WHERE |
| Filtrar grupos | HAVING COUNT(*) > 1 |
| Ranking de grupos | ORDER BY SUM(importe) DESC |
- En el
SELECT, solo columnas delGROUP BYo funciones de agregación. - Orden de escritura:
SELECT,FROM,WHERE,GROUP BY,HAVING,ORDER BY,LIMIT. - Orden de ejecución:
FROM,WHERE,GROUP BY,HAVING,SELECT,ORDER BY,LIMIT.
En la siguiente lección aprenderás a combinar tablas con JOIN.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.