Tema
Agregación y GROUP BY
Los dueños de negocio no preguntan "¿qué filas hay?", preguntan "¿cuánto vendí este mes?", "¿cuántos clientes tengo por ciudad?", "¿cuál es el ticket promedio?". Para eso existen las funciones de agregación y GROUP BY. Aquí aprenderás a resumir datos, agruparlos, filtrar grupos con HAVING y entender el orden en que SQL ejecuta realmente una consulta.
Concepto
Una función de agregación toma muchas filas y devuelve un solo valor. Es como pasar el ticket del súper por la calculadora: muchos productos entran, un total sale.
GROUP BY hace lo mismo pero por grupos: es como separar los tickets en montones por sucursal y sumar cada montón.
txt
productos GROUP BY categoria_id resultado
┌───────────┬────────┐ ┌───────────────────────┐ ┌──────────┬──────────┐
│ categoria │ precio │ │ cat 1: 899,1250,3499 │ ──► │ cat 1 │ 3 prod. │
│ 1 │ 899 │ │ cat 2: 349,289 │ ──► │ cat 2 │ 2 prod. │
│ 1 │ 1250 │ ───► │ cat 3: 459,199 │ ──► │ cat 3 │ 2 prod. │
│ 2 │ 349 │ │ NULL : 99 │ ──► │ NULL │ 1 prod. │
│ ... │ ... │ └───────────────────────┘ └──────────┴──────────┘Funciones de agregación
| Función | Qué calcula | Ignora NULL |
|---|---|---|
COUNT(*) | Número de filas | No (cuenta todas) |
COUNT(columna) | Filas donde la columna no es NULL | Sí |
COUNT(DISTINCT columna) | Valores distintos | Sí |
SUM(columna) | Suma | Sí |
AVG(columna) | Promedio | Sí |
MIN(columna) / MAX(columna) | Mínimo / máximo (también textos y fechas) | Sí |
sql
SELECT
COUNT(*) AS total_productos,
COUNT(categoria_id) AS con_categoria,
SUM(stock) AS piezas_en_almacen,
ROUND(AVG(precio), 2) AS precio_promedio,
MIN(precio) AS mas_barato,
MAX(precio) AS mas_caro
FROM productos;| total_productos | con_categoria | piezas_en_almacen | precio_promedio | mas_barato | mas_caro |
|---|---|---|---|---|---|
| 8 | 7 | 275 | 880.38 | 99.00 | 3499.00 |
Fíjate: COUNT(*) da 8 y COUNT(categoria_id) da 7 porque el Cable USB-C tiene NULL.
sql
-- ¿En cuántas ciudades distintas tenemos clientes?
SELECT COUNT(DISTINCT ciudad) AS ciudades FROM clientes; -- 4
-- Primer y último registro de clientes
SELECT MIN(fecha_registro) AS primero, MAX(fecha_registro) AS ultimo FROM clientes;
-- primero: 2024-01-15 | ultimo: 2024-04-02GROUP BY: agregar por grupos
sql
-- Productos, precio promedio y stock por categoría
SELECT
categoria_id,
COUNT(*) AS productos,
ROUND(AVG(precio), 2) AS precio_promedio,
SUM(stock) AS stock_total
FROM productos
GROUP BY categoria_id
ORDER BY categoria_id;| categoria_id | productos | precio_promedio | stock_total |
|---|---|---|---|
| 1 | 3 | 1882.67 | 40 |
| 2 | 2 | 319.00 | 70 |
| 3 | 2 | 329.00 | 65 |
| NULL | 1 | 99.00 | 100 |
(En PostgreSQL la fila NULL aparece al final; en MySQL al principio.) Los NULL forman su propio grupo.
sql
-- Pedidos por estado
SELECT estado, COUNT(*) AS cantidad
FROM pedidos
GROUP BY estado
ORDER BY cantidad DESC, estado;| estado | cantidad |
|---|---|
| entregado | 2 |
| cancelado | 1 |
| enviado | 1 |
| pendiente | 1 |
El total de cada pedido
Esta es una de las consultas más importantes de cualquier tienda:
sql
SELECT
pedido_id,
SUM(cantidad) AS piezas,
SUM(cantidad * precio_unitario) AS total
FROM detalle_pedido
GROUP BY pedido_id
ORDER BY pedido_id;| pedido_id | piezas | total |
|---|---|---|
| 1 | 3 | 1597.00 |
| 2 | 4 | 1847.00 |
| 3 | 1 | 3499.00 |
| 4 | 4 | 1146.00 |
| 5 | 1 | 899.00 |
Agrupar por varias columnas
sql
-- Pedidos por cliente y estado
SELECT cliente_id, estado, COUNT(*) AS pedidos
FROM pedidos
GROUP BY cliente_id, estado
ORDER BY cliente_id;| cliente_id | estado | pedidos |
|---|---|---|
| 1 | entregado | 1 |
| 1 | enviado | 1 |
| 2 | entregado | 1 |
| 3 | pendiente | 1 |
| 4 | cancelado | 1 |
Agrupar por una expresión: ventas por mes
sql
-- PostgreSQL y MySQL
SELECT
EXTRACT(YEAR FROM p.fecha) AS anio,
EXTRACT(MONTH FROM p.fecha) AS mes,
SUM(d.cantidad * d.precio_unitario) AS ventas
FROM pedidos p, detalle_pedido d
WHERE p.id = d.pedido_id
AND p.estado <> 'cancelado'
GROUP BY EXTRACT(YEAR FROM p.fecha), EXTRACT(MONTH FROM p.fecha)
ORDER BY anio, mes;| anio | mes | ventas |
|---|---|---|
| 2024 | 5 | 3444.00 |
| 2024 | 6 | 4645.00 |
(Aquí unimos dos tablas con una coma y una condición en WHERE; en la siguiente lección aprenderás la forma moderna: JOINs.)
Error común: columnas fuera del GROUP BY
sql
-- ERROR en PostgreSQL y en MySQL 8 (ONLY_FULL_GROUP_BY)
SELECT categoria_id, nombre, COUNT(*)
FROM productos
GROUP BY categoria_id;¿Qué nombre debería mostrar el grupo de Electrónica, si tiene 3 productos? No hay respuesta, por eso falla. Regla: toda columna del SELECT debe estar en el GROUP BY o dentro de una función de agregación.
HAVING: filtrar grupos
WHERE filtra filas antes de agrupar. HAVING filtra grupos después de agregar.
sql
-- Pedidos cuyo total supera 1,500
SELECT pedido_id, SUM(cantidad * precio_unitario) AS total
FROM detalle_pedido
GROUP BY pedido_id
HAVING SUM(cantidad * precio_unitario) > 1500
ORDER BY total DESC;| pedido_id | total |
|---|---|
| 3 | 3499.00 |
| 2 | 1847.00 |
| 1 | 1597.00 |
sql
-- Clientes con más de un pedido
SELECT cliente_id, COUNT(*) AS pedidos
FROM pedidos
GROUP BY cliente_id
HAVING COUNT(*) > 1;| cliente_id | pedidos |
|---|---|
| 1 | 2 |
Combinando ambos:
sql
-- Categorías con stock total mayor a 50, contando solo productos de más de 150 pesos
SELECT categoria_id, SUM(stock) AS stock_total
FROM productos
WHERE precio > 150 -- filtra filas
GROUP BY categoria_id
HAVING SUM(stock) > 50 -- filtra grupos
ORDER BY categoria_id;| categoria_id | stock_total |
|---|---|
| 2 | 70 |
| 3 | 65 |
| WHERE | HAVING | |
|---|---|---|
| Filtra | Filas individuales | Grupos |
| Cuándo | Antes de agrupar | Después de agrupar |
Puede usar SUM, COUNT... | No | Sí |
Buena práctica
Si una condición no usa funciones de agregación, ponla en WHERE, no en HAVING: la BD descarta filas antes y trabaja menos.
El orden lógico de ejecución
Escribes la consulta en un orden, pero la BD la ejecuta en otro. Entenderlo resuelve el 80 % de las dudas:
txt
Orden en que lo escribes Orden en que se ejecuta
──────────────────────── ───────────────────────
1. SELECT 1. FROM / JOIN ← ¿de dónde salen los datos?
2. FROM 2. WHERE ← filtra filas
3. WHERE 3. GROUP BY ← forma grupos
4. GROUP BY 4. HAVING ← filtra grupos
5. HAVING 5. SELECT ← calcula columnas y alias
6. ORDER BY 6. DISTINCT
7. LIMIT 7. ORDER BY ← ordena
8. LIMIT / OFFSET ← recortaConsecuencias prácticas:
- No puedes usar un alias del
SELECTen elWHERE(elWHEREse ejecuta antes).WHERE total > 100falla sitotales un alias. - Sí puedes usar el alias en
ORDER BY(se ejecuta después). - MySQL permite alias en
HAVINGcomo extensión; PostgreSQL no. Repetir la expresión funciona en ambos.
Caso real: tablero de la tienda
sql
-- Resumen general de ventas (sin cancelados)
SELECT
COUNT(DISTINCT p.id) AS pedidos,
SUM(d.cantidad * d.precio_unitario) AS ventas_totales,
ROUND(SUM(d.cantidad * d.precio_unitario)
/ COUNT(DISTINCT p.id), 2) AS ticket_promedio,
SUM(d.cantidad) AS piezas_vendidas
FROM pedidos p, detalle_pedido d
WHERE p.id = d.pedido_id
AND p.estado <> 'cancelado';| pedidos | ventas_totales | ticket_promedio | piezas_vendidas |
|---|---|---|---|
| 4 | 8089.00 | 2022.25 | 12 |
Ejercicios
- ¿Cuántos clientes hay por ciudad? Ordena de mayor a menor.
- ¿Cuántas piezas se han vendido de cada producto (todos los pedidos)? Muestra
producto_idypiezas, ordenado por piezas descendente. - ¿Qué productos han generado más de 1,000 pesos en ventas (suma de cantidad × precio_unitario)?
- ¿Cuál es el valor total del inventario (precio × stock) por categoría?
- ¿Por qué falla
SELECT ciudad, COUNT(*) AS total FROM clientes WHERE total > 1 GROUP BY ciudad;? Corrígela.
Ver solución
sql
-- 1
SELECT ciudad, COUNT(*) AS clientes
FROM clientes
GROUP BY ciudad
ORDER BY clientes DESC, ciudad;| ciudad | clientes |
|---|---|
| Ciudad de México | 2 |
| Guadalajara | 1 |
| Monterrey | 1 |
| Puebla | 1 |
sql
-- 2
SELECT producto_id, SUM(cantidad) AS piezas
FROM detalle_pedido
GROUP BY producto_id
ORDER BY piezas DESC, producto_id;| producto_id | piezas |
|---|---|
| 7 | 5 |
| 1 | 2 |
| 4 | 2 |
| 2 | 1 |
| 3 | 1 |
| 5 | 1 |
| 6 | 1 |
sql
-- 3
SELECT producto_id, SUM(cantidad * precio_unitario) AS ventas
FROM detalle_pedido
GROUP BY producto_id
HAVING SUM(cantidad * precio_unitario) > 1000
ORDER BY ventas DESC;| producto_id | ventas |
|---|---|
| 3 | 3499.00 |
| 1 | 1798.00 |
| 2 | 1250.00 |
sql
-- 4
SELECT categoria_id, SUM(precio * stock) AS valor_inventario
FROM productos
GROUP BY categoria_id
ORDER BY categoria_id;| categoria_id | valor_inventario |
|---|---|
| 1 | 52470.00 |
| 2 | 22630.00 |
| 3 | 16835.00 |
| NULL | 9900.00 |
- Porque
WHEREse ejecuta antes de agrupar: ni el alias ni el conteo existen todavía. UsaHAVING:
sql
SELECT ciudad, COUNT(*) AS total
FROM clientes
GROUP BY ciudad
HAVING COUNT(*) > 1;| ciudad | total |
|---|---|
| Ciudad de México | 2 |
Resumen
COUNT,SUM,AVG,MIN,MAXresumen muchas filas en un valor; ignoranNULL(salvoCOUNT(*)).GROUP BYcalcula esas funciones por grupo. Toda columna delSELECTdebe estar agrupada o agregada.WHEREfiltra filas antes de agrupar;HAVINGfiltra grupos después.- Orden de ejecución: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT.
- El total de un pedido es
SUM(cantidad * precio_unitario)agrupado por pedido.