Hay una familia de preguntas que no necesita ningún valor: solo un o un no. ¿Ha comprado este cliente alguna vez? ¿Tiene este producto alguna reseña? ¿Contiene este pedido algún artículo de cosmética? No importa cuántos, ni cuáles, ni cuánto suman: importa si existe al menos uno.

EXISTS es el operador que responde exactamente a eso, y es probablemente la subconsulta que más vas a escribir en tu vida profesional. Su negación, NOT EXISTS, resuelve la otra mitad —clientes que no han comprado, productos que nadie ha vendido— y lo hace con una propiedad que ninguna de las alternativas tiene: es inmune a los valores nulos. En esta lección verás las dos, las compararás con NOT IN, con el anti-join de 03-03 y con EXCEPT de 03-07, y cerrarás con el problema clásico de la división relacional.

Contenido

  1. EXISTS: un predicado, no un valor
  2. Por qué da igual lo que ponga en el SELECT interno
  3. Cuatro casos de TiendaVerde con EXISTS
  4. NOT EXISTS: los cuatro huecos del conjunto de datos
  5. NOT EXISTS frente a NOT IN: la diferencia que importa
  6. Las cuatro formas de responder "qué no casa"
  7. Cortocircuito: EXISTS frente a COUNT(*) > 0
  8. División relacional: la doble negación
  9. Errores Comunes y Consejos
  10. Ejercicios
  11. Conclusión

  1. EXISTS: un predicado, no un valor

EXISTS (subconsulta) es un predicado: devuelve TRUE o FALSE, nunca NULL. Su regla es de una simplicidad radical:

EXISTS es TRUE en cuanto la subconsulta produce su primera fila. Si no produce ninguna, es FALSE.

SELECT c.id, c.nombre, c.apellidos
FROM clientes AS c
WHERE EXISTS (SELECT 1 FROM pedidos AS pe WHERE pe.cliente_id = c.id)
ORDER BY c.id
LIMIT 4;
id nombre apellidos
1 Lucía Martínez Soler
2 Carlos Ferrer Ibáñez
3 Marta Sanchis Gil
4 Javier Ortega Ruiz

(4 primeras de 12 filas: los 12 clientes que han comprado alguna vez.)

Tres propiedades lo distinguen de todo lo visto: devuelve TRUE/FALSE y nunca NULL, así que es inmune a la lógica de tres valores de 04-03; no mira el contenido de la subconsulta, da igual qué columnas devuelva; y se detiene en la primera fila, no cuenta ni suma ni ordena.

Y una cuarta, decisiva: EXISTS es correlacionado en la práctica siempre. Un EXISTS sin correlación —WHERE EXISTS (SELECT 1 FROM pedidos)— es TRUE o FALSE para todas las filas por igual, así que devuelve toda la tabla o ninguna fila. No es un error, pero tampoco sirve de nada. La condición pe.cliente_id = c.id es la que hace el trabajo: convierte "¿hay pedidos?" en "¿hay pedidos de este cliente?".

  1. Por qué da igual lo que ponga en el SELECT interno

Estas cuatro escrituras son exactamente equivalentes y producen el mismo plan de ejecución:

WHERE EXISTS (SELECT 1        FROM pedidos AS pe WHERE pe.cliente_id = c.id)
WHERE EXISTS (SELECT *        FROM pedidos AS pe WHERE pe.cliente_id = c.id)
WHERE EXISTS (SELECT pe.id    FROM pedidos AS pe WHERE pe.cliente_id = c.id)
WHERE EXISTS (SELECT 1/0      FROM pedidos AS pe WHERE pe.cliente_id = c.id)

La última es la demostración: 1/0 es una división por cero y no da error, porque PostgreSQL nunca evalúa la lista de columnas de un EXISTS. Solo comprueba si la consulta produce filas. La proyección se descarta antes de calcularse.

De ahí el idiom SELECT 1, el que verás en el 90 % del código profesional: es la forma más corta de decir "no me interesa el contenido". SELECT * es igual de correcto y algunas guías lo prefieren porque subraya lo mismo. Elige una y sé coherente; en este curso usamos SELECT 1.

