Curso SQL · Northwind
Tema 7 de 60
Bloque 2 · La consulta PostgreSQL 17 northwind

NULL y la lógica de tres valores

1. Qué es

NULL no es cero. No es cadena vacía. No es "falso". NULL significa desconocido.

Y de ahí sale todo lo demás: si un valor es desconocido, cualquier comparación con él es también desconocida. No verdadera, no falsa: desconocida. SQL tiene tres valores lógicos, no dos:

TRUE · FALSE · UNKNOWN

WHERE solo deja pasar TRUE. FALSE y UNKNOWN se descartan igual — y esa es la fuente de casi todos los resultados silenciosamente incompletos que verás en tu vida.

En Northwind hay NULL por todas partes y significan cosas distintas:

Columna NULL significa
customers.region El país no usa regiones (60 de 91 clientes)
orders.shipped_date Todavía no se ha enviado (21 pedidos)
employees.reports_to No tiene jefe — es el director

Fíjate: en un caso es "no aplica", en otro "aún no", y en otro "no existe". SQL usa el mismo NULL para los tres.* Interpretarlo es tu trabajo, no del motor.

2. Sintaxis

columna IS NULL
columna IS NOT NULL
columna IS DISTINCT FROM valor        -- comparación que sí trata NULL como un valor
columna IS NOT DISTINCT FROM valor

coalesce(a, b, c)     -- el primero que no sea NULL
nullif(a, b)          -- NULL si a = b; si no, a

= NULL no da error y nunca es cierto. Ese es el problema: no avisa.

3. Ejemplos sobre Northwind

La demostración de una línea:

SELECT NULL = NULL AS igual, NULL <> NULL AS distinto, NULL IS NULL AS es_nulo;
 igual | distinto | es_nulo
-------+----------+---------
       |          | t

Las dos primeras columnas salen vacías: no son t ni f, son NULL. "¿Es un desconocido igual a otro desconocido?" — no se puede saber. La tercera, IS NULL, sí responde: IS NULL es el único operador que pregunta por la ausencia sin caer en la trampa.

El mismo error, en la práctica:

SELECT count(*) FROM customers WHERE region = NULL;    --  0
SELECT count(*) FROM customers WHERE region IS NULL;   -- 60

La primera no da error. Devuelve cero y parece una respuesta.

Contar nulos:

SELECT count(*)              AS filas,
       count(region)         AS con_region,
       count(*) - count(region) AS nulos
FROM customers;
 filas | con_region | nulos
-------+------------+-------
    91 |         31 |    60

count(*) cuenta filas. count(columna) cuenta valores no nulos. Restarlos es la forma más corta de auditar una columna, y conviene hacerlo con toda columna nueva antes de fiarse de ella.

La aritmética y el texto se contagian

SELECT 'Lima-' || NULL AS concatenado, 10 + NULL AS suma;
 concatenado | suma
-------------+------
             |

Ambas dan NULL. Un solo NULL en una expresión la vuelve NULL entera. Si construyes una dirección concatenando calle, ciudad y región, y la región es NULL, te quedas sin dirección, no sin región.

Pero las agregaciones lo ignoran

SELECT count(*) AS filas, count(x) AS no_nulos, sum(x) AS suma, avg(x) AS media
FROM (VALUES (10), (20), (NULL)) t(x);
 filas | no_nulos | suma |        media
-------+----------+------+---------------------
     3 |        2 |   30 | 15.0000000000000000

La media es 15, no 10. avg sumó 30 y dividió entre 2, no entre 3: los NULL no entran en el denominador. Casi siempre es lo que quieres. Casi. Si esos nulos significaban "cero ventas", tu media está inflada y nadie te avisará.

La tabla de verdad

SELECT true AND NULL AS a, false AND NULL AS b,
       true OR  NULL AS c, false OR  NULL AS d,
       NOT NULL AS e;
 a | b | c | d | e
---+---+---+---+---
   | f | t |   |
Expresión Resultado Por qué
TRUE AND NULL NULL Depende de lo que no sé
FALSE AND NULL FALSE Ya es falso, da igual lo demás
TRUE OR NULL TRUE Ya es cierto, da igual lo demás
FALSE OR NULL NULL Depende de lo que no sé
NOT NULL NULL Lo contrario de un desconocido sigue siendo desconocido

Las dos filas en negrita son las únicas donde el NULL no contagia: cuando el otro operando ya decide el resultado por sí solo.

El complemento que no suma

