Curso SQL · Northwind
Tema 19 de 64
Bloque 4 · Agregar PostgreSQL 17 northwind

HAVING vs WHERE

Requieregroup-by

1. Qué es

Los dos filtran. La diferencia cabe en una línea:

WHERE filtra filas antes de agrupar. HAVING filtra grupos después de agrupar.

No son alternativas de estilo: filtran cosas distintas en momentos distintos. WHERE es el paso 2 del orden-logico-de-ejecucion y HAVING el 4, con el GROUP BY en medio.

WHERE HAVING
Cuándo Antes de agrupar Después de agrupar
Qué descarta Filas Grupos enteros
Puede usar agregaciones ❌ No ✅ Sí
Puede usar columnas de la tabla ✅ Todas Solo las agrupadas
Puede usar alias del SELECT ❌ No ❌ No
Sin GROUP BY Filtra filas Filtra la fila única del resultado

2. Sintaxis

SELECT columna, agregación(...)
FROM tabla
WHERE  condición_sobre_filas
GROUP BY columna
HAVING condición_sobre_grupos
ORDER BY ...;

El orden de escritura es obligatorio: WHERE antes del GROUP BY, HAVING después.

3. Ejemplos sobre Northwind

HAVING en su uso natural — filtrar por una agregación:

SELECT customer_id, count(*) AS pedidos
FROM orders
GROUP BY customer_id
HAVING count(*) >= 20
ORDER BY pedidos DESC;
 customer_id | pedidos
-------------+---------
 SAVEA       |      31
 ERNSH       |      30
 QUICK       |      28

Tres clientes de 89 superan los 20 pedidos. Esto no se puede escribir con WHERE: cuando el WHERE se evalúa, no hay grupos y no hay nada que contar.

SELECT customer_id, count(*) FROM orders WHERE count(*) >= 20 GROUP BY customer_id;
ERROR:  aggregate functions are not allowed in WHERE

El error es preciso y hay que leerlo como una explicación, no como un obstáculo.

3.1 HAVING tampoco acepta el alias

SELECT customer_id, count(*) AS pedidos
FROM orders
GROUP BY customer_id
HAVING pedidos >= 20;
ERROR:  column "pedidos" does not exist

GROUP BY sí acepta el alias, HAVING no. Es la asimetría del orden-logico-de-ejecucion: la comodidad del alias se concede a GROUP BY y ORDER BY, y a nadie más. En HAVING hay que repetir la agregación entera.

3.2 Los dos juntos, cada uno en su sitio

SELECT ship_country,
       count(*)              AS pedidos,
       round(avg(freight),2) AS flete_medio
FROM orders
WHERE  order_date >= '1997-01-01' AND order_date < '1998-01-01'
GROUP BY ship_country
HAVING count(*) > 20 AND avg(freight) > 80
ORDER BY flete_medio DESC;
 ship_country | pedidos | flete_medio
--------------+---------+-------------
 Austria      |      21 |      178.36
 Germany      |      64 |       97.38
 USA          |      60 |       96.99

Léelo como una frase: "de los pedidos de 1997 (WHERE), agrupados por país, quédate con los países que tienen más de 20 pedidos y flete medio superior a 80 (HAVING)".

Austria es el hallazgo: 21 pedidos —justo por encima del corte— y un flete medio de 178, casi el doble que Alemania. Con 21 pedidos apenas, esa media es frágil; el HAVING count(*) > 20 está precisamente para eso, para no sacar conclusiones de grupos de tres filas.

3.3 El caso peligroso: cuando los dos "funcionan"

Estas dos consultas devuelven lo mismo (83 pedidos de Brasil):

SELECT ship_country, count(*) FROM orders WHERE ship_country = 'Brazil' GROUP BY ship_country;
SELECT ship_country, count(*) FROM orders GROUP BY ship_country HAVING ship_country = 'Brazil';

Filtrar por una columna agrupada se puede poner en cualquiera de los dos sitios, y PostgreSQL genera el mismo plan para ambas: empuja el filtro hacia abajo por su cuenta, aunque escribieras HAVING. Puede hacerlo porque el resultado es demostrablemente el mismo — el orden de ejecución es lógico, no físico. Se comprueba con EXPLAIN, al final del curso (explain-y-explain-analyze).