Lo que sí importa dentro del EXISTS es el WHERE: la correlación. Un EXISTS cuyo WHERE no menciona la fila externa está mal planteado casi seguro.

  1. Cuatro casos de TiendaVerde con EXISTS

Productos con al menos una reseña. El EXISTS no dice cuántas ni de qué puntuación: solo que hay alguna.

SELECT p.id, p.nombre AS producto, p.precio
FROM productos AS p
WHERE EXISTS (SELECT 1 FROM resenas AS r WHERE r.producto_id = p.id)
ORDER BY p.id;
id producto precio
1 Aceite de oliva virgen extra 500 ml 12.50
2 Arroz integral ecológico 1 kg 3.90
5 Tomate triturado ecológico 400 g 1.95
6 Crema facial de aloe vera 50 ml 18.90
10 Detergente ecológico concentrado 1 L 11.20

(5 primeras de 9 filas; las otras cuatro son los productos 12, 15, 16 y 18.) 9 de los 20 productos. Compáralo con un INNER JOIN sobre resenas: ese habría devuelto 12 filas, una por reseña, con el aceite, el arroz y la crema repetidos. EXISTS nunca multiplica filas, y esa es su ventaja más práctica frente al JOIN.

Pedidos que contienen algún producto de cosmética natural. Aquí la subconsulta lleva su propio JOIN:

SELECT pe.id AS pedido_id, pe.fecha_pedido, c.nombre || ' ' || c.apellidos AS cliente, pe.estado
FROM pedidos  AS pe
JOIN clientes AS c ON pe.cliente_id = c.id
WHERE EXISTS (SELECT 1
              FROM lineas_pedido AS lp
              JOIN productos AS p ON p.id = lp.producto_id
              WHERE lp.pedido_id = pe.id
                AND p.categoria_id = 2)
ORDER BY pe.id;
pedido_id fecha_pedido cliente estado
2 2025-03-12 Carlos Ferrer Ibáñez entregado
6 2025-05-23 Ana Belmonte Roca cancelado
9 2025-07-15 Tiago Almeida Nunes entregado
10 2025-08-03 Camille Dubois entregado
15 2025-12-02 Lucía Martínez Soler entregado
18 2026-01-27 Ana Belmonte Roca pagado

6 pedidos de 20. El pedido 9 contiene dos artículos de cosmética (champú y bálsamo) y aparece una sola vez: con JOIN habrían salido 7 filas y habría hecho falta un DISTINCT. Empleados que han gestionado algún pedido, mismo patrón:

SELECT e.id, e.nombre || ' ' || e.apellidos AS empleado, e.puesto
FROM empleados AS e
WHERE EXISTS (SELECT 1 FROM pedidos AS pe WHERE pe.empleado_id = e.id)
ORDER BY e.id;
id empleado puesto
4 Óscar Peris Blasco Comercial
5 Laia Puig Sanchis Comercial
6 Marc Estévez Roig Atención al cliente

3 de los 8 empleados, exactamente los que anunciaba 01-06.

Nota de dialecto: en PostgreSQL EXISTS devuelve un boolean de verdad, así que puedes usarlo como columna: SELECT c.id, EXISTS (SELECT 1 FROM pedidos pe WHERE pe.cliente_id = c.id) AS ha_comprado FROM clientes c devuelve true/false para los 15 clientes. SQL Server y Oracle no lo permiten y obligan a envolverlo en un CASE WHEN EXISTS (...) THEN 1 ELSE 0 END. MySQL 8 y SQLite sí lo aceptan, devolviendo 1 o 0.

  1. NOT EXISTS: los cuatro huecos del conjunto de datos

NOT EXISTS es la negación literal: TRUE cuando la subconsulta no produce ninguna fila. Es la herramienta natural para todas las preguntas con "sin", "nunca" o "ninguno", y responde a los cuatro huecos deliberados de TiendaVerde con la misma plantilla.

-- Clientes que no han comprado nunca
SELECT c.id, c.nombre, c.apellidos, c.ciudad, c.fecha_registro
FROM clientes AS c
WHERE NOT EXISTS (SELECT 1 FROM pedidos AS pe WHERE pe.cliente_id = c.id)
ORDER BY c.id;
id nombre apellidos ciudad fecha_registro
13 Núria Bosch Ferrer Barcelona 2025-06-20
14 Hugo Iglesias Pardo Zaragoza 2025-09-12
15 Inés Carrasco Vega Valencia 2026-01-08