SELECT count(*) FROM orders WHERE     shipped_date > required_date;   --  37
SELECT count(*) FROM orders WHERE NOT (shipped_date > required_date); -- 772

37 + 772 = 809. La tabla tiene 830 pedidos. Faltan 21: exactamente los que tienen shipped_date en NULL.

Y esos 21 son los peores posibles: son los que no se han enviado todavía. Si te piden "los pedidos que no llegaron tarde" y escribes el NOT, dejas fuera precisamente los que están sin entregar. La respuesta correcta depende de la pregunta real, y hay que decidirla a mano:

-- "no llegó tarde" incluyendo los aún no enviados como no-tarde
WHERE shipped_date <= required_date OR shipped_date IS NULL

La trampa grande: NOT IN con NULL

Empleados que no son jefe de nadie. La consulta natural:

SELECT employee_id, first_name
FROM employees
WHERE employee_id NOT IN (SELECT reports_to FROM employees);
 employee_id | first_name
-------------+------------
(0 filas)

Cero filas. Y es falso: hay siete empleados que no dirigen a nadie.

El motivo: reports_to contiene un NULL (Andrew, el director, no reporta a nadie). Así que NOT IN se convierte en:

employee_id <> 2 AND employee_id <> 5 AND employee_id <> NULL

Y esa última comparación es UNKNOWN para todas las filas. TRUE AND UNKNOWN = UNKNOWN → no pasa el WHEREninguna fila sobrevive, pase lo que pase.

Con el NULL fuera de la lista:

SELECT employee_id, first_name
FROM employees
WHERE employee_id NOT IN (SELECT reports_to FROM employees WHERE reports_to IS NOT NULL);
 employee_id | first_name
-------------+------------
           1 | Nancy
           3 | Janet
           4 | Margaret
           6 | Michael
           7 | Robert
           8 | Laura
           9 | Anne

Siete. La forma inmune a este problema es NOT EXISTS, y está en anti-join-y-semi-join.

Regla: NOT IN sobre una subconsulta es una bomba de relojería. Si la columna admite NULL, algún día devolverá cero filas. Usa NOT EXISTS.

Sustituir el nulo: coalesce y nullif

SELECT company_name, region, coalesce(region, 'SIN REGION') AS region_limpia
FROM customers
LIMIT 4;
            company_name            | region | region_limpia
------------------------------------+--------+---------------
 Alfreds Futterkiste                |        | SIN REGION
 Ana Trujillo Emparedados y helados |        | SIN REGION

coalesce devuelve el primer argumento no nulo, y acepta los que quieras: coalesce(movil, fijo, 'sin teléfono').

nullif es el inverso — convierte un valor en NULL:

SELECT nullif(10, 10) AS a, nullif(10, 5) AS b;
 a | b
---+----
   | 10

Su uso real es evitar la división por cero: total / nullif(cantidad, 0) devuelve NULL en vez de reventar.

IS DISTINCT FROM — comparar tratando NULL como valor

SELECT NULL IS NOT DISTINCT FROM NULL AS iguales,
       'A'  IS     DISTINCT FROM NULL AS distintos;
 iguales | distintos
---------+-----------
 t       | t

Devuelve siempre TRUE o FALSE, nunca UNKNOWN. Es lo que quieres cuando comparas dos versiones de un dato y "los dos están vacíos" debe contar como iguales.

Y al ordenar

En PostgreSQL, ORDER BY pone los NULL al final en orden ascendente y al principio en descendente. Se controla explícitamente:

SELECT company_name, region FROM customers ORDER BY region NULLS FIRST;

Más en ordenar-y-limitar.

4. Errores comunes

  1. = NULL o <> NULL. Nunca es cierto. No da error. Usa IS NULL / IS NOT NULL.
  2. Creer que WHERE x <> 'A' devuelve "todo lo que no es A". Devuelve todo lo que se sabe que no es A. Los nulos se quedan fuera.
  3. NOT IN con una subconsulta que puede traer NULL. Cero filas silenciosas.
  4. Concatenar sin coalesce. Un NULL en medio anula la cadena entera.
  5. Confundir NULL con cadena vacía. '' es un texto de longitud cero: existe. '' IS NULL es falso. En Oracle no —es la excepción del mundo— pero en PostgreSQL y SQL Server son cosas distintas.
  6. Interpretar todos los NULL igual. "No aplica", "aún no" y "no lo sé" se escriben igual y significan cosas opuestas. Pregunta antes de rellenarlos.

