Lección 9 de 16 · Nivel 3: Avanzado
SQL intermedio: GROUP BY, HAVING y JOIN
- ⏱ 12 min de lectura
- ❓ 4 preguntas de repaso
- ✍️ Ejercicio con solución
- 📥 Plantilla descargable
En esta lección
- Resumir datos con COUNT, SUM, AVG, MIN y MAX.
- Agrupar resultados con GROUP BY y filtrar grupos con HAVING.
- Distinguir cuándo usar WHERE y cuándo HAVING.
- Unir tablas con INNER JOIN y LEFT JOIN, y usar LEFT JOIN para encontrar datos huérfanos.
- Crear categorías con CASE WHEN y agrupar por mes o trimestre.
Usa la base nexo.db que creaste en la lección anterior, con las tablas ventas, regiones y ventas_sin_limpiar.
- ventas-practica-analisis.xlsx · hojas Ventas_limpias y Regiones
- ventas-practica-analisis.csv · tabla ventas_sin_limpiar
En la lección anterior filtraste y ordenaste filas. Ahora darás el paso que convierte a SQL en una herramienta de análisis: resumir. Con GROUP BY obtienes en una consulta lo que en Excel hacías con una tabla dinámica (ventas por región, pedidos por canal), con HAVING filtras esos resúmenes y con JOIN combinas tablas, por ejemplo para analizar las ventas por país aunque el país esté en otra tabla. Estas tres herramientas aparecen en casi todas las pruebas técnicas de analista.
Funciones de agregación
Una función de agregación convierte muchas filas en un solo valor. Sin GROUP BY, resume la tabla entera:
SELECT COUNT(*) AS filas,
COUNT(DISTINCT id_pedido) AS pedidos_unicos,
ROUND(SUM(importe), 2) AS ventas,
ROUND(AVG(importe), 2) AS ticket_promedio,
MIN(importe) AS minimo,
MAX(importe) AS maximo
FROM ventas;
| filas | pedidos_unicos | ventas | ticket_promedio | minimo | maximo |
|---|---|---|---|---|---|
| 197 | 197 | 38472.2 | 195.29 | 17.5 | 1298.0 |
Comparar COUNT(*) con COUNT(DISTINCT id_pedido) es la forma rápida de detectar duplicados en SQL: en la tabla ventas_sin_limpiar darían 200 y 197. Ten en cuenta que COUNT(columna) no cuenta los NULL: COUNT(importe) en la tabla sin limpiar devuelve 196, no 200.
GROUP BY: un resumen por grupo
GROUP BY parte la tabla en grupos (uno por cada valor distinto de la columna) y aplica la agregación a cada uno. Ventas y pedidos por región:
SELECT region,
COUNT(*) AS pedidos,
ROUND(SUM(importe), 2) AS ventas
FROM ventas
GROUP BY region
ORDER BY ventas DESC;
| region | pedidos | ventas |
|---|---|---|
| Ciudad de México | 41 | 8153.5 |
| Lima | 33 | 7516.6 |
| Nuevo León | 24 | 4211.0 |
| Arequipa | 14 | 3803.6 |
| La Libertad | 16 | 3415.5 |
| Bogotá | 24 | 3317.8 |
| Jalisco | 17 | 3210.2 |
| Valle del Cauca | 14 | 2671.6 |
| Antioquia | 14 | 2172.4 |
El resultado como % de las ventas totales. Colores por país: azul México, naranja Perú, verde Colombia. Las dos capitales suman el 40,7%.
La regla de oro de GROUP BY: cada columna del SELECT debe estar en el GROUP BY o dentro de una función de agregación. Si escribes SELECT region, producto, SUM(importe) ... GROUP BY region, la base de datos no sabe qué producto mostrar para cada región; muchos sistemas dan error y SQLite devuelve uno cualquiera, lo que es peor.
Puedes calcular varias métricas a la vez. Por canal:
SELECT canal,
COUNT(*) AS pedidos,
ROUND(SUM(importe), 2) AS ventas,
ROUND(AVG(importe), 2) AS ticket_promedio
FROM ventas
GROUP BY canal
ORDER BY ventas DESC;
| canal | pedidos | ventas | ticket_promedio |
|---|---|---|---|
| Web | 94 | 17858.9 | 189.99 |
| Marketplace | 60 | 11730.3 | 195.5 |
| Tienda física | 43 | 8883.0 | 206.58 |
Detalle fino: el promedio exacto de Marketplace es 195,505. Excel lo muestra como 195,51, pero SQLite devuelve 195.5 porque guarda los decimales en binario (195,50499…). En sistemas financieros se usan columnas DECIMAL para evitar estas diferencias de centavos.
1. ¿Qué consulta da el número de pedidos y las unidades vendidas por categoría?
Sin GROUP BY, la primera resume toda la tabla en una fila. La tercera lista las 197 filas sin resumir. La correcta devuelve 4 filas: Cómputo 44 pedidos y 71 unidades, Accesorios 79 y 241, Audio 50 y 133, Oficina 24 y 53.
HAVING: filtra los grupos
WHERE filtra filas antes de agrupar; HAVING filtra grupos después de agrupar, y por eso puede usar funciones de agregación. ¿Qué regiones vendieron más de 4.000 USD?
SELECT region, ROUND(SUM(importe), 2) AS ventas
FROM ventas
GROUP BY region
HAVING SUM(importe) > 4000
ORDER BY ventas DESC;
| region | ventas |
|---|---|
| Ciudad de México | 8153.5 |
| Lima | 7516.6 |
| Nuevo León | 4211.0 |
Y puedes combinar ambos. Esta consulta primero se queda con los pedidos web (WHERE), luego agrupa por región y, al final, conserva las regiones cuyas ventas web superan 2.000 USD (HAVING):
SELECT region, COUNT(*) AS pedidos, ROUND(SUM(importe), 2) AS ventas_web
FROM ventas
WHERE canal = 'Web'
GROUP BY region
HAVING SUM(importe) > 2000
ORDER BY ventas_web DESC;
| region | pedidos | ventas_web |
|---|---|---|
| Lima | 13 | 3658.0 |
| Ciudad de México | 21 | 2320.2 |
| La Libertad | 8 | 2262.6 |
| Arequipa | 8 | 2223.3 |
Tres de las cuatro regiones son peruanas: coincide con lo que viste en la tabla dinámica (en Perú, el 55,3% de las ventas es web).
2. Quieres las regiones con al menos 20 pedidos. ¿Dónde va la condición COUNT(*) >= 20?
WHERE se aplica fila por fila, antes de agrupar, y no puede usar COUNT. HAVING se aplica a los grupos ya calculados. El resultado: Ciudad de México (41), Lima (33), Nuevo León (24) y Bogotá (24).
Agrupar por fechas y con CASE WHEN
CASE WHEN crea una columna nueva según condiciones, como un SI anidado de Excel. Es perfecto para crear trimestres, tramos de importe o segmentos:
SELECT CASE
WHEN fecha < '2025-04-01' THEN 'T1'
WHEN fecha < '2025-07-01' THEN 'T2'
WHEN fecha < '2025-10-01' THEN 'T3'
ELSE 'T4'
END AS trimestre,
COUNT(*) AS pedidos,
ROUND(SUM(importe), 2) AS ventas
FROM ventas
GROUP BY trimestre
ORDER BY trimestre;
| trimestre | pedidos | ventas |
|---|---|---|
| T1 | 37 | 6133.8 |
| T2 | 42 | 6985.8 |
| T3 | 48 | 9665.6 |
| T4 | 70 | 15687.0 |
Para agrupar por mes, cada sistema tiene su función: en SQLite strftime('%m', fecha), en PostgreSQL DATE_TRUNC('month', fecha), en MySQL MONTH(fecha). El cuarto trimestre, mes a mes, fue: octubre 4.798,8 (15 pedidos), noviembre 4.967,8 (23) y diciembre 5.920,4 (32).
JOIN: combina tablas
La tabla ventas tiene la región pero no el país; el país está en la tabla regiones. JOIN las une usando la columna que comparten, igual que BUSCARX en Excel, pero para todas las filas a la vez. Hay cuatro tipos principales:
INNER JOIN
Solo las filas que encuentran pareja en ambas tablas. Es el JOIN por defecto.
Riesgo: las filas sin pareja desaparecen sin aviso.LEFT JOIN
Todas las filas de la tabla izquierda; si no hay pareja, las columnas de la derecha quedan en NULL.
Uso estrella: encontrar registros huérfanos o mal escritos.RIGHT JOIN
Lo mismo, pero conservando todas las filas de la tabla derecha.
En la práctica: casi siempre se reescribe como LEFT JOIN cambiando el orden.FULL OUTER JOIN
Todas las filas de ambas tablas, tengan o no pareja.
Útil para: conciliar dos listas, como inventario contra ventas.Ventas por país con INNER JOIN. Las letras v y r son alias de tabla para escribir menos:
SELECT r.pais,
COUNT(*) AS pedidos,
ROUND(SUM(v.importe), 2) AS ventas,
ROUND(AVG(v.importe), 2) AS ticket_promedio
FROM ventas AS v
JOIN regiones AS r ON v.region = r.region
GROUP BY r.pais
ORDER BY ventas DESC;
| pais | pedidos | ventas | ticket_promedio |
|---|---|---|---|
| México | 82 | 15574.7 | 189.94 |
| Perú | 63 | 14735.7 | 233.9 |
| Colombia | 52 | 8161.8 | 156.96 |
Los totales coinciden con los de Excel y la tabla dinámica: 197 pedidos y 38.472,20 USD. Cuando un JOIN cuadra con una cifra conocida, puedes confiar en él.
LEFT JOIN para encontrar datos huérfanos
Ahora haz el JOIN con la tabla sin limpiar. Con INNER JOIN obtienes 192 filas en lugar de 200: ocho pedidos desaparecen sin ningún mensaje. Para ver cuáles, usa LEFT JOIN y quédate con los que no encontraron país:
SELECT '[' || s.region || ']' AS region, COUNT(*) AS filas
FROM ventas_sin_limpiar AS s
LEFT JOIN regiones AS r ON s.region = r.region
WHERE r.pais IS NULL
GROUP BY s.region
ORDER BY filas DESC, s.region;
| region | filas |
|---|---|
| [CDMX] | 2 |
| [lima] | 2 |
| [Bogota] | 1 |
| [Lima ] | 1 |
| [Nuevo Leon] | 1 |
| [antioquia] | 1 |
Los corchetes (concatenados con ||) hacen visible el espacio de «Lima ». Este patrón, LEFT JOIN + IS NULL, es una de las comprobaciones de calidad más útiles que existen.
Si la tabla regiones tuviera «Lima» repetida dos veces, cada pedido de Lima se uniría con ambas filas y sus ventas aparecerían duplicadas. Antes de unir, comprueba que la columna de unión sea única en la tabla de búsqueda: SELECT region, COUNT(*) FROM regiones GROUP BY region HAVING COUNT(*) > 1; debe devolver cero filas.
3. Unes ventas_sin_limpiar (200 filas) con regiones usando INNER JOIN y obtienes 192 filas. ¿Qué pasó?
INNER JOIN solo conserva las filas con pareja. «CDMX», «lima», «Lima », «Bogota», «Nuevo Leon» y «antioquia» no tienen pareja exacta. Los duplicados son otro problema (son 3, y el JOIN los conserva).
El orden en que se ejecuta una consulta
- FROM y JOINArma la tabla de trabajoToma las tablas y las une.
- WHEREFiltra filasDescarta las filas que no cumplen la condición.
- GROUP BYForma gruposUna «caja» por cada valor distinto.
- HAVINGFiltra gruposDescarta grupos según sus agregados.
- SELECTCalcula columnasAgregaciones, alias y CASE.
- ORDER BY y LIMITOrdena y cortaDevuelve el resultado final.
Errores comunes (y qué hacer en su lugar)
Poner en SELECT columnas que no están en GROUP BY ni agregadas.
Agrega la columna al GROUP BY o envuélvela en una agregación.
Filtrar una agregación con WHERE (WHERE SUM(importe) > 4000).
Usa HAVING para condiciones sobre grupos.
Usar INNER JOIN sin comprobar cuántas filas se pierden.
Compara el conteo antes y después, o usa LEFT JOIN con IS NULL.
Unir por una columna que se repite en la tabla de búsqueda.
Verifica que la clave sea única antes del JOIN.
4. ¿Qué devuelve SELECT producto, COUNT(*) AS pedidos FROM ventas GROUP BY producto HAVING COUNT(*) > 20 ORDER BY pedidos DESC;?
HAVING deja solo los productos con más de 20 pedidos: Mouse inalámbrico (32), Audífonos Bluetooth (29), Mochila para laptop (24), Teclado mecánico (23) y Parlante portátil (21). Ninguno es de Cómputo: los productos caros se piden menos veces.
Ejercicio práctico
Escribe una consulta que muestre, para cada combinación de país y canal, el número de pedidos y las ventas, solo para las combinaciones con 20 pedidos o más, ordenadas de mayor a menor venta. ¿Cuál es la combinación más importante y qué país no aparece con su tienda física?
Ver solución
SELECT r.pais, v.canal,
COUNT(*) AS pedidos,
ROUND(SUM(v.importe), 2) AS ventas
FROM ventas AS v
JOIN regiones AS r ON v.region = r.region
GROUP BY r.pais, v.canal
HAVING COUNT(*) >= 20
ORDER BY ventas DESC;
| pais | canal | pedidos | ventas |
|---|---|---|---|
| Perú | Web | 29 | 8143.9 |
| México | Web | 38 | 5878.6 |
| México | Marketplace | 24 | 5566.1 |
| Perú | Marketplace | 22 | 4896.3 |
| México | Tienda física | 20 | 4130.0 |
| Colombia | Web | 27 | 3836.4 |
Perú-Web es la combinación que más vende (8.143,90 USD) con menos pedidos que México-Web: su ticket es mucho mayor. Perú y Colombia no aparecen con Tienda física porque tienen menos de 20 pedidos en ese canal. Fíjate en que el GROUP BY lleva las dos columnas no agregadas del SELECT.
Resumen
- COUNT, SUM, AVG, MIN y MAX resumen filas; COUNT(DISTINCT) ayuda a detectar duplicados.
- GROUP BY crea un resumen por grupo; toda columna no agregada del SELECT debe estar en él.
- WHERE filtra filas antes de agrupar; HAVING filtra grupos después.
- JOIN combina tablas: INNER conserva solo coincidencias y LEFT conserva toda la tabla izquierda.
- LEFT JOIN + IS NULL encuentra datos huérfanos: en la tabla sin limpiar, 8 filas sin país.
En la próxima lección: Visualización de datos: cómo elegir el gráfico correcto. Convertirás estos resultados en gráficos que se entienden de un vistazo.
Crea tu cuenta en 30 segundos, marca tus lecciones y descarga el certificado al terminar.