Y la misma plantilla, cambiando solo la tabla de dentro, da los productos nunca vendidos:

SELECT p.id, p.nombre, p.precio, p.stock, p.activo
FROM productos AS p
WHERE NOT EXISTS (SELECT 1 FROM lineas_pedido AS lp WHERE lp.producto_id = p.id)
ORDER BY p.id;
id nombre precio stock activo
13 Velas de cera de soja (pack 2) 13.75 0 true
19 Desodorante natural en barra 50 g 7.80 75 true
20 Cápsulas de espirulina 120 uds 16.40 55 false

Con NOT EXISTS (SELECT 1 FROM resenas AS r WHERE r.producto_id = p.id) sobre productos obtienes los 11 productos sin reseña, y con NOT EXISTS (SELECT 1 FROM pedidos AS pe WHERE pe.empleado_id = e.id) sobre empleados, los 5 empleados sin pedidos (Rosa, Andrés, Beatriz, Irene y Daniel).

Cuatro preguntas de negocio, una sola plantilla. Esa uniformidad es la razón principal para preferir NOT EXISTS: no hay que decidir qué columna comprobar con IS NULL, ni preocuparse por los nulos, ni recordar qué tabla va a la izquierda.

  1. NOT EXISTS frente a NOT IN: la diferencia que importa

Ahora la demostración central del módulo. La misma pregunta, dos escrituras, dos resultados distintos.

-- ⚠️ INCORRECTA: 0 filas
SELECT e.id, e.nombre, e.apellidos
FROM empleados AS e
WHERE e.id NOT IN (SELECT empleado_id FROM pedidos);

-- ✅ CORRECTA: 5 filas
SELECT e.id, e.nombre, e.apellidos
FROM empleados AS e
WHERE NOT EXISTS (SELECT 1 FROM pedidos AS pe WHERE pe.empleado_id = e.id);
id nombre apellidos
1 Rosa Alcázar Vives
2 Andrés Company Talens
3 Beatriz Nadal Ripoll
7 Irene Salvador Mira
8 Daniel Vercher Lluch

La primera devuelve cero filas y la segunda cinco. La causa, que ya diagnosticaste en 07-01, son los 10 pedidos del canal web con empleado_id a NULL: la lista es {4, 5, 6, NULL} y 1 <> NULL es UNKNOWN, así que NOT IN no puede ser TRUE para ninguna fila. NOT EXISTS no sufre lo mismo porque no compara el valor externo con una lista: pregunta si la subconsulta produce filas. Para el empleado 1, SELECT 1 FROM pedidos WHERE empleado_id = 1 no produce ninguna —las filas con empleado_id nulo tampoco satisfacen esa igualdad y se descartan como cualquier otra que no case— y NOT EXISTS es TRUE. La lógica de tres valores actúa dentro de la subconsulta, donde solo decide qué filas se devuelven, y nunca escapa al predicado.

flowchart LR
    A["e.id = 1"] --> B{"NOT IN (4,5,6,NULL)"}
    B --> C["UNKNOWN → ❌ descartada"]
    A --> D{"NOT EXISTS<br/>(pedidos con empleado_id = 1)"}
    D --> E["0 filas → TRUE → ✅ conservada"]

La regla, en una frase: NOT EXISTS es seguro con nulos y NOT IN no. Si eliges NOT EXISTS por costumbre, nunca tendrás que preguntarte si la columna de la subconsulta admite nulos.

  1. Las cuatro formas de responder "qué no casa"

En 03-07 se anunció que las cuatro escrituras se compararían en el módulo 7. Aquí están, resolviendo la misma pregunta —clientes que no han comprado nunca— y devolviendo todas las mismas 3 filas (13, 14 y 15):

-- 1. NOT EXISTS
SELECT c.id, c.nombre FROM clientes AS c
WHERE NOT EXISTS (SELECT 1 FROM pedidos AS pe WHERE pe.cliente_id = c.id);
-- 2. NOT IN
SELECT c.id, c.nombre FROM clientes AS c
WHERE c.id NOT IN (SELECT cliente_id FROM pedidos);
-- 3. Anti-join (03-03)
SELECT c.id, c.nombre FROM clientes AS c
LEFT JOIN pedidos AS pe ON pe.cliente_id = c.id WHERE pe.id IS NULL;
-- 4. EXCEPT (03-07)
SELECT id FROM clientes EXCEPT SELECT cliente_id FROM pedidos;
NOT EXISTS NOT IN Anti-join EXCEPT
Comportamiento con NULL Seguro Peligroso: 0 filas Seguro Seguro (trata NULL como un valor más)
Columnas que puede devolver Todas las de fuera Todas las de fuera Todas las de fuera Solo las comparadas
¿Elimina duplicados? No los crea No los crea No los crea Sí, siempre
Legibilidad Alta: se lee como la pregunta Muy alta… hasta que falla Media: hay que saber el idiom Alta, pero limitada
Rendimiento típico Anti-join en el plan Peor si hay nulos posibles Anti-join en el plan Requiere ordenar/dedup
Portabilidad Universal Universal Universal EXCEPT no existe en MySQL 5.7 ni en Oracle (allí es MINUS)

La recomendación del curso: usa NOT EXISTS. Es seguro, universal, deja disponibles todas las columnas de la tabla externa y se lee igual que la pregunta de negocio. El anti-join es igual de idiomático y a veces más natural si ya estabas uniendo esas tablas. EXCEPT, solo cuando compares conjuntos de la misma forma y no necesites más columnas. Y NOT IN, únicamente si garantizas que la columna de la subconsulta es NOT NULL — y aun entonces, no ganas nada. Esta tabla se cierra y se amplía con el criterio de rendimiento en 07-05.

  1. Cortocircuito: EXISTS frente a COUNT(*) > 0

Es tentador escribir "¿hay alguno?" como "¿el recuento es mayor que cero?". Funciona y da el mismo resultado —12 filas las dos—, pero no el mismo trabajo:

-- ⚠️ Correcta pero peor
SELECT c.id, c.nombre FROM clientes AS c
WHERE (SELECT COUNT(*) FROM pedidos AS pe WHERE pe.cliente_id = c.id) > 0;
-- ✅ Preferible
SELECT c.id, c.nombre FROM clientes AS c
WHERE EXISTS (SELECT 1 FROM pedidos AS pe WHERE pe.cliente_id = c.id);
COUNT(*) > 0 EXISTS
Filas que lee la subconsulta Todas las del cliente Una: se detiene en la primera
Con un cliente de 50.000 pedidos Cuenta 50.000 Lee 1
Qué comunica al lector "cuántos hay, y compáralo" "¿hay alguno?"

EXISTS cortocircuita: en cuanto encuentra una fila, deja de buscar; COUNT no puede, porque para saber cuántas hay tiene que verlas todas. Con 20 pedidos la diferencia es inmedible; con tablas reales separa una consulta instantánea de un recorrido completo. Y hay un tercer argumento, el más importante en el día a día: EXISTS dice lo que quieres decir. Lo mismo con la negación: COUNT(*) = 0 es NOT EXISTS escrito de forma más cara.

  1. División relacional: la doble negación

Llegamos al problema clásico. La pregunta parece inocente:

¿Qué clientes han comprado productos de todas las categorías?

Y no se puede responder con EXISTS a secas, porque EXISTS habla de "alguno", no de "todos". El truco es reformular la frase hasta convertir el "todos" en dos "ningunos" encadenados:

"ha comprado de TODAS las categorías"
≡ "NO hay NINGUNA categoría de la que NO haya comprado"

Esa reformulación —que en lógica es ∀x P(x) ≡ ¬∃x ¬P(x)— se traduce literalmente a SQL:

SELECT c.id, c.nombre || ' ' || c.apellidos AS cliente
FROM clientes AS c
WHERE NOT EXISTS (                              -- no hay ninguna categoría…
        SELECT 1
        FROM categorias AS cat
        WHERE NOT EXISTS (                      -- …de la que este cliente no haya comprado
                SELECT 1
                FROM pedidos AS pe
                JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
                JOIN productos     AS p  ON p.id = lp.producto_id
                WHERE pe.cliente_id  = c.id
                  AND p.categoria_id = cat.id))