Entonces, ¿da igual? No, por dos razones que no son de rendimiento:

  1. Se lee distinto. WHERE dice "solo me interesan las filas de Brasil". HAVING dice "agrupa todo y luego tira 20 países". La primera es lo que querías.
  2. El optimizador ayuda con los casos evidentes, no con todos. Empujar un = 'Brazil' es fácil. Un HAVING sobre una expresión más rara puede quedarse donde lo escribiste, y entonces sí pagas por agrupar de más.

Regla: si la condición no menciona una agregación, va en WHERE. Sin excepciones.

3.4 WHERE y HAVING dan resultados distintos cuando no filtras lo mismo

Este es el punto que separa entender de memorizar. Categorías con 10 o más productos:

-- A: categorías con 10+ productos ACTIVOS
SELECT category_id, count(*) FROM products
WHERE NOT discontinued
GROUP BY category_id HAVING count(*) >= 10 ORDER BY 2 DESC;
 category_id | count
-------------+-------
           3 |    13
           8 |    12
           2 |    11
           4 |    10
-- B: categorías SIN NINGÚN producto descatalogado
SELECT category_id, count(*) FROM products
GROUP BY category_id HAVING NOT bool_or(discontinued);
 category_id | count
-------------+-------
           8 |    12
           4 |    10
           3 |    13

No son la misma pregunta y no dan la misma respuesta. La A cuenta productos activos y admite categorías que además tienen descatalogados; la B exige que la categoría entera esté limpia. La categoría 2 aparece en A y no en B: tiene 11 activos y al menos uno de baja.

WHERE decide qué filas entran en el grupo. HAVING decide qué grupos sobreviven. Cambiar una condición de sitio no es refactorizar: es cambiar la pregunta.

3.5 Encontrar duplicados, que no es lo mismo que quitarlos

DISTINCT los esconde (distinct-y-duplicados). Para verlos se usa GROUP BY + HAVING:

SELECT
    customer_id,
    order_date,
    count(*)
FROM
    orders
GROUP BY
    customer_id,
    order_date
HAVING
    count(*) > 1
ORDER BY
    count(*) DESC;
 customer_id | order_date | count
-------------+------------+-------
 LACOR       | 1998-03-24 |     2
 LINOD       | 1998-01-19 |     2
 SAVEA       | 1997-10-22 |     2

7 casos de un cliente con dos pedidos el mismo día. ¿Son duplicados? Vamos a mirar:

SELECT
    order_id,
    customer_id,
    order_date,
    employee_id,
    freight
FROM
    orders
WHERE
    customer_id = 'LACOR'
    AND order_date = '1998-03-24';
 order_id | customer_id | order_date | employee_id | freight
----------+-------------+------------+-------------+---------
    10972 | LACOR       | 1998-03-24 |           4 |    0.02
    10973 | LACOR       | 1998-03-24 |           6 |   15.17

No son duplicados. Distinto id, distinto empleado, distinto flete: son dos pedidos reales del mismo cliente el mismo día. Y esa es la lección del ejercicio:

GROUP BY ... HAVING count(*) > 1 no encuentra duplicados. Encuentra repeticiones de la clave que tú elegiste. Si eliges mal la clave, todo parece duplicado. Decidir qué combinación de columnas debería ser única es una pregunta de negocio, no de SQL.

3.6 HAVING sin GROUP BY

Es legal y raro. Sin GROUP BY, toda la tabla es un grupo, así que HAVING filtra esa única fila:

SELECT count(*) AS total FROM orders HAVING count(*) > 100;    -- 830
SELECT count(*) AS total FROM orders HAVING count(*) > 10000;  -- 0 filas

La segunda no devuelve 0: devuelve cero filas. Es la diferencia entre "el resultado es cero" y "no hay resultado", y en un informe se ve como una celda vacía en vez de un cero. Se usa poco, pero sirve para una cosa: una consulta que solo devuelve algo si se cumple una condición global — por ejemplo, una alerta que se dispara solo si el total supera un umbral.

4. Dónde encaja en el orden de ejecución

Con HAVING ya tienes las seis cláusulas básicas del orden-logico-de-ejecucion. Es el primer punto del curso donde se puede ver el orden entero, funcionando.

El embudo, medido de verdad. Es el mismo recorrido que hiciste en orden-logico-de-ejecucion, ahora con los números de cada paso. Países con más de 10 pedidos caros:

SELECT ship_country AS pais,
       count(*)     AS pedidos
FROM orders
WHERE freight > 50
GROUP BY pais
HAVING count(*) > 10
ORDER BY pedidos DESC;
  pais   | pedidos
---------+---------
 USA     |      61
 Germany |      58
 Austria |      33
 Brazil  |      32
 France  |      27
 ...

(las cinco primeras de las diez que devuelve)

Paso a paso, y cada número se puede comprobar por separado:

Paso Qué queda
1. FROM orders 830 filas
2. WHERE freight > 50 360 filas
3. GROUP BY pais 21 grupos
4. HAVING count(*) > 10 10 grupos
5-6. SELECT y ORDER BY 10 filas, ordenadas
SELECT count(*) FROM orders;                      -- 830
SELECT count(*) FROM orders WHERE freight > 50;   -- 360

Por qué el WHERE no puede contar. Este es el error que manda a todo el mundo a buscar HAVING por primera vez:

SELECT ship_country, count(*)
FROM orders
WHERE count(*) > 10
GROUP BY ship_country;
ERROR:  aggregate functions are not allowed in WHERE

PostgreSQL es explícito, y tiene razón: el WHERE es el paso 2 y el GROUP BY el 3. Cuando se evalúa el WHERE, cada fila está sola y no pertenece a ningún grupo, así que no hay nada que contar. Para eso existe HAVING, que corre en el paso 4, con los grupos ya formados.

Y por qué HAVING no acepta el alias, aunque GROUP BY sí. La asimetría que sorprende a todo el mundo:

SELECT ship_country AS pais, count(*) AS pedidos
FROM orders
GROUP BY pais            -- ✅ funciona
HAVING pedidos > 10;     -- ❌ ERROR: column "pedidos" does not exist

No es que HAVING sea más estricto por capricho: ninguno de los dos debería ver el alias, porque nace en el paso 5. Lo que pasa es que el estándar SQL concede esa comodidad a GROUP BY y a ORDER BY, y no a WHERE ni a HAVING. En HAVING hay que repetir la agregación entera: HAVING count(*) > 10.

5. Errores comunes

  1. Poner una agregación en el WHERE. Error explícito. Va en HAVING.
  2. Usar el alias del SELECT en HAVING. Error. Repite la agregación.
  3. Poner en HAVING un filtro que no menciona agregaciones. Funciona, se lee peor, y no siempre lo arregla el optimizador.
  4. Creer que WHERE y HAVING son intercambiables. Lo son solo cuando filtras por una columna agrupada. En cuanto la condición toca una columna no agrupada, cambian la respuesta.
  5. Filtrar en HAVING por una columna que no está en el GROUP BY. Da error: no existe a nivel de grupo.
  6. Olvidar HAVING count(*) > n en análisis de medias. Una media sobre dos filas no es una media, es una anécdota. El HAVING es lo que evita publicarla.
  7. Esperar un 0 donde HAVING devuelve cero filas.

6. Diferencias con SQL Server

WHERE y HAVING funcionan igual en los dos motores: mismas reglas, mismo orden, mismos errores. Es de lo más portable que hay.

La única diferencia hereda la del tema anterior: PostgreSQL permite GROUP BY por alias o posición y SQL Server no, así que una consulta escrita como GROUP BY 1 ... HAVING count(*) > 20 hay que reescribirla con la expresión completa al migrar. HAVING en sí no cambia.

Y una nota de estilo que vale para ambos: en SQL Server es frecuente ver HAVING usado como filtro general porque "así se filtra al final". Es la misma mala costumbre en los dos motores.

7. Ejercicios

  1. Los países con más de 50 pedidos.
  2. Los productos vendidos en más de 50 líneas de pedido distintas, con las unidades totales.
  3. Los clientes cuyo flete medio supera los 100, pero solo si tienen al menos 10 pedidos.
  4. Las categorías donde el precio medio de los productos activos supera los 30.
  5. Explica la diferencia entre estas dos y di cuál responde a "empleados que vendieron algo en 1998": ```sql SELECT employee_id, count(*) FROM orders WHERE extract(year FROM order_date) = 1998 GROUP BY employee_id;

SELECT employee_id, count() FROM orders GROUP BY employee_id HAVING max(order_date) >= '1998-01-01'; 6. Arregla esta consulta:sql SELECT customer_id, count() FROM orders WHERE count() > 20 GROUP BY customer_id; ``` 7. Escribe la consulta que responde: empleados que gestionaron más de 100 pedidos, del que más al que menos.* Usa las seis cláusulas básicas menos una — la que aquí no hace falta — y di cuál has dejado fuera y por qué.

