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

  1. HAVING en el orden lógico de ejecución
  2. El primer HAVING: clientes con más de un pedido
  3. Por qué HAVING puede usar agregados y WHERE no
  4. La misma pregunta resuelta de las dos formas
  5. Por qué WHERE es preferible: filtrar antes de agrupar
  6. WHERE y HAVING en la misma consulta
  7. HAVING sin GROUP BY
  8. Varias condiciones en HAVING
  9. HAVING sobre un agregado que no está en el SELECT
  10. Casos de negocio de TiendaVerde
  11. Los tres sitios donde se filtra: ON, WHERE y HAVING
  12. Errores Comunes y Consejos
  13. Ejercicios
  14. Conclusión del módulo

  1. HAVING en el orden lógico de ejecución

Recupera 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 …

  1. El primer HAVING: clientes con más de un pedido

La 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.

  1. Por qué HAVING puede usar agregados y WHERE no

Intentémoslo, para ver el error:

-- ⚠️ INCORRECTA
SELECT cliente_id, COUNT(*) AS pedidos
FROM pedidos
WHERE COUNT(*) > 1
GROUP BY cliente_id;
ERROR:  aggregate functions are not allowed in WHERE
LINE 3: WHERE COUNT(*) > 1
              ^

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;
ERROR:  column "pedidos" does not exist
LINE 4: 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.

  1. 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. Reserva HAVING para lo que exige haber calculado un agregado.

  1. Por qué WHERE es preferible: filtrar antes de agrupar

El 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 No: actúa sobre resultados calculados
Puede usar agregados No

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 del HAVING y pregúntate: "¿esto depende de más de una fila?". Si la respuesta es no, muévelo al WHERE. Es la optimización más barata que existe: no requiere índices, ni configuración, ni entender el planificador.

  1. WHERE y HAVING en la misma consulta

Lo 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.

  1. HAVING sin GROUP BY

HAVING 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;
(0 filas)

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:

-- ⚠️ Probablemente querías esto otro
SELECT COUNT(*) FROM productos WHERE precio > 5;

Regla práctica: si escribes HAVING y no hay GROUP BY en la consulta, párate y comprueba que era eso lo que querías. En nueve de cada diez casos, no lo era.

  1. Varias condiciones en HAVING

HAVING 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:

HAVING COUNT(*) >= 10
    OR SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)) > 150

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 HAVING convivan AND y OR, pon paréntesis. La precedencia es la misma y el daño también.

  1. HAVING sobre un agregado que no está en el SELECT

Es 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 unidades al SELECT cuesta una línea y convierte un resultado misterioso en uno autoexplicativo. Tu yo de dentro de seis meses te lo agradecerá.

  1. 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.

-- ⚠️ Esto NO es SQL válido
HAVING AVG(...) > AVG(...)_de_toda_la_tabla

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.

  1. Los tres sitios donde se filtra: ON, WHERE y HAVING

Aquí 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
¿Puede usar alias del SELECT? No No No (en PostgreSQL)
¿Puede referirse a columnas no agrupadas? 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 ON ha restringido el emparejamiento a los pedidos entregados, sin eliminar a los clientes que no tienen ninguno (si esa condición hubiera ido al WHERE, el LEFT JOIN se habría degradado a INNER, tal como demostró 03-03).
  • El WHERE ha filtrado por una columna de la tabla izquierda, que es seguro y no degrada nada: quedan los 11 clientes españoles.
  • El HAVING ha 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 al LEFT JOIN y a COUNT(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 en HAVING.
  • Poner en el HAVING una condición que se puede evaluar fila a fila. Funciona, pero agrupa filas que iban a tirarse. Muévelo al WHERE.
  • Usar en HAVING una columna que no está en el GROUP BY. column ... must appear in the GROUP BY clause. Si es un filtro de fila, va en el WHERE.
  • Usar un alias del SELECT en el HAVING. column "..." does not exist en PostgreSQL, aunque MySQL y SQLite lo permitan. Repite el agregado.
  • Escribir HAVING sin GROUP BY por error. Trata toda la tabla como un grupo y devuelve una fila o ninguna. Casi siempre querías un WHERE.
  • Mezclar AND y OR en el HAVING sin paréntesis. Misma precedencia y mismo daño silencioso que en el WHERE (02-03).
  • Filtrar por un agregado que no muestras. Es legal y deja un resultado incomprensible. Añádelo al SELECT.
  • Olvidar que un INNER JOIN en la consulta ya ha descartado grupos. El HAVING filtra lo que le llega; si el JOIN perdió 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 FROM con sus JOIN y cuenta filas; luego el WHERE; luego el GROUP BY y comprueba el número de grupos; y solo al final el HAVING. 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 BY cliente + HAVING. "Pedidos de más de 50 €" → el sujeto es el pedido: puede que sea WHERE.

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;
  1. ¿Qué categorías devuelve y qué categorías "faltan"? ¿Es un error del HAVING?
  2. La columna portes está mal. Explica por qué y calcula cuánto vale realmente para Alimentación frente a lo que debería valer.
  3. 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.

-- ⚠️ INCORRECTA
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 HAVING haciendo 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ó el INNER JOIN mucho antes, en el paso 1. Es el problema de 04-05 sección 10. Ahora bien: como su condición sería 0 > 20, tampoco habría aparecido con LEFT JOIN. El resultado es el mismo, pero por razones distintas, y esa distinción importa — con el umbral en > 20 da igual, con el umbral en >= 0 no 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 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:

  • WHERE filtra filas, HAVING filtra grupos. Todo lo demás se deduce de su posición en el orden lógico: FROM/JOINWHEREGROUP BYHAVINGSELECTDISTINCTORDER BYLIMIT.
  • HAVING puede usar agregados y WHERE no, porque cuando se ejecuta el WHERE los grupos aún no existen. Y HAVING no puede usar columnas no agrupadas, por la misma regla de oro que el SELECT.
  • Cuando la condición recae sobre una columna del GROUP BY, las dos escrituras dan el mismo resultado pero no el mismo trabajo: WHERE agrupa 11 filas, HAVING agrupa 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: WHERE acota el universo (2025) y HAVING selecciona los grupos que importan (más de 50 €).
  • HAVING sin GROUP BY trata la tabla entera como un grupo y devuelve una fila o ninguna. Casi siempre es un WHERE mal escrito.
  • Los tres sitios donde se filtra son ON (condiciona el emparejamiento y conserva las filas izquierdas de un LEFT JOIN), WHERE (filtra filas y degrada el LEFT a INNER si toca la tabla derecha) y HAVING (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

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados