El error oculto de NOT IN con NULL en SQL: explicación completa

El error oculto de NOT IN con NULL en SQL

Un día un cliente me llamó porque una consulta no devolvía resultados. Querían obtener la lista de productos que no se habían vendido y usaban una instrucción que parecía correcta. Aunque no recuerdo los nombres de las tablas ni de las columnas, la consulta que usaban era similar a la siguiente. Sospeché de inmediato que se trataba del error oculto de NOT IN con NULL en SQL.

SELECT *
FROM producto
WHERE producto_id NOT IN (SELECT producto_id FROM venta);

La consulta no devolvía ningún registro, aunque había productos sin ventas. Habían pasado varias horas y el equipo ya no sabía qué revisar, así que terminaron exportando los datos a Excel para comparar listas a mano. La solución funcionó, pero no era eficiente: cada exportación abría espacio para errores, filas incompletas, filtros mal aplicados o columnas mezcladas. Además, repetir el proceso cada vez que algo fallaba hacía más difícil entender el origen real del problema.

En ese punto, en medio de la revisión apareció algo que no habían visto en SQL: un NULL mezclado entre los identificadores de producto. Volví a la subconsulta y confirmé que ese NULL estaba ahí desde el principio. Ese único valor explicaba el error oculto de NOT IN con NULL en SQL y por qué la lógica se había detenido. Antes de exponer la solución intentaré explicar qué ocurrió dentro de la consulta. La idea es revisar cómo interpreta SQL cada comparación, cómo interviene NULL en esa lógica y por qué una condición que parecía correcta terminó bloqueada por un único valor desconocido. A partir de ahí, la solución se vuelve evidente.

NULL no es un valor: el error oculto de NOT IN con NULL en SQL

En SQL, NULL no representa un número ni un texto. Tampoco representa cero ni vacío. NULL significa “desconocido”. Por esa razón, no participa en comparaciones tradicionales. Cuando se usa =, <>, !=, >, < o cualquier otro operador de comparación, el resultado no es TRUE ni FALSE. El resultado es UNKNOWN.

Entonces, ¿cómo se debe comparar NULL? NULL solo se evalúa con IS NULL o IS NOT NULL. Todo lo demás produce un estado que no es verdadero ni falso.

Supongamos que x es NULL. Veamos lo que pasa cuando lo comparamos con los operadores tradicionales, IS NULL y IS NOT NULL:

  • x = NULL → UNKNOWN
  • x <> NULL → UNKNOWN
  • x > NULL → UNKNOWN
  • x IS NULL → TRUE
  • x IS NOT NULL → FALSE

Aquí es donde ocurre lo importante: UNKNOWN significa que SQL no puede afirmar que la comparación sea verdadera ni falsa. La expresión queda en un estado intermedio que no permite tomar decisiones, y cuando ese estado llega a un WHERE, se interpreta como si la condición no se cumpliera. Por eso una sola comparación con NULL puede bloquear toda la lógica.

La lógica de tres valores en SQL

SQL no trabaja con dos valores lógicos. Trabaja con tres: TRUE, FALSE y UNKNOWN. Este tercer estado cambia la forma en que se evalúan las expresiones y modifica el resultado de operadores como NOT, OR y AND. Cuando una comparación produce UNKNOWN, la expresión completa puede quedar en un estado que no permite decidir si la condición se cumple o no. Por eso es necesario revisar cómo funcionan estos operadores cuando interviene UNKNOWN y entender cómo ese estado termina afectando la condición final en el WHERE. Revisemos las siguientes tablas de verdad para analizar el comportamiento de NULL bajo condiciones específicas.

NOT

EntradaResultado
TRUEFALSE
FALSETRUE
UNKNOWNUNKNOWN

OR

ABResultado
TRUEcualquier valorTRUE
FALSEFALSEFALSE
FALSEUNKNOWNUNKNOWN
UNKNOWNUNKNOWNUNKNOWN

AND

ABResultado
FALSEcualquier valorFALSE
TRUETRUETRUE
TRUEUNKNOWNUNKNOWN
UNKNOWNUNKNOWNUNKNOWN

La conclusión de todo esto es que UNKNOWN se propaga. Si aparece en una expresión, esta puede terminar en UNKNOWN.

Cómo se transforma NOT IN cuando hay NULL

Sin pérdida de generalidad, supongamos que los valores que nos interesa comparar son 1, 2 y NULL. En el caso de la tienda, estos valores eran los identificadores de los artículos que se habían vendido. Con ese conjunto reducido podemos observar cómo se comportan las comparaciones y cómo un solo NULL altera el resultado completo. Cuando se escribe:

x NOT IN (1, NULL, 2)

SQL lo interpreta como:

NOT (x = 1 OR x = NULL OR x = 2)

Cada comparación produce un valor lógico:

  • x = 1 → TRUE o FALSE
  • x = NULL → siempre UNKNOWN
  • x = 2 → TRUE o FALSE

La expresión interna queda:

NOT ( (x = 1) OR UNKNOWN OR (x = 2) )

Evaluación completa: el error oculto de NOT IN con NULL en SQL

Primer caso: Si x es 1:

NOT ( TRUE OR UNKNOWN OR FALSE ) → 
NOT TRUE → FALSE

Segundo caso: Si x es 2:

NOT ( FALSE OR UNKNOWN OR TRUE ) → 
NOT TRUE → FALSE

Tercer caso: Si x no está en la lista:

NOT ( FALSE OR UNKNOWN OR FALSE ) → 
NOT UNKNOWN → UNKNOWN

Como se observa en este ejemplo, estas comparaciones hacen que el resultado sea FALSE o UNKNOWN. En consecuencia, ningún valor cumple la condición y la expresión completa nunca llega a evaluarse como verdadera. Por eso la consulta queda vacía incluso cuando existen productos sin ventas.

En resumen: Si la lista contiene un solo NULL, NOT IN nunca devuelve TRUE. Este es exactamente el error oculto de NOT IN con NULL en SQL.

Qué ocurrió en la tienda

La tabla de ventas tenía un NULL en producto_id. Ese NULL provenía de una regla ON DELETE SET NULL. Ese único valor anuló toda la expresión. La consulta dejó de identificar productos sin ventas.

Resolviendo el error oculto de NOT IN con NULL en SQL

Hay dos formas seguras de obtener el resultado correcto.

1. NOT EXISTS

SELECT p.*
FROM producto p
WHERE NOT EXISTS (
    SELECT 1
    FROM venta v
    WHERE v.producto_id = p.producto_id
);

Además de evitar el problema del NULL, NOT EXISTS aporta una ventaja: el motor de bases de datos puede usar el índice que conecta ambas tablas. El predicado interno establece una relación directa entre producto y venta, y esa relación permite que el optimizador navegue por el índice sin revisar filas que no corresponden. Esto hace que la búsqueda sea más selectiva y más rápida. También ocurre algo clave: EXISTS se detiene en cuanto encuentra la primera coincidencia. No necesita recorrer toda la tabla ni evaluar más filas. Con una sola coincidencia, la condición queda resuelta. Esa combinación —uso del índice y evaluación corta— hace que NOT EXISTS sea más seguro y más eficiente que NOT IN en consultas reales.

2. LEFT JOIN con IS NULL

SELECT p.*
FROM producto p
LEFT JOIN venta v ON v.producto_id = p.producto_id
WHERE v.producto_id IS NULL;

El LEFT JOIN también resuelve el problema y lo hace de una forma que el motor puede optimizar. Cuando se usa la condición v.producto_id IS NULL, el motor identifica que solo necesita revisar las filas del lado izquierdo y verificar si existe o no una coincidencia en la tabla relacionada. Esto permite aprovechar el índice de la tabla de ventas para buscar coincidencias rápidas y descartar filas sin necesidad de recorrer toda la tabla. Además, el LEFT JOIN evita el problema porque no necesita comparar directamente con NULL. La lógica es distinta: si no existe una fila relacionada en la tabla de ventas, la columna del lado derecho simplemente queda en NULL como resultado natural de la unión. Esa “ausencia de coincidencia” es suficiente para identificar que el producto no tiene ventas. No intervienen operadores lógicos ni comparaciones que puedan generar UNKNOWN; el NULL proviene del propio mecanismo del JOIN, no de una comparación fallida.

