Ya sabes formar grupos y calcular agregados sobre ellos. Falta la última pieza: filtrar los grupos. No "los pedidos de 2025", que es filtrar filas y eso ya lo hace WHERE, sino "los clientes que han hecho más de un pedido", "las categorías que facturan más de 150 €", "los productos que han vendido más de 8 unidades". Esas condiciones no se pueden evaluar mirando una fila: hay que haber formado el grupo y haber calculado su agregado antes de poder decidir.
Para eso existe HAVING. Su definición cabe en una línea —WHERE filtra filas, HAVING filtra grupos— y todo lo demás se deduce del diagrama del orden lógico que completaste ayer. En esta lección verás por qué HAVING puede usar agregados y WHERE no, por qué aun así WHERE es casi siempre preferible, cómo se combinan ambos en la misma consulta, y cerrarás el hilo que 03-03 dejó abierto con la tabla definitiva de los tres sitios donde se puede filtrar en SQL.
Y con esto se cierra el módulo 4.
Contenido
HAVINGen el orden lógico de ejecución- El primer
HAVING: clientes con más de un pedido - Por qué
HAVINGpuede usar agregados yWHEREno - La misma pregunta resuelta de las dos formas
- Por qué
WHEREes preferible: filtrar antes de agrupar WHEREyHAVINGen la misma consultaHAVINGsinGROUP BY- Varias condiciones en
HAVING HAVINGsobre un agregado que no está en elSELECT- Casos de negocio de TiendaVerde
- Los tres sitios donde se filtra:
ON,WHEREyHAVING - Errores Comunes y Consejos
- Ejercicios
- Conclusión del módulo
HAVING en el orden lógico de ejecución
HAVING en el orden lógico de ejecuciónRecupera el diagrama de 04-05 y fíjate en el paso 4:
flowchart LR
A["1 · FROM / JOIN<br/>de dónde salen las filas"] --> B["2 · WHERE<br/>filtra FILAS<br/>(sin agregados)"]
B --> C["3 · GROUP BY<br/>forma los GRUPOS"]
C --> D["4 · HAVING<br/>filtra GRUPOS<br/>(sí agregados)"]
D --> E["5 · SELECT<br/>proyecta · nacen los alias"]
E --> F["5b · DISTINCT"]
F --> G["6 · ORDER BY"]
G --> H["7 · LIMIT / OFFSET"]
HAVING está después de GROUP BY y antes de SELECT. De esa única posición salen las cinco propiedades de la cláusula:
| Propiedad | Por qué |
|---|---|
| Puede usar funciones de agregación | Cuando se ejecuta, los grupos ya están formados y sus agregados calculados |
Puede usar las columnas del GROUP BY |
Son las que definen cada grupo y tienen un único valor por grupo |
| No puede usar columnas sueltas | Una columna no agrupada no tiene un valor único dentro del grupo. Mismo error que en el SELECT |
No ve los alias del SELECT |
El SELECT es el paso 5, posterior. Hay que repetir el agregado entero |
| Descarta grupos enteros, no filas | Si un grupo no supera la condición, desaparece con todas sus filas |
La sintaxis se coloca siempre entre GROUP BY y ORDER BY:
SELECT columnas, agregados
FROM tablas
WHERE condición_sobre_filas
GROUP BY columnas
HAVING condición_sobre_grupos
ORDER BY …
LIMIT …
- El primer
HAVING: clientes con más de un pedido
HAVING: clientes con más de un pedidoLa pregunta clásica de fidelización: ¿qué clientes han repetido?
SELECT c.id,
c.nombre || ' ' || c.apellidos AS cliente,
c.pais,
COUNT(*) AS pedidos,
MIN(pe.fecha_pedido) AS primer_pedido,
MAX(pe.fecha_pedido) AS ultimo_pedido
FROM pedidos AS pe
JOIN clientes AS c ON pe.cliente_id = c.id
GROUP BY c.id, c.nombre, c.apellidos, c.pais
HAVING COUNT(*) > 1
ORDER BY pedidos DESC, c.id;| id | cliente | pais | pedidos | primer_pedido | ultimo_pedido |
|---|---|---|---|---|---|
| 1 | Lucía Martínez Soler | España | 3 | 2025-03-04 | 2025-12-02 |
| 2 | Carlos Ferrer Ibáñez | España | 2 | 2025-03-12 | 2025-09-09 |
| 4 | Javier Ortega Ruiz | España | 2 | 2025-04-19 | 2025-12-19 |
| 5 | Ana Belmonte Roca | España | 2 | 2025-05-23 | 2026-01-27 |
| 6 | Pau Llorens Vidal | España | 2 | 2025-06-11 | 2026-02-09 |
| 7 | Sofia Moreira Costa | Portugal | 2 | 2025-06-28 | 2026-01-13 |
| 9 | Camille Dubois | Francia | 2 | 2025-08-03 | 2026-02-21 |
7 clientes de 15 han repetido. Sin el HAVING, la consulta devolvería 12 filas (los 12 compradores); con él, se quedan solo los grupos cuyo COUNT(*) supera 1. Se han descartado cinco grupos enteros: Marta, Tiago, Julien, Elena y Diego, cada uno con su único pedido.
Fíjate en que esta condición es imposible de escribir en el WHERE. Al evaluarse el WHERE, la consulta está mirando el pedido 1 de Lucía y no tiene forma de saber que existirán el 5 y el 15: los grupos aún no se han formado.
- Por qué
HAVING puede usar agregados y WHERE no
HAVING puede usar agregados y WHERE noIntentémoslo, para ver el error:
-- ⚠️ INCORRECTA
SELECT cliente_id, COUNT(*) AS pedidos
FROM pedidos
WHERE COUNT(*) > 1
GROUP BY cliente_id;El mensaje es tajante y el diagrama explica por qué. WHERE se ejecuta en el paso 2, cuando el motor está examinando una fila cada vez y no existe todavía ningún grupo. COUNT(*) no significaría nada ahí: ¿el recuento de qué?
HAVING se ejecuta en el paso 4, con los grupos ya construidos y sus agregados ya calculados. Puede preguntar por COUNT(*), SUM(...), AVG(...) o cualquier otro, porque son valores que ya existen.
flowchart TD
A["20 filas de pedidos"] --> B["2 · WHERE<br/>mira una fila cada vez<br/>❌ no hay grupos: COUNT(*) no existe"]
B --> C["3 · GROUP BY cliente_id<br/>12 grupos"]
C --> D["Se calcula COUNT(*)<br/>de cada grupo"]
D --> E["4 · HAVING COUNT(*) > 1<br/>✅ los agregados ya existen<br/>→ 7 grupos"]
Y lo simétrico: HAVING no puede usar una columna que no esté agrupada ni agregada, exactamente igual que el SELECT:
-- ⚠️ INCORRECTA
SELECT cliente_id, COUNT(*) AS pedidos
FROM pedidos
GROUP BY cliente_id
HAVING estado = 'entregado';ERROR: column "pedidos.estado" must appear in the GROUP BY clause or be used in an aggregate function
LINE 4: HAVING estado = 'entregado';
^Es la regla de oro de 04-05, aplicada a HAVING. El grupo del cliente 5 contiene un pedido cancelado y otro pagado: ¿cuál es "el estado" de ese grupo? No hay respuesta. Si lo que querías era filtrar por estado, eso es un filtro de fila y va en el WHERE.
Y una tercera restricción, que ya viste en 04-05: HAVING no acepta alias del SELECT en PostgreSQL.
-- ⚠️ INCORRECTA en PostgreSQL (funciona en MySQL y SQLite)
SELECT cliente_id, COUNT(*) AS pedidos
FROM pedidos
GROUP BY cliente_id
HAVING pedidos > 1;Hay que repetir el agregado: HAVING COUNT(*) > 1. Es redundante y es lo que exige el estándar, porque los alias nacen en el paso 5.
- La misma pregunta resuelta de las dos formas
Cuando la condición recae sobre una columna del GROUP BY, sí se puede escribir en los dos sitios, y el resultado es idéntico. Es el mejor experimento para entender la diferencia.
La pregunta: "¿cuántos pedidos se pagaron con tarjeta?"
Versión A — filtrando filas con WHERE:
-- ✅ CORRECTA y preferible
SELECT metodo_pago,
COUNT(*) AS pedidos,
SUM(gastos_envio) AS portes
FROM pedidos
WHERE metodo_pago = 'tarjeta'
GROUP BY metodo_pago;| metodo_pago | pedidos | portes |
|---|---|---|
| tarjeta | 11 | 52.10 |
Versión B — filtrando grupos con HAVING:
-- ✅ CORRECTA pero peor
SELECT metodo_pago,
COUNT(*) AS pedidos,
SUM(gastos_envio) AS portes
FROM pedidos
GROUP BY metodo_pago
HAVING metodo_pago = 'tarjeta';| metodo_pago | pedidos | portes |
|---|---|---|
| tarjeta | 11 | 52.10 |
Resultado idéntico. Las dos son válidas y metodo_pago es legal en el HAVING porque está en el GROUP BY. Pero no hacen el mismo trabajo:
flowchart TD
subgraph A["Versión A · WHERE"]
A1["20 filas"] --> A2["WHERE metodo_pago='tarjeta'<br/>→ 11 filas"]
A2 --> A3["GROUP BY sobre 11 filas<br/>→ 1 grupo"]
A3 --> A4["✅ 1 fila"]
end
subgraph B["Versión B · HAVING"]
B1["20 filas"] --> B2["GROUP BY sobre las 20<br/>→ 4 grupos"]
B2 --> B3["Se agregan los 4 grupos"]
B3 --> B4["HAVING descarta 3<br/>→ 1 grupo"]
B4 --> B5["✅ 1 fila"]
end
La versión A agrupa 11 filas y forma 1 grupo. La versión B agrupa 20 filas, forma 4 grupos, calcula cuatro sumas y luego tira tres a la basura. Con 20 pedidos la diferencia es imperceptible; con veinte millones, es la diferencia entre una consulta instantánea y una que hace sudar al servidor.
La regla: si la condición se puede evaluar mirando una sola fila, va en el
WHERE. ReservaHAVINGpara lo que exige haber calculado un agregado.
- Por qué
WHERE es preferible: filtrar antes de agrupar
WHERE es preferible: filtrar antes de agruparEl principio se enunció en 02-03 y aquí alcanza su expresión más clara: filtrar pronto es filtrar barato. Cada fila descartada por el WHERE es una fila que no hay que ordenar, ni meter en una tabla hash, ni agregar.
| Aspecto | WHERE (antes de agrupar) |
HAVING (después de agrupar) |
|---|---|---|
Filas que llegan al GROUP BY |
Solo las que pasan el filtro | Todas |
| Grupos que se construyen | Solo los necesarios | Todos, y luego se descartan |
| Agregados que se calculan | Solo los que se van a mostrar | Todos, incluidos los que se tirarán |
| Puede aprovechar un índice | Sí | No: actúa sobre resultados calculados |
| Puede usar agregados | No | Sí |
Ese punto del índice es el más importante en tablas grandes. Un WHERE fecha_pedido >= '2025-01-01' puede resolverse con un índice sobre fecha_pedido, leyendo solo las filas relevantes. Un HAVING nunca puede: opera sobre valores que no existían hasta que la consulta los calculó.
Verlo requiere EXPLAIN, la herramienta que compara planes de ejecución, y eso es el módulo 8. Pero la intuición ya la tienes: en la versión A del apartado anterior, PostgreSQL puede descartar 9 de las 20 filas antes de tocar el GROUP BY; en la versión B, no puede descartar ninguna.
Consejo práctico: cuando escribas una consulta con
GROUP BY, repasa cada condición delHAVINGy pregúntate: "¿esto depende de más de una fila?". Si la respuesta es no, muévelo alWHERE. Es la optimización más barata que existe: no requiere índices, ni configuración, ni entender el planificador.
WHERE y HAVING en la misma consulta
WHERE y HAVING en la misma consultaLo habitual no es elegir entre uno y otro: es usar los dos, cada uno para lo suyo. WHERE acota el universo de filas, HAVING selecciona qué grupos merecen aparecer.
La pregunta: "de las ventas de 2025, ¿qué clientes gastaron más de 50 €?"
SELECT c.id,
c.nombre || ' ' || c.apellidos AS cliente,
c.pais,
COUNT(DISTINCT pe.id) AS pedidos_2025,
COUNT(*) AS lineas,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS total_2025
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id
JOIN clientes AS c ON pe.cliente_id = c.id
WHERE pe.fecha_pedido >= DATE '2025-01-01'
AND pe.fecha_pedido < DATE '2026-01-01'
GROUP BY c.id, c.nombre, c.apellidos, c.pais
HAVING SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)) > 50
ORDER BY total_2025 DESC, c.id;| id | cliente | pais | pedidos_2025 | lineas | total_2025 |
|---|---|---|---|---|---|
| 1 | Lucía Martínez Soler | España | 3 | 9 | 107.60 |
| 10 | Julien Moreau | Francia | 1 | 3 | 66.90 |
| 7 | Sofia Moreira Costa | Portugal | 1 | 3 | 64.88 |
| 4 | Javier Ortega Ruiz | España | 2 | 4 | 62.93 |
| 2 | Carlos Ferrer Ibáñez | España | 2 | 4 | 59.46 |
5 filas. El reparto de responsabilidades es perfectamente nítido:
| Cláusula | Condición | Qué descarta |
|---|---|---|
WHERE |
fecha_pedido en 2025 |
Las 7 líneas de los 4 pedidos de 2026. Se evalúa fila a fila |
GROUP BY |
Por cliente | Forma 12 grupos con las 40 líneas supervivientes |
HAVING |
Suma > 50 € | Descarta 7 grupos enteros cuyo total no llega a 50 € |
Sin el HAVING el resultado tendría 12 filas; sin el WHERE, los totales incluirían 2026 y Sofia subiría de 64,88 € a 111,88 €, cambiando incluso el orden del ranking. Los dos filtros son necesarios y ninguno puede sustituir al otro.
Y observa un detalle de escritura: la expresión del importe está repetida en el SELECT y en el HAVING. Es obligatorio (el alias total_2025 no existe aún) y es feo. Las CTE del módulo 10 lo resolverán definitivamente.
HAVING sin GROUP BY
HAVING sin GROUP BYHAVING puede aparecer sin GROUP BY. Cuando eso ocurre, SQL trata toda la tabla como un único grupo, exactamente igual que un agregado sin GROUP BY (04-04, sección 8). El resultado es una consulta que devuelve una fila o ninguna.
SELECT COUNT(*) AS productos,
ROUND(AVG(precio), 2) AS precio_medio
FROM productos
HAVING COUNT(*) > 5;| productos | precio_medio |
|---|---|
| 20 | 9.04 |
Como el catálogo tiene 20 productos y 20 > 5, el grupo único sobrevive y sale su fila. Ahora con una condición que no se cumple:
SELECT COUNT(*) AS productos,
ROUND(AVG(precio), 2) AS precio_medio
FROM productos
HAVING COUNT(*) > 100;Cero filas. Es la única forma de que una consulta agregada sin GROUP BY no devuelva ninguna, y por eso desconcierta: sin HAVING, SELECT COUNT(*) FROM productos WHERE 1 = 0 devolvería una fila con un 0.
¿Para qué sirve? Casi para nada en el día a día. Su único uso razonable es como guardia de una comprobación: "devuélveme el resumen solo si hay datos suficientes", o "avísame solo si el total supera un umbral". Fuera de eso, un HAVING sin GROUP BY suele ser un WHERE mal escrito:
Regla práctica: si escribes
HAVINGy no hayGROUP BYen la consulta, párate y comprueba que era eso lo que querías. En nueve de cada diez casos, no lo era.
- Varias condiciones en
HAVING
HAVINGHAVING acepta condiciones compuestas con AND, OR, NOT y paréntesis, con la misma precedencia y las mismas trampas que el WHERE de 02-03. AND se evalúa antes que OR, y mezclarlos sin paréntesis produce resultados incorrectos sin dar ningún error.
La pregunta: "categorías con al menos 10 líneas de venta y más de 150 € facturados".
SELECT cat.id,
cat.nombre AS categoria,
COUNT(*) AS lineas,
SUM(lp.cantidad) AS unidades,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM lineas_pedido AS lp
JOIN productos AS p ON lp.producto_id = p.id
JOIN categorias AS cat ON p.categoria_id = cat.id
GROUP BY cat.id, cat.nombre
HAVING COUNT(*) >= 10
AND SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)) > 150
ORDER BY facturacion DESC, cat.id;| id | categoria | lineas | unidades | facturacion |
|---|---|---|---|---|
| 1 | Alimentación | 16 | 49 | 256.27 |
| 4 | Bebidas | 11 | 29 | 195.28 |
| 2 | Cosmética natural | 10 | 16 | 156.32 |
3 filas de 5. Se han descartado Hogar sostenible (7 líneas, no llega a 10) e Higiene personal (3 líneas y 31,50 €, falla las dos condiciones).
Y ahora la versión con OR, que responde a otra pregunta:
daría las mismas 3 filas en este conjunto de datos —las tres cumplen ambas condiciones—, lo cual es una coincidencia peligrosa: si mañana una categoría alcanzara 12 líneas con 90 € de facturación, AND la excluiría y OR la incluiría. Que dos consultas devuelvan lo mismo hoy no significa que sean equivalentes.
Recordatorio de 02-03: siempre que en un
HAVINGconvivanANDyOR, pon paréntesis. La precedencia es la misma y el daño también.
HAVING sobre un agregado que no está en el SELECT
HAVING sobre un agregado que no está en el SELECTEs perfectamente legal filtrar por un agregado que no muestras. También es una de las cosas que más confunden a quien lee la consulta después.
SELECT cat.id,
cat.nombre AS categoria,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM lineas_pedido AS lp
JOIN productos AS p ON lp.producto_id = p.id
JOIN categorias AS cat ON p.categoria_id = cat.id
GROUP BY cat.id, cat.nombre
HAVING SUM(lp.cantidad) > 25
ORDER BY facturacion DESC, cat.id;| id | categoria | facturacion |
|---|---|---|
| 1 | Alimentación | 256.27 |
| 4 | Bebidas | 195.28 |
2 filas, y quien lea este resultado no puede saber por qué. Ni Cosmética natural (156,32 €) ni Hogar sostenible aparecen, pese a facturar más que muchas cosas. La razón está escondida en el HAVING: solo Alimentación (49 unidades) y Bebidas (29 unidades) superan las 25 unidades vendidas, y esa columna no se muestra.
Es legal, funciona y a veces es lo que quieres (un informe ejecutivo no tiene por qué enseñar los criterios de corte). Pero como norma de higiene:
Si filtras por un agregado, muéstralo. Añadir
SUM(lp.cantidad) AS unidadesalSELECTcuesta una línea y convierte un resultado misterioso en uno autoexplicativo. Tu yo de dentro de seis meses te lo agradecerá.
- Casos de negocio de TiendaVerde
Cinco preguntas reales, resueltas.
10.1. Categorías con más de tres productos en catálogo
SELECT cat.id,
cat.nombre AS categoria,
COUNT(*) AS productos,
ROUND(AVG(p.precio), 2) AS precio_medio
FROM productos AS p
JOIN categorias AS cat ON p.categoria_id = cat.id
GROUP BY cat.id, cat.nombre
HAVING COUNT(*) > 3
ORDER BY productos DESC, cat.id;| id | categoria | productos | precio_medio |
|---|---|---|---|
| 1 | Alimentación | 5 | 6.18 |
| 2 | Cosmética natural | 4 | 11.54 |
| 3 | Hogar sostenible | 4 | 10.09 |
| 4 | Bebidas | 4 | 8.90 |
4 categorías de 6. Quedan fuera Higiene personal (2 productos) y Complementos (1). Es el diagnóstico de un catálogo con dos áreas claramente infradesarrolladas.
10.2. Productos que han vendido más de 8 unidades
SELECT p.id,
p.nombre AS producto,
SUM(lp.cantidad) AS unidades,
COUNT(*) AS veces_vendido,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM lineas_pedido AS lp
JOIN productos AS p ON lp.producto_id = p.id
GROUP BY p.id, p.nombre
HAVING SUM(lp.cantidad) > 8
ORDER BY unidades DESC, p.id;| id | producto | unidades | veces_vendido | facturacion |
|---|---|---|---|---|
| 2 | Arroz integral ecológico 1 kg | 14 | 4 | 54.60 |
| 5 | Tomate triturado ecológico 400 g | 14 | 2 | 23.79 |
| 16 | Kombucha de jengibre 750 ml | 12 | 3 | 56.43 |
| 1 | Aceite de oliva virgen extra 500 ml | 9 | 5 | 109.53 |
| 14 | Infusión de manzanilla ecológica 20 uds | 9 | 3 | 29.25 |
| 18 | Cepillo de dientes de bambú | 9 | 3 | 31.50 |
6 productos de los 17 que se han vendido alguna vez. Son los de mayor rotación, y el equipo de compras debería vigilar su stock con prioridad.
10.3. Categorías cuya facturación supera los 150 €
SELECT cat.id,
cat.nombre AS categoria,
COUNT(*) AS lineas,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM lineas_pedido AS lp
JOIN productos AS p ON lp.producto_id = p.id
JOIN categorias AS cat ON p.categoria_id = cat.id
GROUP BY cat.id, cat.nombre
HAVING SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)) > 150
ORDER BY facturacion DESC, cat.id;| id | categoria | lineas | facturacion |
|---|---|---|---|
| 1 | Alimentación | 16 | 256.27 |
| 4 | Bebidas | 11 | 195.28 |
| 2 | Cosmética natural | 10 | 156.32 |
3 categorías concentran 607,87 € de los 727,95 € facturados, un 83,5 %. Es la regla de Pareto asomando en un conjunto de datos de veinte pedidos.
10.4. Comerciales con más de tres pedidos gestionados
SELECT e.id,
e.nombre || ' ' || e.apellidos AS comercial,
e.puesto,
COUNT(*) AS pedidos,
SUM(pe.gastos_envio) AS portes
FROM pedidos AS pe
JOIN empleados AS e ON pe.empleado_id = e.id
GROUP BY e.id, e.nombre, e.apellidos, e.puesto
HAVING COUNT(*) > 3
ORDER BY pedidos DESC, e.id;| id | comercial | puesto | pedidos | portes |
|---|---|---|---|---|
| 4 | Óscar Peris Blasco | Comercial | 4 | 22.40 |
| 5 | Laia Puig Sanchis | Comercial | 4 | 32.30 |
2 filas. Aquí conviene recordar el aviso de 04-05: esta consulta usa INNER JOIN, así que los 10 pedidos del canal web no están y los cinco empleados sin pedidos tampoco. Para este informe concreto da igual —preguntamos por quien gestiona más de tres, y quien gestiona cero no es candidato—, pero si la pregunta fuera "reparto de la carga comercial", faltaría la mitad de la empresa.
10.5. Clientes cuyo ticket medio supera la media general
Esta es la que no se puede resolver todavía, y merece la pena entender exactamente por qué.
Empecemos por lo que sí sabemos calcular. El ticket medio de cada cliente:
SELECT c.id,
c.nombre || ' ' || c.apellidos AS cliente,
COUNT(DISTINCT pe.id) AS pedidos,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento))
/ COUNT(DISTINCT pe.id), 2) AS ticket_medio
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id
JOIN clientes AS c ON pe.cliente_id = c.id
GROUP BY c.id, c.nombre, c.apellidos
ORDER BY ticket_medio DESC, c.id;| id | cliente | pedidos | ticket_medio |
|---|---|---|---|
| 10 | Julien Moreau | 1 | 66.90 |
| 7 | Sofia Moreira Costa | 2 | 55.94 |
| 8 | Tiago Almeida Nunes | 1 | 44.60 |
| 1 | Lucía Martínez Soler | 3 | 35.87 |
| 9 | Camille Dubois | 2 | 35.44 |
| 12 | Diego Ramos Herrera | 1 | 31.70 |
| 4 | Javier Ortega Ruiz | 2 | 31.47 |
| 11 | Elena Navarro Puig | 1 | 30.30 |
| 2 | Carlos Ferrer Ibáñez | 2 | 29.73 |
| 3 | Marta Sanchis Gil | 1 | 29.53 |
| 6 | Pau Llorens Vidal | 2 | 28.67 |
| 5 | Ana Belmonte Roca | 2 | 27.43 |
Y también sabemos calcular la media general de los 20 tickets, en otra consulta: 36,40 €.
Lo que no podemos hacer es escribir HAVING ... > (la media general) dentro de la misma consulta, porque ese valor es a su vez un agregado calculado sobre otro conjunto de filas. HAVING puede comparar el agregado de un grupo con una constante (> 50) o con otro agregado del mismo grupo (SUM(a) > SUM(b)), pero no con un agregado global.
La solución es una subconsulta que calcule la media general y la inyecte en el HAVING, y eso es la lección 07-01. Allí escribirás algo con esta forma:
-- Avance de 07-01: no lo escribas aún
HAVING SUM(...) / COUNT(DISTINCT pe.id) > (SELECT AVG(...) FROM ...)Cuando llegues, sabrás que la respuesta son tres clientes —Julien Moreau, Sofia Moreira Costa y Tiago Almeida Nunes—, los únicos que superan los 36,40 € de ticket medio general. De momento, la forma honesta de resolverlo es en dos consultas y comparando a mano; saber cuándo una pregunta necesita una herramienta que aún no tienes es tan importante como saber usar las que ya tienes.
- Los tres sitios donde se filtra:
ON, WHERE y HAVING
ON, WHERE y HAVINGAquí se cierra el hilo que 03-03 dejó abierto. SQL tiene tres lugares donde poner una condición, y cada uno actúa en un momento distinto del orden lógico:
flowchart LR
A["FROM<br/>tablas"] --> B["ON<br/>① condiciona el<br/>EMPAREJAMIENTO"]
B --> C["WHERE<br/>② filtra FILAS<br/>ya emparejadas"]
C --> D["GROUP BY<br/>forma grupos"]
D --> E["HAVING<br/>③ filtra GRUPOS"]
E --> F["SELECT"]
ON |
WHERE |
HAVING |
|
|---|---|---|---|
| Cuándo actúa | Paso 1, dentro del FROM |
Paso 2 | Paso 4 |
| Sobre qué actúa | Parejas candidatas de filas | Filas del resultado del FROM |
Grupos |
| ¿Puede usar agregados? | No | No | Sí |
¿Puede usar alias del SELECT? |
No | No | No (en PostgreSQL) |
| ¿Puede referirse a columnas no agrupadas? | Sí | Sí | No |
Efecto en un INNER JOIN |
Equivalente a ponerlo en WHERE |
Equivalente a ponerlo en ON |
— |
Efecto en un LEFT JOIN |
Conserva las filas izquierdas sin pareja | Degrada el LEFT a INNER |
— |
| Pregunta típica | "Todos los X con sus Y que cumplan Z" | "Solo las filas que cumplen Z" | "Solo los grupos que cumplen Z" |
El árbol de decisión
flowchart TD
Q{"¿Qué quiero filtrar?"}
Q -->|"Qué filas de la tabla derecha<br/>se emparejan en un OUTER JOIN"| ON["ON<br/>(03-03)"]
Q -->|"Qué filas individuales<br/>entran en el cálculo"| W["WHERE<br/>(02-03)"]
Q -->|"Qué grupos aparecen<br/>en el resultado"| H["HAVING<br/>(04-06)"]
W --> N["Si la condición<br/>necesita un agregado,<br/>NO cabe aquí → HAVING"]
H --> M["Si la condición se puede<br/>evaluar fila a fila,<br/>NO debería estar aquí → WHERE"]
Y la comprobación final con los tres a la vez, en una única consulta de TiendaVerde:
SELECT c.id,
c.nombre || ' ' || c.apellidos AS cliente,
COUNT(pe.id) AS pedidos_entregados
FROM clientes AS c
LEFT JOIN pedidos AS pe
ON pe.cliente_id = c.id -- ① ON: solo empareja los entregados
AND pe.estado = 'entregado'
WHERE c.pais = 'España' -- ② WHERE: solo clientes españoles
GROUP BY c.id, c.nombre, c.apellidos
HAVING COUNT(pe.id) >= 2 -- ③ HAVING: solo quien tiene 2 o más
ORDER BY pedidos_entregados DESC, c.id;| id | cliente | pedidos_entregados |
|---|---|---|
| 1 | Lucía Martínez Soler | 3 |
| 2 | Carlos Ferrer Ibáñez | 2 |
2 filas, y las tres cláusulas han hecho un trabajo distinto e insustituible:
- El
ONha restringido el emparejamiento a los pedidos entregados, sin eliminar a los clientes que no tienen ninguno (si esa condición hubiera ido alWHERE, elLEFT JOINse habría degradado aINNER, tal como demostró 03-03). - El
WHEREha filtrado por una columna de la tabla izquierda, que es seguro y no degrada nada: quedan los 11 clientes españoles. - El
HAVINGha descartado los 9 clientes españoles con menos de dos pedidos entregados — entre ellos Ana Belmonte Roca, que tiene dos pedidos pero ninguno entregado, y Núria, Hugo e Inés, que aparecían con un honesto 0 gracias alLEFT JOINy aCOUNT(pe.id).
Toda la lógica de filtrado del curso, en once líneas.
Errores Comunes y Consejos
- Poner un agregado en el
WHERE.ERROR: aggregate functions are not allowed in WHERE. Va enHAVING. - Poner en el
HAVINGuna condición que se puede evaluar fila a fila. Funciona, pero agrupa filas que iban a tirarse. Muévelo alWHERE. - Usar en
HAVINGuna columna que no está en elGROUP BY.column ... must appear in the GROUP BY clause. Si es un filtro de fila, va en elWHERE. - Usar un alias del
SELECTen elHAVING.column "..." does not existen PostgreSQL, aunque MySQL y SQLite lo permitan. Repite el agregado. - Escribir
HAVINGsinGROUP BYpor error. Trata toda la tabla como un grupo y devuelve una fila o ninguna. Casi siempre querías unWHERE. - Mezclar
ANDyORen elHAVINGsin paréntesis. Misma precedencia y mismo daño silencioso que en elWHERE(02-03). - Filtrar por un agregado que no muestras. Es legal y deja un resultado incomprensible. Añádelo al
SELECT. - Olvidar que un
INNER JOINen la consulta ya ha descartado grupos. ElHAVINGfiltra lo que le llega; si elJOINperdió filas antes, el informe ya estaba incompleto (04-05, sección 10). - Intentar comparar el agregado de un grupo con un agregado global. Necesita subconsulta: 07-01.
- Consejo: escribe la consulta por capas. Primero el
FROMcon susJOINy cuenta filas; luego elWHERE; luego elGROUP BYy comprueba el número de grupos; y solo al final elHAVING. Depurar una consulta agregada escrita de una tacada es un suplicio. - Consejo: comprueba cuántos grupos descarta el
HAVING. Ejecuta la consulta sin él y compara: 12 clientes contra 7, 5 categorías contra 3. Ese delta te confirma que la condición hace lo que crees. - Consejo: lee la pregunta de negocio buscando el sujeto. "Clientes que hayan hecho más de un pedido" → el sujeto es el cliente y la condición es sobre su conjunto de pedidos:
GROUP BYcliente +HAVING. "Pedidos de más de 50 €" → el sujeto es el pedido: puede que seaWHERE.
Ejercicios
Ejercicio 1
Compras quiere identificar proveedores con catálogo relevante. Escribe una consulta que devuelva, para cada proveedor con al menos 4 productos en catálogo: su nombre, su país, el número de productos, el precio medio (dos decimales) y el precio del producto más caro.
Ordena por número de productos descendente. Después responde: ¿qué proveedor queda fuera y por qué?
Ejercicio 2
Dirección quiere saber en qué meses de 2025 se superaron los 60 € de facturación. Escribe una única consulta que use WHERE y HAVING, mostrando el mes (AAAA-MM), el número de pedidos distintos, el de líneas y la facturación.
Después indica, para cada una de las tres cláusulas de filtrado, qué condición le corresponde y por qué no podría ir en las otras.
Ejercicio 3
Un compañero te pasa esta consulta con el comentario "quiero las categorías con más de 20 unidades vendidas, pero me faltan categorías y no sé si el número está bien":
-- ⚠️ INCORRECTA
SELECT cat.nombre AS categoria,
SUM(lp.cantidad) AS unidades,
SUM(pe.gastos_envio) AS portes
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id
JOIN productos AS p ON lp.producto_id = p.id
JOIN categorias AS cat ON p.categoria_id = cat.id
GROUP BY cat.nombre
HAVING SUM(lp.cantidad) > 20;- ¿Qué categorías devuelve y qué categorías "faltan"? ¿Es un error del
HAVING? - La columna
portesestá mal. Explica por qué y calcula cuánto vale realmente para Alimentación frente a lo que debería valer. - Reescribe la consulta corrigiendo lo que se pueda corregir hoy e indica qué parte necesita el módulo 7.
Soluciones
Solución 1
SELECT pr.id,
pr.nombre AS proveedor,
pr.pais,
COUNT(*) AS productos,
ROUND(AVG(p.precio), 2) AS precio_medio,
MAX(p.precio) AS mas_caro
FROM productos AS p
JOIN proveedores AS pr ON p.proveedor_id = pr.id
GROUP BY pr.id, pr.nombre, pr.pais
HAVING COUNT(*) >= 4
ORDER BY productos DESC, pr.id;| id | proveedor | pais | productos | precio_medio | mas_caro |
|---|---|---|---|---|---|
| 1 | Huerta del Turia | España | 5 | 5.74 | 12.50 |
| 3 | Verde Atlántico | Portugal | 4 | 12.91 | 22.00 |
| 4 | Maison Nature | Francia | 4 | 9.93 | 18.90 |
| 5 | EcoNordic Supplies | Alemania | 4 | 11.21 | 16.40 |
4 filas. Queda fuera BioSierra Ibérica, que solo suministra 3 productos (la miel, la pasta de espelta y la infusión de manzanilla) y no llega al umbral de 4. Comprobación: 5 + 4 + 4 + 4 + 3 = 20 productos.
Dos observaciones sobre estos precios medios, que ahora sí son los de catálogo: son distintos de los que salían en la solución 2 de 04-05 (6,73 € para Huerta del Turia en lugar de 5,74 €). La diferencia es que aquí partimos de productos y cada referencia cuenta una vez; allí partíamos de un LEFT JOIN con lineas_pedido y cada referencia contaba tantas veces como se hubiera vendido. Las dos medias son correctas y responden a preguntas distintas: "precio medio del catálogo" frente a "precio medio de lo que se vende". Comprobar de qué tabla parte una consulta antes de interpretar su media es un hábito que evita muchos disgustos.
Y fíjate en que EcoNordic Supplies aparece pese a estar inactivo: la consulta no filtra por pr.activo. Si la pregunta fuera "proveedores operativos con catálogo relevante", habría que añadir WHERE pr.activo — un filtro de fila, no de grupo.
Solución 2
SELECT TO_CHAR(pe.fecha_pedido, 'YYYY-MM') AS mes,
COUNT(DISTINCT pe.id) AS pedidos,
COUNT(*) AS lineas,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id
WHERE pe.fecha_pedido >= DATE '2025-01-01'
AND pe.fecha_pedido < DATE '2026-01-01'
GROUP BY TO_CHAR(pe.fecha_pedido, 'YYYY-MM')
HAVING SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)) > 60
ORDER BY facturacion DESC, mes;| mes | pedidos | lineas | facturacion |
|---|---|---|---|
| 2025-10 | 2 | 5 | 97.20 |
| 2025-06 | 2 | 5 | 95.48 |
| 2025-03 | 2 | 5 | 68.80 |
| 2025-12 | 2 | 5 | 64.58 |
| 2025-04 | 2 | 5 | 61.28 |
5 meses de los 10 de 2025 superaron los 60 €. Y aparece un patrón interesante: los cinco son meses con 2 pedidos y 5 líneas. Con este volumen, facturar bien un mes es simplemente que caigan dos pedidos en lugar de uno.
Reparto de responsabilidades:
| Cláusula | Condición | Por qué va aquí |
|---|---|---|
ON |
lp.pedido_id = pe.id |
Es el emparejamiento, no un filtro. Sin él no hay consulta |
WHERE |
Fechas de 2025 | Se evalúa fila a fila: cada línea sabe la fecha de su pedido. Descartarla antes evita agrupar las 7 líneas de 2026. Además puede usar un índice sobre fecha_pedido |
HAVING |
Facturación > 60 € | Necesita el SUM del mes entero. Ninguna línea individual puede decidir si su mes supera los 60 € |
La condición del WHERE no podría ir en el HAVING: pe.fecha_pedido no está en el GROUP BY (agrupamos por mes, no por día), así que daría el error must appear in the GROUP BY clause. Y la del HAVING no podría ir en el WHERE: aggregate functions are not allowed in WHERE. Cada una solo cabe donde está.
Solución 3
1. Qué devuelve y qué falta.
| categoria | unidades | portes |
|---|---|---|
| Alimentación | 49 | 86.75 |
| Bebidas | 29 | 90.10 |
2 filas. Las categorías que "faltan" son:
- Cosmética natural (16 unidades), Hogar sostenible (10) e Higiene personal (9): no faltan por error, es el
HAVINGhaciendo su trabajo. Ninguna supera las 20 unidades. Correcto. - Complementos (0 unidades): esta sí falta por un motivo distinto. No la descarta el
HAVING, la descartó elINNER JOINmucho antes, en el paso 1. Es el problema de 04-05 sección 10. Ahora bien: como su condición sería0 > 20, tampoco habría aparecido conLEFT JOIN. El resultado es el mismo, pero por razones distintas, y esa distinción importa — con el umbral en> 20da igual, con el umbral en>= 0no daría igual en absoluto.
2. Por qué portes está mal. gastos_envio vive en pedidos, y tras el JOIN con lineas_pedido cada pedido aparece tantas veces como líneas tenga. Es el error de 04-04 sección 11, ahora dentro de un GROUP BY.
Para Alimentación, la consulta da 86,75 €; para Bebidas, 90,10 €. Sumadas todas las categorías darían 278,70 €, el mismo número inflado de 04-04 sección 11, frente a los 118,25 € reales.
¿Y qué debería dar? La pregunta ni siquiera está bien planteada: los gastos de envío no se pueden repartir por categoría, porque un pedido con productos de tres categorías paga unos portes, no tres. Los 4,95 € del pedido 1 no son "4,95 € de Alimentación": son 4,95 € del pedido, y el pedido 1 lleva productos de Alimentación y de Bebidas.
Ese es el punto más profundo del ejercicio. El número está mal, pero el problema real es que la métrica no existe al nivel de granularidad que se pide. Es exactamente el tipo de pregunta que hay que devolver a quien la formula.
3. La versión corregida:
-- ✅ CORRECTA para lo que sí se puede responder hoy
SELECT cat.id,
cat.nombre AS categoria,
COUNT(*) AS lineas,
SUM(lp.cantidad) AS unidades,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS facturacion
FROM lineas_pedido AS lp
JOIN productos AS p ON lp.producto_id = p.id
JOIN categorias AS cat ON p.categoria_id = cat.id
GROUP BY cat.id, cat.nombre
HAVING SUM(lp.cantidad) > 20
ORDER BY unidades DESC, cat.id;| id | categoria | lineas | unidades | facturacion |
|---|---|---|---|---|
| 1 | Alimentación | 16 | 49 | 256.27 |
| 4 | Bebidas | 11 | 29 | 195.28 |
Cambios aplicados:
| Cambio | Motivo |
|---|---|
Se elimina el JOIN con pedidos |
Ya no hace falta: sin gastos_envio, lineas_pedido y productos bastan |
Se elimina SUM(pe.gastos_envio) |
La métrica no existe a nivel de categoría |
Se añade facturacion, que sí vive en la línea |
Es la métrica correcta a esta granularidad |
Se añade cat.id al GROUP BY y al ORDER BY |
Desempate determinista y agrupación por PK |
Qué necesita el módulo 7. Si la pregunta fuera "¿cuánto de los portes es atribuible a cada categoría, prorrateado por el peso de cada categoría en el importe del pedido?", eso es una métrica legítima pero exige calcular primero el total de cada pedido, luego el peso de cada línea sobre ese total, y después repartir. Ese "calcular un agregado y usarlo en otro cálculo" es precisamente lo que resuelven las subconsultas del módulo 7 y las CTE del módulo 10.
Conclusión del módulo
HAVING cierra la lógica de filtrado de SQL:
WHEREfiltra filas,HAVINGfiltra grupos. Todo lo demás se deduce de su posición en el orden lógico:FROM/JOIN→WHERE→GROUP BY→HAVING→SELECT→DISTINCT→ORDER BY→LIMIT.HAVINGpuede usar agregados yWHEREno, porque cuando se ejecuta elWHERElos grupos aún no existen. YHAVINGno puede usar columnas no agrupadas, por la misma regla de oro que elSELECT.- Cuando la condición recae sobre una columna del
GROUP BY, las dos escrituras dan el mismo resultado pero no el mismo trabajo:WHEREagrupa 11 filas,HAVINGagrupa 20 y tira 3 grupos. Filtrar antes de agrupar es siempre preferible, y además es lo único que puede aprovechar un índice (EXPLAIN, módulo 8). - Lo normal es usar los dos a la vez:
WHEREacota el universo (2025) yHAVINGselecciona los grupos que importan (más de 50 €). HAVINGsinGROUP BYtrata la tabla entera como un grupo y devuelve una fila o ninguna. Casi siempre es unWHEREmal escrito.- Los tres sitios donde se filtra son
ON(condiciona el emparejamiento y conserva las filas izquierdas de unLEFT JOIN),WHERE(filtra filas y degrada elLEFTaINNERsi toca la tabla derecha) yHAVING(filtra grupos). El hilo abierto en 03-03 queda cerrado. - Has resuelto los casos de negocio reales de TiendaVerde: 7 clientes recurrentes, 4 categorías con más de 3 productos, 6 productos con más de 8 unidades vendidas, 3 categorías que concentran el 83,5 % de la facturación. Y sabes qué preguntas todavía no puedes responder: comparar el agregado de un grupo con un agregado global exige subconsultas (07-01).
Y con esto se cierra el módulo 4. Repasa lo que has ganado en seis lecciones: buscas por patrones de texto con LIKE, ILIKE y expresiones regulares, y sabes cuál de ellos puede usar un índice; filtras por listas y rangos con IN y BETWEEN, y conoces el error más caro de SQL —NOT IN con un NULL—; entiendes la lógica de tres valores y con ella se te han resuelto de golpe cinco misterios que arrastrabas desde el módulo 2; calculas con COUNT, SUM, AVG, MIN y MAX, y sabes que todas ignoran los nulos menos COUNT(*); partes los datos en grupos con GROUP BY combinado con JOIN, que es el patrón central de todo el análisis de datos; y filtras esos grupos con HAVING. De propina, has liquidado el aviso que el módulo 3 te repitió tres veces: 278,70 € frente a 118,25 €, y sabes exactamente por qué.
Hasta aquí, sin embargo, solo has leído. Las cuatro instrucciones que has usado —SELECT, FROM, WHERE, GROUP BY— pertenecen todas al mismo sublenguaje, el DQL del que hablaba 01-01, y ninguna de ellas ha cambiado jamás un solo byte de TiendaVerde. Podrías haber trabajado todo el curso sobre una base de datos de solo lectura y no habrías notado la diferencia.
Pero una tienda que no puede dar de alta un producto, registrar un pedido, corregir un precio ni cancelar una compra no es una tienda: es un catálogo muerto. En el módulo 5, Manipulación de datos, cruzarás al otro lado. Aprenderás a crear tablas con CREATE TABLE —con los tipos, las claves y las restricciones que llevas cinco módulos leyendo en el esquema de TiendaVerde—, a insertar filas con INSERT, a modificarlas con UPDATE, a eliminarlas con DELETE, a resolver el clásico "insértalo si no existe y actualízalo si existe" con UPSERT, y a cambiar la estructura de una tabla en producción con ALTER TABLE. Cambia también el nivel de responsabilidad: un SELECT mal escrito devuelve un número equivocado, pero un UPDATE sin WHERE modifica las veinte filas de la tabla y no hay forma de deshacerlo. Ese WHERE que llevas cuatro módulos afinando deja de ser una cuestión de precisión analítica y pasa a ser tu red de seguridad.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