8. Pregunta de negocio

Marketing quiere lanzar un programa de fidelización para "clientes buenos": los que compran a menudo y gastan bien. Piden la lista. No te dan umbrales — dicen que los pongas tú.

La parte difícil no es el SQL. Es defender los números que elijas.

? RespuestaRespóndela antes de abrir
SELECT o.customer_id,
       count(DISTINCT o.order_id)                                    AS pedidos,
       round(sum(od.unit_price * od.quantity * (1 - od.discount)), 2) AS gasto_total,
       round(sum(od.unit_price * od.quantity * (1 - od.discount))
             / count(DISTINCT o.order_id), 2)                         AS ticket_medio,
       max(o.order_date)                                              AS ultima_compra
FROM orders o
JOIN order_details od ON od.order_id = o.order_id
GROUP BY o.customer_id
HAVING count(DISTINCT o.order_id) >= 10
   AND sum(od.unit_price * od.quantity * (1 - od.discount)) >= 20000
   AND max(o.order_date) >= '1998-01-01'
ORDER BY gasto_total DESC;
 customer_id | pedidos | gasto_total | ticket_medio |   ultima
-------------+---------+-------------+--------------+------------
 QUICK       |      28 |   110277.31 |      3938.48 | 1998-04-14
 ERNSH       |      30 |   104874.98 |      3495.83 | 1998-05-05
 SAVEA       |      31 |   104361.95 |      3366.51 | 1998-05-01
 RATTC       |      18 |    51097.80 |      2838.77 | 1998-05-06
 HUNGO       |      19 |    49979.91 |      2630.52 | 1998-04-30
 HANAR       |      14 |    32841.37 |      2345.81 | 1998-04-27
 ...
 BOTTM       |      14 |    20801.60 |      1485.83 | 1998-04-24

17 clientes de 89 pasan los tres filtros.

Cómo defiendo los tres umbrales, que era el encargo real:

  1. >= 10 pedidos — "compra a menudo". La mediana de pedidos por cliente es 8 y la media 9,33; pedir 10 deja fuera a algo más de la mitad. No es un listón arbitrario: está anclado al percentil 50 y se puede explicar en una frase.
  2. >= 20.000 de gasto — "gasta bien". La facturación total es 1.265.793 entre 89 clientes: 14.222 de media. Poner el corte por encima de la media es defendible en una frase.
  3. ultima_compra >= 1998-01-01 — el que no pedían y hay que meter igual. Un cliente que compró mucho en 1996 y desapareció no es un cliente bueno, es un cliente perdido. Mandarle una tarjeta de fidelización es tirar el dinero; lo que necesita es una llamada de recuperación. Ese tercer filtro convierte la lista en accionable.

Y lo que hay que decir al entregarla: los tres umbrales son decisiones mías, no verdades. Lo correcto es entregar la consulta con los números arriba y fáciles de cambiar, enseñar cuántos clientes caen con cada combinación, y dejar que Marketing elija el tamaño de la campaña. Un análisis que no se puede recalibrar sin reescribirlo se usa una vez.

✓ SolucionesResuélvelos antes de abrir

1.

SELECT ship_country, count(*) AS pedidos
FROM orders
GROUP BY ship_country
HAVING count(*) > 50
ORDER BY pedidos DESC;
 ship_country | pedidos
--------------+---------
 USA          |     122
 Germany      |     122
 Brazil       |      83
 France       |      77
 UK           |      56

5 países de 21 concentran 460 de los 830 pedidos: el 55%. (EE. UU. y Alemania empatan a 122 y su orden entre sí no está definido — desempata con ORDER BY pedidos DESC, ship_country si la lista se va a comparar.)

2.

SELECT product_id, count(*) AS lineas, sum(quantity) AS unidades
FROM order_details
GROUP BY product_id
HAVING count(*) > 50
ORDER BY lineas DESC;
 product_id | lineas | unidades
------------+--------+----------
         59 |     54 |     1496
         31 |     51 |     1397
         60 |     51 |     1577
         24 |     51 |     1125

Solo 4 productos de 77 pasan de 50 líneas. Y fíjate en el 24: 51 líneas y 1.125 unidades, frente a las 1.577 del 60 con las mismas 51 líneas. Se pide igual de a menudo, en cantidades un 40% menores. "El más vendido" cambia de nombre según cuentes líneas o unidades.

3.

SELECT customer_id,
       count(*)              AS pedidos,
       round(avg(freight),2) AS flete_medio
FROM orders
GROUP BY customer_id
HAVING count(*) >= 10 AND avg(freight) > 100
ORDER BY flete_medio DESC;
 customer_id | pedidos | flete_medio
-------------+---------+-------------
 SAVEA       |      31 |      215.60
 ERNSH       |      30 |      206.85
 QUICK       |      28 |      200.20
 QUEEN       |      13 |      152.52
 HUNGO       |      19 |      145.01
 PICCO       |      10 |      118.61
 RATTC       |      18 |      118.57
 MEREP       |      13 |      107.25
 RICSU       |      10 |      100.13

9 clientes. Las dos condiciones van juntas en el mismo HAVING, unidas por AND. Y el count(*) >= 10 es lo que impide que un cliente con un solo pedido carísimo encabece la lista: sin él entrarían clientes con dos o tres pedidos cuyo "flete medio" no significa nada.

4.

SELECT category_id,
       count(*)                 AS productos,
       round(avg(unit_price),2) AS precio_medio
FROM products
WHERE NOT discontinued
GROUP BY category_id
HAVING avg(unit_price) > 30
ORDER BY precio_medio DESC;
 category_id | productos | precio_medio
-------------+-----------+--------------
           1 |         9 |        46.03

Una sola categoría. El WHERE NOT discontinued y el HAVING avg(...) > 30 hacen cosas distintas y las dos hacen falta: el primero decide qué productos entran en la media, el segundo qué categorías se publican.

Y ese único resultado merece una segunda mirada. Quitando el HAVING, el cuadro completo es este:

 category_id | productos | precio_medio | maximo
-------------+-----------+--------------+--------
           1 |         9 |        46.03 | 263.50
           7 |         4 |        29.06 |  53.00
           4 |        10 |        28.73 |  55.00
           3 |        13 |        25.16 |  81.00
           6 |         2 |        15.73 |  24.00

La categoría 1 supera el corte por un solo producto: Côte de Blaye, a 263,50. Los otros ocho rondan los 20. Una media arrastrada por un único valor extremo no describe la categoría — y el HAVING no puede detectarlo, porque solo ve el número que le das.

Y abajo, el problema simétrico: la categoría 6 tiene 2 productos. Una media de dos valores es una anécdota. Un informe honesto llevaría AND count(*) >= 5 y, para la categoría 1, la mediana en vez de la media.

5. No son lo mismo:

  • La primera cuenta los pedidos de 1998. El WHERE deja fuera las filas de otros años antes de agrupar, así que el count(*) es el de ese año.
  • La segunda cuenta todos los pedidos de la historia de cada empleado, y solo se queda con los empleados cuyo pedido más reciente cae en 1998.

La que responde "empleados que vendieron algo en 1998" es la primera, y además da el número correcto al lado. La segunda responde a "empleados que siguen activos", que es otra pregunta útil pero distinta — y su count(*) es engañoso, porque parece de 1998 y no lo es.

6. El filtro es sobre grupos, no sobre filas, así que va en HAVING:

SELECT customer_id, count(*)
FROM orders
GROUP BY customer_id
HAVING count(*) > 20;
 customer_id | count
-------------+-------
 ERNSH       |    30
 QUICK       |    28
 SAVEA       |    31

7.

SELECT employee_id AS empleado,
       count(*)    AS pedidos
FROM orders
GROUP BY empleado
HAVING count(*) > 100
ORDER BY pedidos DESC;
 empleado | pedidos
----------+---------
        4 |     156
        3 |     127
        1 |     123
        8 |     104

Cuatro de los nueve empleados. La que sobra es el WHERE: no hay que descartar ninguna fila de partida — se cuentan todos los pedidos y el filtro es sobre el total de cada empleado, que es exactamente el trabajo del HAVING.

Y una limitación honesta de este punto del curso: sale el id del empleado y no su nombre, porque el nombre vive en otra tabla. Para eso hace falta un JOIN (inner-join).