Este tema no está en las guías al uso con este nombre. Aparece disperso entre
EXISTS,INyNOT IN, sin explicar que son la misma idea ni advertir de la trampa de la sección 4 — que es de las que borran informes enteros en silencio.
1. Qué es
Un JOIN normal trae columnas de la otra tabla. Muchas veces no quieres columnas: quieres saber si existe o no existe.
| Nombre | Pregunta | Cómo se escribe |
|---|---|---|
| Semi-join | ¿Tiene al menos uno? | EXISTS / IN |
| Anti-join | ¿No tiene ninguno? | NOT EXISTS |
La propiedad que los define, y por la que merecen un nombre propio:
Un semi-join no multiplica filas. Un cliente con 31 pedidos aparece una vez, no 31. Un
JOINnormal daría 31 y habría que arreglarlo conDISTINCT— que es la tirita de distinct-y-duplicados.
2. Sintaxis
-- Semi-join
WHERE EXISTS (SELECT 1 FROM b WHERE b.clave = a.clave)
WHERE a.clave IN (SELECT clave FROM b)
-- Anti-join
WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.clave = a.clave)
El SELECT 1 de dentro no es un truco de rendimiento: EXISTS solo mira si hay filas, nunca sus valores, así que da igual escribir SELECT 1, SELECT * o SELECT 1/0. Se escribe 1 por convención, para que quede claro que no se usa.
3. Semi-join: "los que tienen al menos uno"
SELECT count(*) FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id); -- 89
SELECT count(*) FROM customers c
WHERE c.customer_id IN (SELECT customer_id FROM orders); -- 89
SELECT count(*) FROM customers c
JOIN orders o ON o.customer_id = c.customer_id; -- 830 ⚠
89, 89 y 830. Las dos primeras responden "¿cuántos clientes han comprado?". La tercera responde "¿cuántos pedidos hay?", aunque parezca que cuenta clientes.
El caso realista — clientes que han comprado el producto 38:
-- Semi-join: 19
SELECT count(*) FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o
JOIN order_details od ON od.order_id = o.order_id
WHERE o.customer_id = c.customer_id AND od.product_id = 38);
-- JOIN normal: 24 ⚠
SELECT count(*) FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
JOIN order_details od ON od.order_id = o.order_id
WHERE od.product_id = 38;
19 clientes contra 24 filas. Cinco clientes compraron Côte de Blaye más de una vez, y el JOIN los cuenta una vez por compra. Los dos números son ciertos; solo uno responde a "¿a cuántos clientes les mando la promoción?".
3.1 EXISTS contra IN
Con datos sin nulos, son equivalentes. Y no solo en el resultado — el plan también:
EXPLAIN (COSTS OFF) SELECT c.customer_id FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);
Hash Join
Hash Cond: (c.customer_id = o.customer_id)
-> Seq Scan on customers c
-> Hash
-> HashAggregate
Group Key: o.customer_id
-> Seq Scan on orders o
La versión con IN produce el mismo plan, línea por línea. PostgreSQL reconoce las dos formas como el mismo semi-join.
Cuándo usar cada uno:
EXISTS |
IN |
|
|---|---|---|
| Varias condiciones de correlación | ✅ Natural | ❌ Incómodo |
| Lista de valores literales | ❌ | ✅ IN ('A','B','C') |
| Con posibles nulos | ✅ Seguro | ⚠️ En la forma negada, peligroso |
Regla: IN para listas literales, EXISTS para subconsultas.
4. Anti-join: "los que no tienen ninguno" — y la trampa
Las tres formas de escribirlo:
-- A: NOT EXISTS
SELECT c.company_name FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);
-- B: LEFT JOIN ... IS NULL
SELECT c.company_name FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;
-- C: NOT IN ⚠️ ver abajo
SELECT c.company_name FROM customers c
WHERE c.customer_id NOT IN (SELECT customer_id FROM orders);
company_name
--------------------------------------
FISSA Fabrica Inter. Salchichas S.A.
Paris spécialités
Aquí las tres dan lo mismo. Y aun así solo una es correcta.
4.1 La trampa de NOT IN con NULL
Empleados que no dirigen a nadie. La misma pregunta, dos formas:
SELECT count(*) FROM employees e
WHERE e.employee_id NOT IN (SELECT reports_to FROM employees); -- 0 ❌
SELECT count(*) FROM employees e
WHERE NOT EXISTS (SELECT 1 FROM employees s WHERE s.reports_to = e.employee_id); -- 7 ✅
Cero contra siete. NOT IN no da error, no da aviso, y devuelve el resultado más creíble de todos: una lista vacía.
Por qué. reports_to contiene un NULL — Andrew Fuller no reporta a nadie. Así que NOT IN (2, 5, NULL) se expande a:
employee_id <> 2 AND employee_id <> 5 AND employee_id <> NULL
Esa última comparación es UNKNOWN para todas las filas, y TRUE AND UNKNOWN = UNKNOWN (null-y-la-logica-de-tres-valores). El WHERE solo deja pasar TRUE. Ninguna fila puede sobrevivir, hagan lo que hagan los datos.
Y esto se ve también en el plan:
-- NOT EXISTS
Nested Loop Anti Join
-> Seq Scan on customers c
-> Index Only Scan using idx_orders_customer_id on orders o
-- NOT IN
Seq Scan on customers c
Filter: (NOT (ANY (customer_id = ((hashed SubPlan 1).col1))))
SubPlan 1
-> Seq Scan on orders
NOT EXISTS se convierte en un Anti Join de verdad y usa el índice. NOT IN no puede: por la semántica del NULL, el motor está obligado a comprobar la lista entera fila por fila. Es peor resultado y peor plan.
Regla sin excepción: para un anti-join,
NOT EXISTS.NOT INsolo es seguro sobre una lista literal (NOT IN ('A','B')) o sobre una columna declaradaNOT NULL— y esa declaración puede cambiar mañana sin que nadie revise tus consultas.
4.2 NOT EXISTS contra LEFT JOIN ... IS NULL
Las dos son correctas. Diferencias:
NOT EXISTS |
LEFT JOIN ... IS NULL |
|
|---|---|---|
| Intención | Explícita: "no existe" | Hay que deducirla |
| Riesgo | Ninguno | Elegir mal la columna del IS NULL |
| Plan | Anti Join |
Hash Right Join + Filter |
El riesgo del LEFT JOIN es real: hay que comprobar IS NULL sobre una columna que nunca sea nula en la tabla derecha. Con o.order_id (la clave primaria) está bien; con o.shipped_date saldrían además los 21 pedidos sin enviar.
NOT EXISTS no tiene esa decisión que tomar. Por eso es el que se recomienda.
4.3 Combinar semi y anti: la pregunta que solo ellos responden
Clientes que compraron en el primer trimestre de 1998 y no han vuelto:
SELECT count(*) FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
AND o.order_date >= '1998-01-01')
AND NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
AND o.order_date >= '1998-04-01');
count
-------
25
25 clientes en riesgo de fuga. Con JOIN esto no se escribe de forma natural: harían falta dos agregaciones y un HAVING. Con un semi y un anti se lee como la frase en español.
4.4 EXCEPT, la cuarta forma
SELECT customer_id FROM customers
EXCEPT
SELECT customer_id FROM orders;
Devuelve los mismos 2 clientes. EXCEPT es un anti-join sobre columnas, no sobre tablas: solo sirve cuando lo que quieres devolver es exactamente la columna que comparas. Está en union-intersect-except.
5. Errores comunes
NOT INcon una subconsulta. Cero filas silenciosas. El error del tema.JOINnormal donde querías un semi-join. Filas multiplicadas: 24 en vez de 19.- Arreglar lo anterior con
DISTINCT. Funciona y esconde el problema; con una agregación al lado, la esconde mal. LEFT JOIN ... WHERE columna IS NULLsobre una columna que puede ser nula de verdad.- Poner columnas de la subconsulta en el
SELECTde fuera. No se puede: un semi-join no trae columnas. Si las necesitas, es unJOIN. NOT EXISTSsin correlacionar.NOT EXISTS (SELECT 1 FROM orders)sinWHEREes cierto o falso para toda la tabla: devuelve todo o nada.- Creer que
EXISTSes más lento queIN. Producen el mismo plan.
6. Diferencias con SQL Server
| PostgreSQL | SQL Server | |
|---|---|---|
EXISTS / NOT EXISTS |
✅ | ✅ Idéntico |
IN / NOT IN |
✅ | ✅ Con la misma trampa de NULL |
EXCEPT |
✅ | ✅ |
LEFT JOIN ... IS NULL |
✅ | ✅ |
Este tema es 100% portable, trampa incluida. NOT IN con nulos se comporta igual de mal en SQL Server, en Oracle y en MySQL: no es una peculiaridad de PostgreSQL, es el estándar SQL.
Detalle de entrevista: "¿Cuál es la diferencia entre NOT IN y NOT EXISTS?" es una de las preguntas más frecuentes que existen. La respuesta completa son tres frases: con nulos en la subconsulta, NOT IN devuelve cero filas; NOT EXISTS funciona bien; y además NOT EXISTS permite al optimizador usar un anti-join.
7. Ejercicios
- Los productos que nunca se han vendido. ¿Hay alguno?
- Los clientes que han comprado algún producto de la categoría
Seafood, sin duplicados. - Los empleados que no han gestionado ningún pedido en 1998.
- Los proveedores que no tienen ningún producto descatalogado.
- Explica por qué estas dos dan resultados distintos y cuál es la correcta: ```sql SELECT count(*) FROM orders o WHERE o.ship_region NOT IN (SELECT region FROM customers);
SELECT count(*) FROM orders o WHERE NOT EXISTS (SELECT 1 FROM customers c WHERE c.region = o.ship_region); ```
8. Pregunta de negocio
Marketing prepara una campaña de recuperación. Quieren "los clientes que compraban y han dejado de comprar", con su última compra y lo que gastaban. Y avisan: la última vez que pidieron algo así, les llegó una lista vacía y nadie supo por qué.
La última frase te está diciendo exactamente qué salió mal.