ORDER BY c.id;
(0 filas)

Ningún cliente, y el resultado no es decepcionante sino diagnóstico: Complementos no ha vendido nunca (0,00 € en las cifras del curso), así que nadie puede haber comprado de las seis categorías. La consulta es correcta; lo que falla es la pregunta. Reformulada sobre las cuatro categorías principales —Alimentación, Cosmética natural, Hogar sostenible y Bebidas— basta con acotar la subconsulta intermedia:

        SELECT 1
        FROM categorias AS cat
        WHERE cat.id IN (1, 2, 3, 4)
          AND NOT EXISTS ( ... )
id cliente
1 Lucía Martínez Soler

Una sola clienta. Lucía es la única que ha comprado de las cuatro categorías principales, y lo ha conseguido con tres pedidos muy distintos entre sí: el 1 (aceite, arroz e infusión), el 5 (detergente, estropajo y bolsas) y el 15 (miel, champú y estropajo). Es exactamente el perfil que un equipo de marketing querría identificar: el cliente que ha explorado el catálogo entero.

Cómo se lee esta consulta sin marearse

Léela de dentro hacia fuera, y en tres frases:

Nivel Qué pregunta Para quién
Interno (NOT EXISTS 2) "¿este cliente no ha comprado nada de esta categoría?" Cada pareja cliente-categoría
Intermedio (FROM categorias) "¿hay alguna categoría que cumpla lo anterior?" Cada cliente
Externo (NOT EXISTS 1) "¿ninguna la cumple?" → entonces las compró todas Cada cliente

Y una advertencia: hay dos correlaciones en juego. La interna usa c.id (dos niveles hacia fuera) y cat.id (uno). Si olvidas una, la consulta sigue siendo válida y devuelve una barbaridad. Prueba la subconsulta interna con valores fijos (c.id = 1, cat.id = 3) antes de ensamblar las tres capas.

Nota: hay una alternativa más legible con GROUP BY y HAVING COUNT(DISTINCT p.categoria_id) = 4, más corta y a menudo más rápida. La doble negación merece entenderse porque es la única que funciona cuando el conjunto de referencia no es un simple recuento —"todas las categorías activas", "todos los productos de un catálogo que cambia"— y porque es el idiom que reconocerás al leer código ajeno.

Errores Comunes y Consejos

  • Escribir un EXISTS sin correlación. WHERE EXISTS (SELECT 1 FROM pedidos) es TRUE para todas las filas y devuelve la tabla entera: la condición que enlaza con la fila externa es lo esencial.
  • Creer que SELECT * dentro de un EXISTS es más lento. No lo es: la lista de columnas nunca se evalúa, y SELECT 1 y SELECT * producen el mismo plan.
  • Usar NOT IN sobre una columna que admite nulos. Cero filas, sin ningún aviso; NOT EXISTS no tiene ese problema nunca. Y escribir COUNT(*) > 0 cuenta todas las filas para responder a algo que se contesta con la primera.
  • Poner un ORDER BY o un LIMIT dentro de un EXISTS. No cambian el resultado y añaden trabajo. Y confundir NOT EXISTS con EXISTS (... WHERE NOT ...). "No tiene ningún pedido entregado" es NOT EXISTS (... estado = 'entregado'); "tiene algún pedido no entregado" es EXISTS (... estado <> 'entregado'). Son preguntas distintas.
  • Consejo: traduce la pregunta palabra por palabra. "Clientes que han comprado" → EXISTS. "Clientes que no han comprado nunca" → NOT EXISTS. "Clientes que han comprado de todas" → NOT EXISTS ( ... NOT EXISTS ( ... )). Y ahí, cuidado con olvidar una de las dos correlaciones: no da error y el resultado es basura.
  • Consejo: usa EXISTS cuando el JOIN te obligaría a un DISTINCT. Si solo quieres saber si hay relación y no necesitas datos de la otra tabla, evita la multiplicación de filas de raíz. Y prueba siempre las subconsultas con valores fijos —sustituye c.id por 1— antes de montar la correlación.