En cuanto al rendimiento, LEFT JOIN y NOT EXISTS suelen comportarse de manera similar cuando las tablas están bien indexadas, pero en la mayoría de los motores modernos NOT EXISTS tiende a ser más eficiente. La razón es que EXISTS permite una evaluación corta: el motor se detiene en cuanto encuentra la primera coincidencia, sin revisar filas adicionales. En cambio, el LEFT JOIN necesita construir el conjunto resultante de la unión, aunque luego solo se filtren las filas donde la columna del lado derecho queda en NULL. Esto implica más trabajo intermedio, especialmente si la tabla relacionada es grande. Por eso, aunque ambos enfoques son correctos y seguros frente a NULL, NOT EXISTS suele ofrecer un plan más directo y con menos procesamiento innecesario.

Esquema reproducible con CTE

Este ejemplo reproduce el problema sin crear tablas físicas:

-- Esto es una mala práctica NOT IN
WITH producto AS (
    SELECT * FROM (VALUES
		(1, 'Laptop'),
		(2, 'Mouse'),
		(3, 'Teclado'),
		(4, 'Monitor'),
		(5, 'Impresora'),
		(6, 'Parlantes'),
		(7, 'Webcam'),
		(8, 'Disco Externo')
    ) AS t(producto_id, nombre)
),
venta AS (
    SELECT * FROM (VALUES
        (101, 1, '2024-01-10'),
        (102, NULL, '2024-01-12'),
        (103, 2, '2024-02-01')
    ) AS t(venta_id, producto_id, fecha)
)
SELECT producto_id, nombre
FROM producto
WHERE producto_id NOT IN (SELECT producto_id FROM venta);

-- Buena práctica, con NOT EXISTS
WITH producto AS (
    SELECT * FROM (VALUES
		(1, 'Laptop'),
		(2, 'Mouse'),
		(3, 'Teclado'),
		(4, 'Monitor'),
		(5, 'Impresora'),
		(6, 'Parlantes'),
		(7, 'Webcam'),
		(8, 'Disco Externo')
    ) AS t(producto_id, nombre)
),
venta AS (
    SELECT * FROM (VALUES
        (101, 1, '2024-01-10'),
        (102, NULL, '2024-01-12'),
        (103, 2, '2024-02-01')
    ) AS t(venta_id, producto_id, fecha)
)
SELECT p.producto_id, p.nombre
FROM producto p
WHERE NOT EXISTS (
    SELECT 1
    FROM venta v
    WHERE v.producto_id = p.producto_id
);

-- Buena práctica, con LEFT JOIN
WITH producto AS (
    SELECT * FROM (VALUES
		(1, 'Laptop'),
		(2, 'Mouse'),
		(3, 'Teclado'),
		(4, 'Monitor'),
		(5, 'Impresora'),
		(6, 'Parlantes'),
		(7, 'Webcam'),
		(8, 'Disco Externo')
    ) AS t(producto_id, nombre)
),
venta AS (
    SELECT * FROM (VALUES
        (101, 1, '2024-01-10'),
        (102, NULL, '2024-01-12'),
        (103, 2, '2024-02-01')
    ) AS t(venta_id, producto_id, fecha)
)
SELECT p.producto_id, p.nombre
FROM producto p
LEFT JOIN venta v
    ON v.producto_id = p.producto_id
WHERE v.producto_id IS NULL;

Puede descargar el código anterior en este enlace.

Cierre

NULL no es un valor, y por eso NOT IN no sabe cómo manejarlo. Si la lista contiene un NULL, toda la condición queda atrapada en UNKNOWN y deja de funcionar. La forma segura de resolverlo es usar NOT EXISTS o un LEFT JOIN con IS NULL, que no dependen de comparaciones ambiguas y permiten que el motor use índices de manera eficiente. Con estos patrones, la consulta vuelve a producir el resultado correcto y se evita ese error silencioso que hace que NOT IN falle cuando aparece un NULL en la lista.

Foto Vitaly Garievpexels.com

error: Content is protected !!