SQL · Guías · 30 de octubre de 2026 · 11 min de lectura
GROUP BY y HAVING en SQL: diferencia entre WHERE y HAVING, con ejemplos

GROUP BY y HAVING no son alternativas: hacen dos trabajos distintos en la misma consulta. GROUP BY agrupa filas (una fila de salida por aeropuerto, por día, por clase) para que los agregados como COUNT y AVG se calculen por grupo. HAVING filtra esos grupos cuando los agregados ya están calculados. WHERE es la tercera pieza: filtra filas individuales antes de agrupar. La regla corta: una condición sobre una columna tal cual va en WHERE; una condición sobre un agregado (COUNT(*) > 1000, AVG(amount) > 300) va en HAVING.
Todos los ejemplos usan la base de datos de una aerolínea del curso de SQL de Nomto: 33.121 vuelos entre 104 aeropuertos en el verano de 2026, con reservas, billetes y tramos de vuelo (valores en inglés, dinero en dólares). Puedes pegar cada consulta en la sala de SQL de Nomto y comprobar el resultado tú mismo.
Qué hace GROUP BY
Sin GROUP BY, un agregado convierte toda la tabla en una sola fila. Con él, obtienes una fila por cada valor distinto de las columnas de agrupación.
SELECT status, COUNT(*) AS flights
FROM flights
GROUP BY status
ORDER BY flights DESC;
| status | flights |
|---|---|
| Arrived | 16707 |
| Scheduled | 15383 |
| On Time | 518 |
| Cancelled | 414 |
| Departed | 58 |
| Delayed | 41 |
Seis estados, seis filas. Cada fila es un grupo, y COUNT(*) se ha calculado por separado dentro de cada uno.
Se puede agrupar por varias columnas y por expresiones. Aquí, el número de asientos por modelo de avión, con un conteo condicional de asientos de clase business en la misma pasada:
SELECT aircraft_code,
COUNT(*) AS seats,
SUM(CASE WHEN fare_conditions = 'Business' THEN 1 ELSE 0 END) AS business
FROM seats
GROUP BY aircraft_code
ORDER BY seats DESC;
| aircraft_code | seats | business |
|---|---|---|
| 773 | 402 | 30 |
| 763 | 222 | 30 |
| 321 | 170 | 28 |
| 320 | 140 | 20 |
| 733 | 130 | 12 |
| 319 | 116 | 20 |
| SU9 | 97 | 12 |
| CR2 | 50 | 0 |
| CN1 | 12 | 0 |
Qué hace HAVING
HAVING conserva o descarta grupos enteros. Se evalúa cuando los agregados ya existen, así que puede usarlos.
SELECT departure_airport, COUNT(*) AS departures
FROM flights
WHERE status = 'Arrived'
GROUP BY departure_airport
HAVING COUNT(*) > 1000
ORDER BY departures DESC;
| departure_airport | departures |
|---|---|
| DME | 1618 |
| SVO | 1503 |
Léela en el orden en que lo hace la base de datos: toma todos los vuelos, quédate solo con los que llegaron (WHERE), agrúpalos por aeropuerto de salida, cuenta cada grupo y conserva solo los grupos con más de 1.000 filas (HAVING). Pasan dos aeropuertos de Moscú, Domodédovo y Sheremétievo.
HAVING puede usar cualquier agregado, no solo el que seleccionas. ¿Qué modelos de avión no tienen ningún asiento de business?
SELECT aircraft_code, COUNT(*) AS seats
FROM seats
GROUP BY aircraft_code
HAVING SUM(CASE WHEN fare_conditions = 'Business' THEN 1 ELSE 0 END) = 0;
| aircraft_code | seats |
|---|---|
| CR2 | 50 |
| CN1 | 12 |
El Bombardier CRJ-200 y el Cessna 208 Caravan.
Diferencia entre WHERE y HAVING
| WHERE | HAVING | |
|---|---|---|
| Filtra | filas individuales | grupos |
| Se ejecuta | antes de GROUP BY |
después de GROUP BY y de los agregados |
| Puede usar agregados | no | sí |
| Puede usar columnas sin agregar | cualquier columna | solo las agrupadas (o dentro de agregados) |
Funciona sin GROUP BY |
sí | sí, todo el resultado es un único grupo |
La consecuencia práctica: una condición en WHERE cambia lo que entra en los agregados; una condición en HAVING, no. Compara estas dos consultas sobre precios de tramos.
Precio medio por clase, quedándote solo con los tramos que cuestan más de 300 dólares:
SELECT fare_conditions, COUNT(*) AS segments, ROUND(AVG(amount), 2) AS avg_price
FROM ticket_flights
WHERE amount > 300
GROUP BY fare_conditions
ORDER BY avg_price DESC;
| fare_conditions | segments | avg_price |
|---|---|---|
| Business | 63467 | 739.73 |
| Economy | 90837 | 500.99 |
| Comfort | 6919 | 474.92 |
Precio medio por clase, quedándote solo con las clases cuya media supera los 300 dólares:
SELECT fare_conditions, COUNT(*) AS segments, ROUND(AVG(amount), 2) AS avg_price
FROM ticket_flights
GROUP BY fare_conditions
HAVING AVG(amount) > 300
ORDER BY avg_price DESC;
| fare_conditions | segments | avg_price |
|---|---|---|
| Business | 107642 | 511.43 |
| Comfort | 17291 | 327.41 |
La primera consulta responde a «cuánto cuesta de media un tramo caro en cada clase»: todas las medias están infladas porque se han tirado los tramos baratos, y Economy incluso adelanta a Comfort. La segunda responde a «qué clases son caras de media»: las medias son las reales (la de Economy es 159,60 dólares, por eso queda fuera). Mismas columnas, mismo número, preguntas completamente distintas.
Si sirven los dos, usa WHERE
Una condición sobre una columna agrupada técnicamente puede ir en cualquiera de los dos sitios:
SELECT departure_airport, COUNT(*) AS departures
FROM flights
WHERE departure_airport IN ('SVO', 'DME', 'VKO')
GROUP BY departure_airport
ORDER BY departures DESC;
| departure_airport | departures |
|---|---|
| DME | 3217 |
| SVO | 2981 |
| VKO | 1719 |
Si mueves la condición a HAVING departure_airport IN ('SVO', 'DME', 'VKO'), salen las mismas tres filas. Pero la versión con HAVING primero agrupa y cuenta los 104 aeropuertos y luego descarta 101 grupos. Muchos optimizadores bajan esa condición por ti, pero no conviene contar con ello, y WHERE además le dice al lector tu intención: esas filas quedan fuera desde el principio.
En qué orden ejecuta SQL una consulta
Escribes SELECT primero, pero la base de datos evalúa las cláusulas en este orden lógico:
FROMyJOIN: forma el conjunto de filasWHERE: descarta filasGROUP BY: forma los grupos- se calculan los agregados de cada grupo
HAVING: descarta gruposSELECT: calcula las columnas de salida y los aliasDISTINCTORDER BYLIMIT/OFFSET
Casi todos los errores con GROUP BY y HAVING salen de esta lista:
WHEREse ejecuta antes de que existan los agregados, así queWHERE COUNT(*) > 1000es imposible.SELECTse ejecuta después deGROUP BY, así que cada columna seleccionada debe tener un único valor por grupo.ORDER BYse ejecuta después deSELECT, así que puede usar un alias comodepartures.- En PostgreSQL,
HAVINGse ejecuta antes queSELECTy no puede usar un alias delSELECT: escribeHAVING COUNT(*) > 1000, noHAVING departures > 1000. Algunos motores (MySQL, DuckDB) aceptan el alias, y por eso el código copiado de una base de datos a otra a veces falla justo aquí.
Errores frecuentes
1. Un agregado en WHERE
SELECT departure_airport, COUNT(*)
FROM flights
WHERE COUNT(*) > 1000
GROUP BY departure_airport;
Falla. PostgreSQL responde que las funciones de agregado no están permitidas en WHERE («aggregate functions are not allowed in WHERE»). Lleva la condición a HAVING.
2. Una columna seleccionada que ni se agrupa ni se agrega
SELECT departure_airport, arrival_airport, COUNT(*)
FROM flights
GROUP BY departure_airport;
También falla: la columna arrival_airport debe aparecer en GROUP BY o usarse dentro de una función de agregado. Un aeropuerto de salida tiene muchos aeropuertos de llegada, así que la base de datos no puede elegir un único valor por grupo. O añades arrival_airport al GROUP BY (una fila por ruta) o lo agregas, por ejemplo con COUNT(DISTINCT arrival_airport).
PostgreSQL hace una excepción: si agrupas por la clave primaria de una tabla, puedes seleccionar otras columnas de esa tabla. Agrupar por b.book_ref te deja seleccionar b.total_amount. Poner las dos en GROUP BY funciona en todas partes, así que las consultas de esta guía lo hacen así:
SELECT b.book_ref, COUNT(*) AS tickets, b.total_amount
FROM bookings b
JOIN tickets t ON t.book_ref = b.book_ref
GROUP BY b.book_ref, b.total_amount
HAVING COUNT(*) >= 5
ORDER BY b.total_amount DESC
LIMIT 5;
| book_ref | tickets | total_amount |
|---|---|---|
| 65A6EA | 5 | 10656.00 |
| 7734A5 | 5 | 8291.00 |
| 148C46 | 5 | 8135.00 |
| 070682 | 5 | 8012.00 |
| 8DA112 | 5 | 6150.00 |
En toda la base de datos solo hay 23 reservas con cinco billetes o más.
3. Comparar grupos pequeños con grupos grandes
Retraso medio de salida por aeropuerto, entre los vuelos que realmente despegaron:
SELECT departure_airport,
ROUND(AVG(EXTRACT(EPOCH FROM actual_departure - scheduled_departure) / 60), 1) AS avg_delay_min,
COUNT(*) AS departed
FROM flights
WHERE status IN ('Arrived', 'Departed')
GROUP BY departure_airport
ORDER BY avg_delay_min DESC
LIMIT 3;
| departure_airport | avg_delay_min | departed |
|---|---|---|
| ULY | 24.4 | 97 |
| BQS | 22.8 | 31 |
| LPK | 22.7 | 22 |
Los «peores» aeropuertos tienen entre 22 y 97 salidas, así que un par de retrasos largos dominan su media. Un tamaño mínimo de grupo en HAVING hace la comparación más justa:
SELECT departure_airport,
COUNT(*) AS departed,
ROUND(AVG(EXTRACT(EPOCH FROM actual_departure - scheduled_departure) / 60), 1) AS avg_delay_min
FROM flights
WHERE status IN ('Arrived', 'Departed')
GROUP BY departure_airport
HAVING COUNT(*) >= 300
ORDER BY avg_delay_min DESC
LIMIT 5;
| departure_airport | departed | avg_delay_min |
|---|---|---|
| SVX | 348 | 14.7 |
| VKO | 870 | 13.4 |
| BZK | 306 | 13.2 |
| KJA | 358 | 13.1 |
| LED | 960 | 12.6 |
Fíjate en el reparto de tareas: WHERE decide qué vuelos cuentan como despegados y HAVING decide qué aeropuertos tienen suficientes.
4. COUNT(*) cuando querías COUNT(columna)
COUNT(*) cuenta filas; COUNT(columna) cuenta valores que no son NULL. En una consulta agrupada esa diferencia suele ser justo lo que importa:
SELECT status, COUNT(*) AS flights, COUNT(actual_departure) AS with_departure
FROM flights
GROUP BY status
ORDER BY flights DESC;
| status | flights | with_departure |
|---|---|---|
| Arrived | 16707 | 16707 |
| Scheduled | 15383 | 0 |
| On Time | 518 | 0 |
| Cancelled | 414 | 8 |
| Departed | 58 | 58 |
| Delayed | 41 | 0 |
Ocho vuelos cancelados tienen una hora de salida registrada, una rareza de los datos que con COUNT(*) solo nunca verías. Un HAVING COUNT(actual_departure) > 0 en esta consulta dejaría tres grupos, no seis.
5. Agrupar por una expresión en un sitio y no en el otro
Si agrupas por fecha, agrupa por la misma expresión que seleccionas:
SELECT CAST(scheduled_departure AS date) AS day, COUNT(*) AS cancelled
FROM flights
WHERE status = 'Cancelled'
GROUP BY CAST(scheduled_departure AS date)
HAVING COUNT(*) >= 15
ORDER BY day;
| day | cancelled |
|---|---|
| 2026-09-07 | 18 |
| 2026-09-08 | 21 |
| 2026-09-09 | 51 |
| 2026-09-10 | 287 |
Agrupar por la marca de tiempo scheduled_departure tal cual daría un grupo por cada minuto de salida, y el HAVING no encontraría nada. (La acumulación al final del calendario merece una segunda mirada antes de que alguien la presente como una crisis: 403 de las 414 cancelaciones están en la programación futura de septiembre).
HAVING sin GROUP BY
Si una consulta tiene agregados y HAVING pero no GROUP BY, todo el resultado se trata como un único grupo. La consulta devuelve una fila o ninguna:
SELECT COUNT(*) AS flights
FROM flights
HAVING COUNT(*) > 30000;
| flights |
|---|
| 33121 |
Cambia el umbral a 40.000 y obtendrás cero filas. De vez en cuando es útil en controles de calidad de datos: una consulta que devuelve una fila solo cuando algo va mal.
Lista rápida para decidir
- ¿La condición usa
COUNT,SUM,AVG,MIN,MAXu otro agregado? Va enHAVING. - ¿Habla de los valores de una sola fila (estado, fecha, precio de un tramo)? Va en
WHERE. - ¿Solo habla de una columna agrupada? Funciona en los dos; mejor
WHERE. - ¿Cada columna del
SELECTaparece enGROUP BYo dentro de un agregado? Si no, la consulta está mal. - En PostgreSQL, ¿has repetido el agregado en
HAVINGen lugar de usar su alias?
Si hay JOIN de por medio, comprueba que las filas no se han multiplicado antes de fiarte de un SUM o un COUNT; lo explica la guía de JOIN en SQL. Cuando necesitas un agregado junto a cada fila en lugar de juntar filas, lo que buscas son las funciones de ventana, no GROUP BY. Y si quieres repasar SELECT, WHERE y ORDER BY desde el principio, empieza por las consultas SQL básicas.
Preguntas frecuentes
¿Se puede usar HAVING sin GROUP BY?
Sí. Todo el resultado se convierte en un único grupo y la consulta devuelve una fila o nada, según la condición de HAVING.
¿HAVING es más lento que WHERE?
En las condiciones sobre agregados no hay elección, así que la velocidad no es la cuestión. En las condiciones sobre columnas sin agregar, WHERE permite descartar filas antes de agrupar, lo que nunca es más lento y a menudo es más rápido. Usa WHERE siempre que la condición no necesite un agregado.
¿Puedo usar un alias de columna en HAVING?
No en PostgreSQL ni en SQL estándar: HAVING se evalúa antes que SELECT, así que el alias todavía no existe. Repite la expresión. MySQL y otros motores permiten el alias como extensión.
¿Se pueden usar WHERE y HAVING en la misma consulta?
Sí, y es lo habitual: WHERE elige las filas que alimentan los agregados y HAVING elige los grupos que se conservan. Los ejemplos de salidas y de retrasos de arriba usan los dos.
¿Qué diferencia hay entre GROUP BY y DISTINCT?
SELECT DISTINCT status FROM flights y SELECT status FROM flights GROUP BY status devuelven los mismos seis valores. GROUP BY además permite calcular agregados por grupo y filtrar grupos con HAVING; DISTINCT solo elimina filas duplicadas.
También en Português (Brasil), Tiếng Việt, English, 简体中文