Ejercicios

Ejercicio 1

Atención al cliente quiere una campaña de opiniones dirigida a clientes que han comprado alguna vez pero nunca han escrito una reseña. Escribe la consulta con EXISTS y NOT EXISTS en la misma cláusula WHERE, mostrando id, nombre completo, país y número de pedidos.

Después responde: ¿cuántos clientes quedan fuera por cada una de las dos condiciones?

Ejercicio 2

Un compañero necesita los pedidos que no contienen ningún producto de alimentación y ha escrito esto:

-- ⚠️ Sospechosa
SELECT DISTINCT pe.id
FROM pedidos AS pe
JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
JOIN productos     AS p  ON p.id = lp.producto_id
WHERE p.categoria_id <> 1;
  1. Explica por qué está mal y qué pregunta responde realmente.
  2. Escríbela correctamente con NOT EXISTS y da el número de filas.
  3. ¿Podrías resolverla con un anti-join? ¿Y con NOT IN? Justifica si serían seguras.

Ejercicio 3

Compras quiere saber qué proveedores no tienen ningún producto sin vender: aquellos de los que todas sus referencias se han vendido al menos una vez. Escríbelo con doble NOT EXISTS. (Pista: misma estructura que la división relacional, pero el conjunto de referencia son los productos del propio proveedor.)

Soluciones

Solución 1

SELECT c.id,
       c.nombre || ' ' || c.apellidos AS cliente,
       c.pais,
       (SELECT COUNT(*) FROM pedidos AS pe WHERE pe.cliente_id = c.id) AS pedidos
FROM clientes AS c
WHERE EXISTS     (SELECT 1 FROM pedidos AS pe WHERE pe.cliente_id = c.id)
  AND NOT EXISTS (SELECT 1 FROM resenas AS r  WHERE r.cliente_id  = c.id)
ORDER BY c.id;
id cliente pais pedidos
5 Ana Belmonte Roca España 2
10 Julien Moreau Francia 1
12 Diego Ramos Herrera España 1

3 clientes. El reparto de los 15:

Condición Descarta Quiénes
EXISTS (pedidos) 3 Núria, Hugo e Inés: no han comprado nunca, no tienen nada que reseñar
NOT EXISTS (resenas) 9 Los 9 clientes que ya han escrito alguna reseña
Sobreviven 3 Ana, Julien y Diego

Los tres son objetivos perfectos: han comprado, están satisfechos o no lo sabemos, y nunca les hemos pedido su opinión. Fíjate en que las dos condiciones son inseparables: solo con NOT EXISTS (resenas) saldrían 6 clientes, incluyendo a tres que no han comprado nada.

Solución 2

1. Qué está mal. La consulta responde a "pedidos que contienen algún producto que no es de alimentación", que es casi lo contrario. Un pedido con aceite (categoría 1) y kombucha (categoría 4) tiene una línea que cumple categoria_id <> 1, así que aparece — aunque sí lleva alimentación. El error es de cuantificador: el WHERE de un JOIN filtra líneas, y la pregunta habla de pedidos. El DISTINCT disimula el síntoma (las filas repetidas) sin tocar la causa.

2. La versión correcta:

-- ✅ CORRECTA
SELECT pe.id AS pedido_id, pe.fecha_pedido, pe.estado
FROM pedidos AS pe
WHERE NOT EXISTS (SELECT 1
                  FROM lineas_pedido AS lp
                  JOIN productos AS p ON p.id = lp.producto_id
                  WHERE lp.pedido_id = pe.id
                    AND p.categoria_id = 1)
ORDER BY pe.id;
pedido_id fecha_pedido estado
2 2025-03-12 entregado
5 2025-05-07 entregado
7 2025-06-11 entregado
9 2025-07-15 entregado
10 2025-08-03 entregado

(5 primeras de 10 filas; las otras son los pedidos 12, 13, 16, 18 y 19.) 10 pedidos de 20 no llevan ni un solo producto de alimentación. La consulta original devolvía 18 —todos los que tienen alguna línea de otra categoría—, y ocho de ellos sí compran alimentación. Observa el cambio de lógica: la condición p.categoria_id = 1 se escribe en positivo dentro del EXISTS, y la negación la aporta el NOT. Ese es el patrón de toda pregunta con "ninguno".