5. Diferencias con SQL Server

PostgreSQL SQL Server
Primer no nulo coalesce(a,b) COALESCE(a,b) o ISNULL(a,b)
Comparar con nulos IS DISTINCT FROM No existe hasta SQL Server 2022; antes se emula con EXISTS(SELECT a INTERSECT SELECT b)
Orden de los nulos Al final en ASC, configurable con NULLS FIRST/LAST Al principio en ASC, y no se puede cambiar directamente
SET ANSI_NULLS OFF No existe Existe (obsoleto): hace que = NULL funcione. No lo uses

La tercera fila muerde: la misma consulta con ORDER BY da un orden distinto en cada motor. Si el orden importa, escríbelo.

6. Ejercicios

  1. Cuántos pedidos están sin enviar. Y cuántos clientes no tienen fax.
  2. Lista los clientes con su región, sustituyendo el nulo por el texto n/a.
  3. Corrige esta consulta, que pretende dar los productos que no son de la categoría 1: sql SELECT product_name FROM products WHERE category_id <> 1; ¿Cuándo daría un resultado incompleto y cuándo no?
  4. Escribe una consulta que devuelva, por cada columna de customers, cuántos nulos tiene. Hazlo para region, fax y postal_code en una sola fila.
  5. Sin ejecutarla, di qué devuelve: sql SELECT count(*) FROM orders WHERE shipped_date IS NULL OR shipped_date IS NOT NULL;

7. Pregunta de negocio

Logística quiere el porcentaje de pedidos entregados con retraso. Te dan la definición: "retraso es cuando la fecha de envío es posterior a la fecha requerida".

Da el número. Y da también la frase que tienes que decir al entregarlo.

SolucionesResuélvelos antes de abrir

1.

SELECT count(*) FILTER (WHERE shipped_date IS NULL) AS sin_enviar FROM orders;   -- 21
SELECT count(*) FROM customers WHERE fax IS NULL;                                 -- 22

(FILTER se ve en agregacion-avanzada; aquí vale igual WHERE shipped_date IS NULL.)

2.

SELECT company_name, coalesce(region, 'n/a') AS region
FROM customers;

3. La consulta está bien si category_id no admite nulos, y en Northwind no los admite: es NOT NULL, así que devuelve los 65 productos correctos. Sería incompleta en cuanto un producto tuviera categoría desconocida — ese producto no es de la categoría 1, pero no aparecería. La versión defensiva:

SELECT product_name FROM products WHERE category_id IS DISTINCT FROM 1;

Lo importante del ejercicio: la respuesta depende del esquema, no de la consulta. Antes de escribir un <>, mira si la columna admite nulos con \d products.

4.

SELECT count(*) - count(region)      AS region_nulos,
       count(*) - count(fax)         AS fax_nulos,
       count(*) - count(postal_code) AS cp_nulos
FROM customers;
 region_nulos | fax_nulos | cp_nulos
--------------+-----------+----------
           60 |        22 |        1

Este patrón es un perfilado de datos en una línea y es lo primero que se ejecuta contra una tabla que no conoces.

5. 830, todas las filas. IS NULL OR IS NOT NULL cubre los tres estados sin dejar hueco, porque ambos operadores devuelven siempre TRUE o FALSE, nunca UNKNOWN. Con = NULL OR <> NULL habrían salido 0.

Pregunta de negocio.

SELECT count(*) FILTER (WHERE shipped_date > required_date) AS con_retraso,
       count(shipped_date)                                  AS enviados,
       count(*)                                             AS total,
       round(100.0 * count(*) FILTER (WHERE shipped_date > required_date)
             / count(shipped_date), 2)                      AS pct_sobre_enviados
FROM orders;
 con_retraso | enviados | total | pct_sobre_enviados
-------------+----------+-------+--------------------
          37 |      809 |   830 |               4.57

El número es 4,57%. La frase es esta:

"37 pedidos llegaron tarde. Es el 4,57% de los 809 que ya se enviaron, o el 4,46% de los 830 totales. He usado el primero, porque los 21 pedidos que faltan no se han enviado todavía: no se puede decir si llegaron tarde. Si alguno de esos 21 ya pasó su fecha requerida, el porcentaje real será mayor."

Ahí están las tres cosas que hacen buena una respuesta: el número, el denominador que elegiste, y el NULL que no escondiste. Dividir entre 830 sin decir nada también da un número — y también es defendible — pero solo si lo dices.