3. Con anti-join, sí: FROM pedidos pe LEFT JOIN (lineas_pedido lp JOIN productos p ON p.id = lp.producto_id AND p.categoria_id = 1) ON lp.pedido_id = pe.id WHERE lp.id IS NULL funciona, pero hay que llevar la condición de categoría al ON (si va al WHERE degrada el LEFT a INNER, 03-03), lo que la hace bastante más frágil de escribir. Con NOT IN también funcionaríape.id NOT IN (SELECT lp.pedido_id FROM lineas_pedido lp JOIN productos p ON ... WHERE p.categoria_id = 1)— y sería segura porque lineas_pedido.pedido_id es NOT NULL. Pero seguirías dependiendo de una garantía del esquema que mañana puede cambiar: NOT EXISTS no depende de nada.

Solución 3

SELECT pr.id, pr.nombre AS proveedor, pr.pais
FROM proveedores AS pr
WHERE NOT EXISTS (
        SELECT 1
        FROM productos AS p
        WHERE p.proveedor_id = pr.id
          AND NOT EXISTS (SELECT 1 FROM lineas_pedido AS lp
                          WHERE lp.producto_id = p.id))
ORDER BY pr.id;
id proveedor pais
1 Huerta del Turia España
2 BioSierra Ibérica España
3 Verde Atlántico Portugal

3 proveedores de 5. Cuadra con los tres productos nunca vendidos: el 19 (Desodorante) es de Maison Nature, y el 13 (Velas) y el 20 (Espirulina) son de EcoNordic Supplies. Esos dos quedan fuera; los otros tres han colocado su catálogo entero.

La estructura es idéntica a la de la sección 8 con una única diferencia: el conjunto de referencia está correlacionado (p.proveedor_id = pr.id) en lugar de ser la tabla entera de categorías. Es la variante más útil del patrón en la práctica, y su lectura literal es "no hay ningún producto suyo del que no exista ninguna venta".

Conclusión

EXISTS es la subconsulta que más vas a escribir:

  • EXISTS es un predicado: devuelve TRUE en cuanto la subconsulta produce una fila, y nunca NULL. No mira las columnas del SELECT interno —de ahí el idiom SELECT 1, e incluso SELECT 1/0 funciona— y cortocircuita.
  • En la práctica siempre es correlacionado: la condición que enlaza con la fila externa es lo que le da sentido.
  • No multiplica filas. Los 9 productos con reseña salen 9 veces, no 12; los 6 pedidos con cosmética salen 6, no 7. Donde un JOIN pediría DISTINCT, EXISTS no lo necesita.
  • NOT EXISTS resuelve los cuatro huecos de TiendaVerde con una sola plantilla: 3 clientes sin comprar, 3 productos sin vender, 11 productos sin reseña y 5 empleados sin pedidos. Y sobre todo: es seguro con nulos y NOT IN no — la misma pregunta da 0 filas con NOT IN y 5 con NOT EXISTS, por los 10 pedidos web con empleado_id nulo.
  • Conoces las cuatro formas de responder "qué no casa" —NOT EXISTS, NOT IN, anti-join y EXCEPT— con sus diferencias en nulos, columnas disponibles, duplicados y portabilidad; la recomendación es NOT EXISTS. Y sabes traducir un "todos" con la doble negación: ningún cliente ha comprado de las seis categorías (Complementos no vendió nunca) y solo Lucía lo ha hecho de las cuatro principales.

En la lección siguiente, subconsultas en SELECT, FROM y WHERE, dejarás de mirar el qué para mirar el dónde: qué cambia según la cláusula en la que colocas la subconsulta, por qué una tabla derivada en el FROM necesita alias obligatoriamente, cómo se agrega dos veces seguidas para llegar por fin al ticket medio de 36,40 € desde su origen, y cómo se cruzan dos agregados de granularidad distinta para que los 727,95 € de producto y los 118,25 € de portes cuadren sin inflarse.

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