Esta es la lección de la deuda. Desde 01-03 vienes tropezándote con el mismo fenómeno con distintos disfraces: empleado_id = NULL devolvió cero filas cuando había diez; empleado_id <> 4 devolvió seis en lugar de dieciséis; los diez pedidos web desaparecieron de un INNER JOIN; una condición sobre la tabla derecha en el WHERE degradó un LEFT JOIN a INNER; y ayer mismo, NOT IN (4, 5, NULL) devolvió exactamente nada. Cada vez te prometimos que "se explica en 04-03".
Aquí está. Todos esos casos son la misma cosa, y esa cosa se llama lógica de tres valores. Al terminar esta lección no habrás aprendido cinco reglas nuevas: habrás aprendido una sola, y todos esos síntomas dejarán de sorprenderte porque los podrás predecir. También verás la parte que casi nadie explica: que el estándar SQL trata los NULL de una forma en el WHERE y de otra distinta en DISTINCT, GROUP BY y ORDER BY, y que conocer esa incoherencia es lo que separa a quien sufre los nulos de quien los usa.
Contenido
- Qué es
NULLy qué no es - Los tres
NULLde TiendaVerde y qué significan - La lógica de tres valores:
TRUE,FALSEyUNKNOWN - Las tablas de verdad completas
IS NULLeIS NOT NULLIS DISTINCT FROMeIS NOT DISTINCT FROM- Dónde
NULLsí se agrupa conNULL NULLy la restricciónUNIQUENULLen concatenaciones y en aritmética- La resolución de los casos pendientes
- Diseñar con
NULL, con centinelas o conNOT NULL - Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Qué es
NULL y qué no es
NULL y qué no esNULL es la ausencia de valor. No es un valor especial: es la marca de que en esa celda no hay ninguno.
La confusión más extendida consiste en tratarlo como si fuera un valor concreto. No lo es, y la diferencia es visible en cuanto comparas:
NULL no es… |
Por qué importa |
|---|---|
| Cero | 0 es un número: significa "ninguna unidad". NULL significa "no sé cuántas unidades". 0 + 5 = 5; NULL + 5 = NULL |
Cadena vacía '' |
'' es una cadena de longitud 0, un valor real. LENGTH('') es 0; LENGTH(NULL) es NULL |
FALSE |
FALSE es una respuesta. NULL es la falta de respuesta |
Otro NULL |
Dos ausencias no son iguales: no sabes qué había en ninguna de las dos |
Ese último punto es el corazón de todo. Si en productos.coste hay dos filas con NULL, ¿son iguales sus costes? No lo sabes. Podrían ser 3,20 € y 47 €. SQL, con criterio, se niega a afirmar que son iguales; y también se niega a afirmar que son distintos.
La frase que resume la lección:
NULLno significa "vacío", significa "desconocido". Y todo lo que comparas con algo desconocido produce un resultado desconocido.
- Los tres
NULL de TiendaVerde y qué significan
NULL de TiendaVerde y qué significanEn un esquema bien diseñado, cada columna que admite nulos lo hace por una razón de negocio concreta. TiendaVerde tiene tres, y las tres significan cosas distintas:
| Columna | Filas con NULL |
Qué significa en el negocio |
|---|---|---|
pedidos.empleado_id |
10 de 20 | Pedido entrado por la web, sin comercial asignado. No es que se haya perdido el dato: es que no existe |
clientes.referido_por_id |
7 de 15 | Cliente que llegó por su cuenta, sin programa de referidos |
empleados.jefe_id |
1 de 8 | La dirección general: Rosa Alcázar Vives no tiene jefe porque está arriba del organigrama |
Vamos a verlos.
SELECT id,
cliente_id,
fecha_pedido,
estado,
empleado_id
FROM pedidos
WHERE empleado_id IS NULL
ORDER BY id;| id | cliente_id | fecha_pedido | estado | empleado_id |
|---|---|---|---|---|
| 1 | 1 | 2025-03-04 | entregado | (null) |
| 3 | 3 | 2025-04-02 | entregado | (null) |
| 5 | 1 | 2025-05-07 | entregado | (null) |
| 7 | 6 | 2025-06-11 | entregado | (null) |
| 9 | 8 | 2025-07-15 | entregado | (null) |
| 11 | 2 | 2025-09-09 | entregado | (null) |
| 13 | 11 | 2025-10-22 | entregado | (null) |
| 15 | 1 | 2025-12-02 | entregado | (null) |
| 17 | 7 | 2026-01-13 | enviado | (null) |
| 19 | 6 | 2026-02-09 | pagado | (null) |
10 filas, el 50 % del canal de venta.
SELECT id,
nombre || ' ' || apellidos AS cliente,
fecha_registro,
referido_por_id
FROM clientes
WHERE referido_por_id IS NULL
ORDER BY id;| id | cliente | fecha_registro | referido_por_id |
|---|---|---|---|
| 1 | Lucía Martínez Soler | 2025-01-10 | (null) |
| 4 | Javier Ortega Ruiz | 2025-02-14 | (null) |
| 6 | Pau Llorens Vidal | 2025-03-09 | (null) |
| 7 | Sofia Moreira Costa | 2025-03-21 | (null) |
| 9 | Camille Dubois | 2025-04-18 | (null) |
| 12 | Diego Ramos Herrera | 2025-06-01 | (null) |
| 14 | Hugo Iglesias Pardo | 2025-09-12 | (null) |
7 filas. Los otros 8 clientes llegaron recomendados.
SELECT id,
nombre || ' ' || apellidos AS empleado,
puesto,
jefe_id
FROM empleados
WHERE jefe_id IS NULL;| id | empleado | puesto | jefe_id |
|---|---|---|---|
| 1 | Rosa Alcázar Vives | Directora general | (null) |
1 fila. Este NULL no es un dato ausente ni un dato desconocido: es una afirmación estructural ("aquí termina la jerarquía"), y por eso el SELF JOIN de 03-06 necesitaba un LEFT JOIN para no perder a Rosa.
Y todavía hay un cuarto tipo de NULL que ya has visto y que no está en ninguna tabla: los que fabrica el motor al construir un LEFT JOIN (03-03, sección 3). Esos no estaban en los datos; aparecen porque no hubo pareja.
Origen del NULL |
Ejemplo | ¿Está en disco? |
|---|---|---|
| Dato que no existe (regla de negocio) | empleado_id de un pedido web |
Sí |
| Dato desconocido | Un coste que el proveedor no ha comunicado |
Sí |
| Frontera estructural | jefe_id de la dirección general |
Sí |
Generado por un LEFT JOIN sin pareja |
pe.estado de Núria Bosch |
No, lo crea la consulta |
| Resultado de una operación con nulos | precio * NULL |
No, lo crea la expresión |
- La lógica de tres valores:
TRUE, FALSE y UNKNOWN
TRUE, FALSE y UNKNOWNFuera de SQL, una condición es verdadera o falsa. En SQL hay tres resultados posibles, porque una comparación con NULL no puede decidirse:
UNKNOWN no es un tercer estado exótico: es exactamente lo que dice la palabra. "¿El empleado del pedido 1 es el número 4?" — no lo sabemos, porque no hay empleado registrado.
Y ahora la regla que lo gobierna todo, ya enunciada en 02-03 y que conviene repetir textualmente:
WHEREconserva únicamente las filas cuya condición se evalúa aTRUE. Las que danFALSEse descartan, y las que danUNKNOWNtambién.
Lo mismo vale para el ON de un JOIN y para el HAVING que verás en 04-06. Un solo criterio, tres cláusulas.
flowchart TD
A["Condición evaluada<br/>sobre una fila"] --> B{"¿Resultado?"}
B -->|"TRUE"| C["✅ La fila pasa"]
B -->|"FALSE"| D["❌ La fila se descarta"]
B -->|"UNKNOWN"| E["❌ La fila se descarta<br/>(igual que FALSE)"]
Ahí está la clave de por qué FALSE y UNKNOWN parecen lo mismo mirando el resultado de una sola consulta: ambos descartan la fila. La diferencia solo se hace visible cuando niegas la condición, porque NOT FALSE es TRUE pero NOT UNKNOWN sigue siendo UNKNOWN.
- Las tablas de verdad completas
AND
AND |
TRUE |
FALSE |
UNKNOWN |
|---|---|---|---|
TRUE |
TRUE |
FALSE |
UNKNOWN |
FALSE |
FALSE |
FALSE |
FALSE |
UNKNOWN |
UNKNOWN |
FALSE |
UNKNOWN |
Las dos celdas destacadas son las importantes: FALSE AND UNKNOWN es FALSE, no UNKNOWN. Si uno de los factores ya es falso, el conjunto es falso aunque el otro sea desconocido. Da igual lo que no sepas: la conjunción ya está decidida.
OR
OR |
TRUE |
FALSE |
UNKNOWN |
|---|---|---|---|
TRUE |
TRUE |
TRUE |
TRUE |
FALSE |
TRUE |
FALSE |
UNKNOWN |
UNKNOWN |
TRUE |
UNKNOWN |
UNKNOWN |
Simétricamente: TRUE OR UNKNOWN es TRUE. Si una alternativa ya es cierta, la disyunción es cierta.
Estas dos celdas explican por sí solas la asimetría de 04-02:
| Operador | Se expande a | Celda que decide | Resultado con un NULL en la lista |
|---|---|---|---|
IN |
OR encadenados |
TRUE OR UNKNOWN = TRUE |
Funciona con normalidad |
NOT IN |
AND encadenados |
TRUE AND UNKNOWN = UNKNOWN |
Cero filas siempre |
NOT
| Entrada | NOT entrada |
|---|---|
TRUE |
FALSE |
FALSE |
TRUE |
UNKNOWN |
UNKNOWN |
Negar lo desconocido sigue siendo desconocido. De aquí sale la observación práctica más útil de toda la lección:
Una condición y su negación no cubren todas las filas. Si
estado = 'entregado'da 14 filas yestado <> 'entregado'da 6, y la tabla tiene 20, la lógica cierra porqueestadoesNOT NULL. Conempleado_id = 4(4 filas) yempleado_id <> 4(6 filas), 4 + 6 = 10 ≠ 20: las 10 que faltan son las nulas. Esa suma es tu mejor detector de nulos.
Y una advertencia sobre el orden de evaluación
Las tablas de verdad describen el significado, no el orden en que PostgreSQL evalúa las condiciones. El motor puede reordenar los operandos de un AND libremente si eso abarata el plan. Por eso no puedes usar un AND como protección:
PostgreSQL podría evaluar la división primero. La forma segura es CASE o NULLIF, ambos del módulo 6.
IS NULL e IS NOT NULL
IS NULL e IS NOT NULLComo = NULL nunca es TRUE, SQL ofrece un predicado específico. IS NULL no es una comparación: es una pregunta sobre el estado de la celda, y por eso devuelve siempre TRUE o FALSE, nunca UNKNOWN.
| igualdad | es_nulo | no_es_nulo |
|---|---|---|
| (null) | true | false |
La primera columna es NULL (es decir, UNKNOWN); las otras dos son booleanos de verdad. Esa es toda la diferencia, y explica por qué WHERE empleado_id IS NULL funciona y WHERE empleado_id = NULL no.
| Escritura | Tipo de resultado | ¿Devuelve filas? |
|---|---|---|
columna = NULL |
UNKNOWN siempre |
Nunca |
columna <> NULL |
UNKNOWN siempre |
Nunca |
columna IS NULL |
TRUE / FALSE |
Sí, las nulas |
columna IS NOT NULL |
TRUE / FALSE |
Sí, las no nulas |
Y la pareja IS NULL / IS NOT NULL sí parte la tabla en dos mitades exactas:
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE empleado_id IS NULL) AS sin_comercial,
COUNT(*) FILTER (WHERE empleado_id IS NOT NULL) AS con_comercial
FROM pedidos;| total | sin_comercial | con_comercial |
|---|---|---|
| 20 | 10 | 10 |
10 + 10 = 20. Cierra. (FILTER y COUNT son el tema de la lección siguiente; aquí solo hacen de instrumento de medida.)
Nota de dialecto: algunos motores permiten configurar que
= NULLse comporte comoIS NULL—SQL Server lo hace conSET ANSI_NULLS OFF, hoy obsoleto—. No lo actives nunca. Convierte tu SQL en algo que solo funciona en tu servidor y rompe la lógica que acabas de aprender.
IS DISTINCT FROM e IS NOT DISTINCT FROM
IS DISTINCT FROM e IS NOT DISTINCT FROMA veces sí quieres comparar tratando NULL como "un valor más": que dos nulos se consideren iguales y que un nulo se considere distinto de cualquier valor. Para eso existe este par de operadores, que nunca devuelven UNKNOWN.
| Expresión | = / <> |
IS [NOT] DISTINCT FROM |
|---|---|---|
5 = 5 / 5 IS NOT DISTINCT FROM 5 |
TRUE |
TRUE |
5 = 4 / 5 IS NOT DISTINCT FROM 4 |
FALSE |
FALSE |
5 = NULL / 5 IS NOT DISTINCT FROM NULL |
UNKNOWN |
FALSE |
NULL = NULL / NULL IS NOT DISTINCT FROM NULL |
UNKNOWN |
TRUE |
Con datos reales, la diferencia salta a la vista. "Todos los pedidos que no gestionó Óscar Peris (empleado 4)":
-- ⚠️ INCORRECTA para la pregunta: pierde los pedidos web
SELECT id, cliente_id, empleado_id, estado
FROM pedidos
WHERE empleado_id <> 4
ORDER BY id;| id | cliente_id | empleado_id | estado |
|---|---|---|---|
| 4 | 4 | 5 | entregado |
| 8 | 7 | 5 | entregado |
| 12 | 10 | 5 | entregado |
| 14 | 12 | 6 | entregado |
| 18 | 5 | 5 | pagado |
| 20 | 9 | 6 | pendiente |
6 filas. Es el resultado que te sorprendió en 02-03.
-- ✅ CORRECTA: un pedido sin comercial tampoco lo gestionó Óscar
SELECT id, cliente_id, empleado_id, estado
FROM pedidos
WHERE empleado_id IS DISTINCT FROM 4
ORDER BY id;16 filas: las 6 anteriores más los 10 pedidos web. Ahora sí, 4 + 16 = 20.
La forma larga equivalente sería WHERE empleado_id <> 4 OR empleado_id IS NULL, que es lo que escribiste a mano en la solución 3 de 02-03. IS DISTINCT FROM dice lo mismo en cuatro palabras y sin riesgo de olvidar el segundo término.
Su uso más valioso, sin embargo, aparece al comparar dos columnas que pueden ser nulas las dos:
-- ¿Han cambiado los datos entre dos versiones de una fila?
WHERE nuevo.coste IS DISTINCT FROM viejo.costeCon <>, una fila cuyo coste pasa de NULL a 12.00 no se detectaría como cambio (la comparación daría UNKNOWN). Con IS DISTINCT FROM, sí. Es el operador de referencia para detección de cambios, comparación de versiones y procesos de sincronización.
Nota de dialecto:
IS DISTINCT FROMes SQL estándar y existe en PostgreSQL, SQLite y Oracle 23ai. MySQL usa el operador<=>("null-safe equal"), que equivale aIS NOT DISTINCT FROM. SQL Server no tenía nada hasta 2022, que introdujoIS [NOT] DISTINCT FROM; en versiones anteriores hay que escribir la forma larga conOR ... IS NULL.
- Dónde
NULL sí se agrupa con NULL
NULL sí se agrupa con NULLAquí llega la parte que descoloca a mucha gente: el estándar SQL no es coherente consigo mismo. Acabas de aprender que NULL = NULL es UNKNOWN, y sin embargo:
| empleado_id |
|---|
| 4 |
| 5 |
| 6 |
| (null) |
4 filas. Diez pedidos tienen empleado_id nulo y DISTINCT los ha colapsado en una sola fila. Es decir: para DISTINCT, esos diez nulos sí son iguales entre sí.
Lo mismo ocurre al agrupar:
| empleado_id | pedidos |
|---|---|
| 4 | 4 |
| 5 | 4 |
| 6 | 2 |
| (null) | 10 |
4 grupos, y los NULL forman uno propio con 10 filas. Esto es enormemente útil —de hecho es lo que permite contar el canal web de un vistazo— y lo desarrollará la lección 04-05.
Y al ordenar, los nulos también se agrupan entre sí, aunque su posición sea configurable (02-05):
| id | empleado_id | estado |
|---|---|---|
| 1 | (null) | entregado |
| 3 | (null) | entregado |
| 5 | (null) | entregado |
| 7 | (null) | entregado |
| 9 | (null) | entregado |
| 11 | (null) | entregado |
| 13 | (null) | entregado |
| 15 | (null) | entregado |
| 17 | (null) | enviado |
| 19 | (null) | pagado |
| 2 | 4 | entregado |
| 6 | 4 | entregado |
(12 primeras de 20 filas.)
La tabla que conviene tener a mano
| Contexto | ¿Dos NULL se consideran iguales? |
Consecuencia práctica |
|---|---|---|
WHERE, ON, HAVING con = |
No | La fila se descarta |
IS NOT DISTINCT FROM |
Sí | Comparación segura ante nulos |
DISTINCT / DISTINCT ON |
Sí | Todos los nulos colapsan en uno |
GROUP BY |
Sí | Los nulos forman un grupo |
ORDER BY |
Sí | Van todos juntos (NULLS FIRST/LAST) |
UNION, INTERSECT, EXCEPT |
Sí | Deduplican los nulos como cualquier valor |
Restricción UNIQUE |
No (por defecto) | Se admiten varios nulos en la columna |
| Funciones de agregación | Se ignoran | AVG promedia solo los no nulos (04-04) |
COUNT(*) |
Se cuentan | Cuenta filas, no valores |
La justificación oficial del estándar es que WHERE responde a preguntas sobre hechos (y de un desconocido no puedes afirmar nada), mientras que GROUP BY y DISTINCT responden a preguntas sobre agrupación de filas (y ahí "sin valor" es una categoría tan válida como cualquier otra). Es una explicación razonable, pero no cambia el hecho de que el mismo símbolo se comporte de dos formas. Apréndete la tabla; no intentes deducirla.
NULL y la restricción UNIQUE
NULL y la restricción UNIQUEConsecuencia directa de la fila penúltima de la tabla anterior: en PostgreSQL, una columna con restricción UNIQUE admite tantos NULL como quieras, porque dos nulos no se consideran duplicados.
-- Si productos tuviera una columna 'codigo_ean' UNIQUE que admitiera nulos:
-- estas dos filas convivirían sin problema
INSERT INTO productos (nombre, codigo_ean, ...) VALUES ('Producto A', NULL, ...);
INSERT INTO productos (nombre, codigo_ean, ...) VALUES ('Producto B', NULL, ...);Esto sorprende, y a veces es justo lo que quieres ("el EAN es único, pero no todos los productos lo tienen todavía") y a veces no ("solo puede haber un cliente sin verificar"). PostgreSQL 15 añadió la forma de exigir lo contrario:
-- PostgreSQL 15+: los NULL cuentan como duplicados entre sí
ALTER TABLE productos
ADD CONSTRAINT productos_ean_uk UNIQUE NULLS NOT DISTINCT (codigo_ean);En TiendaVerde las dos columnas UNIQUE —categorias.nombre y clientes.email— son además NOT NULL, así que la cuestión no llega a plantearse. Pero es un detalle de diseño que hay que decidir conscientemente, no descubrir en producción.
Nota de dialecto: este es uno de los puntos donde los motores más divergen. PostgreSQL, MySQL, SQLite y Oracle admiten varios nulos en una columna
UNIQUE; SQL Server admite solo uno (trata todos los nulos como el mismo valor a efectos del índice único). Una tabla que funciona en PostgreSQL puede fallar al migrarla a SQL Server por este motivo exacto. Las restricciones se estudian en el módulo 5.
Y recuerda que una clave primaria no tiene este debate: PRIMARY KEY implica NOT NULL, siempre y en todos los motores.
NULL en concatenaciones y en aritmética
NULL en concatenaciones y en aritméticaLa regla es de una simplicidad brutal: casi cualquier operación con NULL devuelve NULL. Se dice que el nulo se propaga.
SELECT 100 + NULL AS suma,
100 * NULL AS producto,
'Hola' || NULL AS concatenacion,
UPPER(NULL) AS mayusculas;| suma | producto | concatenacion | mayusculas |
|---|---|---|---|
| (null) | (null) | (null) | (null) |
Lo viste en 02-02 con la concatenación, y en 03-03 con los LEFT JOIN. Aquí está el porqué: si no sabes cuál es el segundo sumando, no puedes saber cuál es la suma.
Sobre datos reales, el efecto en un LEFT JOIN:
SELECT c.id,
c.nombre || ' ' || c.apellidos AS cliente,
pe.id AS pedido_id,
pe.gastos_envio,
pe.gastos_envio * 2 AS envio_doble
FROM clientes AS c
LEFT JOIN pedidos AS pe ON pe.cliente_id = c.id
WHERE c.id IN (12, 13, 14, 15)
ORDER BY c.id, pe.id;| id | cliente | pedido_id | gastos_envio | envio_doble |
|---|---|---|---|---|
| 12 | Diego Ramos Herrera | 14 | 4.95 | 9.90 |
| 13 | Núria Bosch Ferrer | (null) | (null) | (null) |
| 14 | Hugo Iglesias Pardo | (null) | (null) | (null) |
| 15 | Inés Carrasco Vega | (null) | (null) | (null) |
envio_doble vale NULL, no 0.00, para los tres clientes sin pedidos. En un informe eso puede aparecer como una celda vacía, y quien la lea puede interpretarla como un cero. No lo es: es "no aplica".
Las excepciones
No todo propaga el nulo. Conviene conocer las excepciones porque son precisamente las herramientas para tratarlo:
| Construcción | Con NULL |
Comentario |
|---|---|---|
IS NULL / IS NOT NULL |
Devuelve booleano | El predicado de esta lección |
IS DISTINCT FROM |
Devuelve booleano | Sección 6 |
COALESCE(a, b, c) |
Devuelve el primer no nulo | 06-04 |
NULLIF(a, b) |
Convierte un valor en NULL |
06-04 |
CASE WHEN ... IS NULL THEN ... |
Permite decidir | 06-05 |
CONCAT('a', NULL, 'b') |
Ignora los nulos → 'ab' |
La alternativa a ||, en 06-01 |
| Funciones de agregación | Ignoran los nulos | 04-04, la lección siguiente |
COUNT(*) |
Cuenta la fila igualmente | 04-04 |
Las tres primeras filas ya las manejas. Las de COALESCE, NULLIF y CASE —las que sustituyen un nulo por un valor presentable— son el contenido de 06-04 y 06-05; ahí escribirás COALESCE(pe.gastos_envio, 0) para que la celda del informe muestre 0.00. Y la de los agregados es la primera cosa que verás mañana.
- La resolución de los casos pendientes
Vamos a cerrar, uno a uno, todos los cabos sueltos del curso. Los cinco tienen la misma explicación.
Caso 1: WHERE empleado_id = NULL devolvió 0 filas (02-03)
empleado_id = NULL se evalúa a UNKNOWN para las veinte filas: para las diez con valor porque no puedes comparar un número con lo desconocido, y para las diez nulas porque NULL = NULL tampoco es cierto. El WHERE descarta todo lo que no sea TRUE. Cero filas, y no podría ser de otro modo. Lo correcto es IS NULL.
Caso 2: WHERE empleado_id <> 4 devolvió 6 y no 16 (02-03)
Los diez pedidos web evalúan NULL <> 4 → UNKNOWN → descartados. Quedan los 10 con comercial, menos los 4 de Óscar: 6 filas. Si la pregunta era "los que no gestionó Óscar", la respuesta correcta tiene 16 filas y se escribe WHERE empleado_id IS DISTINCT FROM 4, como en la sección 6.
Caso 3: el INNER JOIN con empleados perdió 10 pedidos (03-02)
La condición ON pe.empleado_id = e.id es una comparación como cualquier otra. Para los pedidos web da UNKNOWN, ninguna fila de empleados la satisface, y el INNER JOIN los descarta. El ON sigue la misma regla que el WHERE.
Caso 4: una condición en el WHERE degradó un LEFT JOIN a INNER (03-03)
El LEFT JOIN fabrica una fila para Núria con todas las columnas de pedidos a NULL. Después, WHERE pe.estado = 'entregado' evalúa NULL = 'entregado' → UNKNOWN → descartada. Por eso 18 filas se convertían en 14 y 15 clientes en 12. La condición debe ir en el ON, que actúa antes de fabricar esos nulos.
Caso 5: NOT IN (4, 5, NULL) devolvió 0 filas (04-02)
Se expande a ... AND empleado_id <> NULL, y ese factor es UNKNOWN para toda fila. TRUE AND UNKNOWN = UNKNOWN (tabla de verdad de la sección 4). Cero filas garantizadas, para cualquier tabla y cualquier dato.
El patrón común
flowchart LR
A["Un NULL entra<br/>en una comparación"] --> B["El resultado es UNKNOWN"]
B --> C["WHERE / ON / HAVING<br/>solo dejan pasar TRUE"]
C --> D["La fila desaparece<br/>sin error y sin aviso"]
D --> E["🔍 Síntoma: el filtro y su<br/>negación no suman el total"]
Los cinco casos son el mismo caso. Una vez que lo ves así, dejan de ser cinco reglas que memorizar y pasan a ser una que aplicar.
- Diseñar con
NULL, con centinelas o con NOT NULL
NULL, con centinelas o con NOT NULLCuando te toque diseñar una tabla (módulo 5) tendrás que decidir, columna a columna, si admite nulos. Hay tres estrategias y ninguna es universalmente correcta.
| Estrategia | Ejemplo | A favor | En contra |
|---|---|---|---|
Permitir NULL |
pedidos.empleado_id |
Modela con honestidad "no hay valor". No inventa datos. Los agregados lo ignoran solos | Obliga a manejar la lógica de tres valores en cada consulta |
| Valor centinela | Un empleado ficticio con id = 0 llamado "Web" |
Las consultas se simplifican: JOIN normales, sin LEFT, sin IS NULL |
El centinela es un dato falso: aparece en los recuentos, en los DISTINCT y en los informes. Hay que acordarse de excluirlo siempre |
NOT NULL con DEFAULT |
pedidos.gastos_envio NOT NULL DEFAULT 0 |
La columna nunca sorprende. Sumar es sumar | Solo vale cuando existe un valor por defecto verdadero. 0.00 de portes es un hecho; un coste a 0.00 sería mentira |
Criterios para elegir:
- ¿Existe un valor por defecto que sea cierto? Si sí,
NOT NULL DEFAULT. Los portes gratuitos son 0,00 € de verdad, no una ausencia. - ¿La ausencia significa algo para el negocio? Si sí,
NULLy documéntalo. "Pedido web" es una información valiosa, no un hueco. - ¿Estás tentado de usar
-1,0,'N/A'o'9999-12-31'? Cuidado: eso es un centinela disfrazado.AVGlo promediará,MINlo devolverá como mínimo yCOUNTlo contará. TiendaVerde sería un desastre si el canal web se hubiera codificado comoempleado_id = 0: cualquier "media de pedidos por comercial" quedaría envenenada.
La postura sensata: los
NULLson parte del modelo relacional y esconderlos con centinelas no elimina el problema, solo lo hace invisible. UsaNOT NULLsiempre que puedas justificar un valor real por defecto; usaNULLcuando la ausencia sea legítima; y no uses centinelas salvo que tengas una razón muy concreta y la dejes escrita en la documentación del esquema.
Errores Comunes y Consejos
- Escribir
= NULLo<> NULL. Cero filas siempre. EsIS NULL/IS NOT NULL. - Creer que
NULLes cero o cadena vacía.NULL + 5esNULL;0 + 5es5.NULL || 'a'esNULL;'' || 'a'es'a'. - Olvidar que
<>,NOT IN,NOT LIKEyNOT BETWEENdescartan las filas nulas. Comprueba siempre que la condición y su negación sumen el total. - Usar
NOT INcon una lista que puede traer unNULL. Cero filas, garantizadas (04-02). - Poner una condición sobre la tabla derecha en el
WHEREde unLEFT JOIN. Los nulos fabricados no la superan y elLEFTse degrada aINNER(03-03). - Suponer que si
AesNULLentoncesNOT Aes verdad.NOT UNKNOWNesUNKNOWN. - Interpretar una celda vacía de un informe como un cero. Puede ser "no aplica".
COALESCE(06-04) lo hace explícito. - Esperar que
UNIQUEimpida varias filas nulas. No lo hace, salvoNULLS NOT DISTINCTen PostgreSQL 15+ o en SQL Server, que va al revés. - Confiar en el orden de evaluación de un
ANDpara protegerse de un nulo o de una división por cero. El planificador reordena. UsaCASEoNULLIF(módulo 6). - Consejo: al leer un esquema, mira primero qué columnas admiten
NULL.\d tablaenpsqllo dice. Son exactamente los sitios donde tus consultas pueden perder filas. - Consejo: cuando una consulta devuelva menos filas de las esperadas, sospecha de los nulos antes que de los datos. Es la causa más frecuente y la más silenciosa.
- Consejo: usa
IS DISTINCT FROMpor defecto al comparar dos columnas nullables. Te ahorra elOR ... IS NULLy hace la intención explícita.
Ejercicios
Ejercicio 1
Sin ejecutar nada, predice el resultado (true, false o *(null)*) de cada expresión. Después ejecútalas todas en una sola consulta y comprueba.
SELECT NULL = NULL AS a,
NULL IS NULL AS b,
NULL <> NULL AS c,
NOT (NULL = NULL) AS d,
(1 = 1) OR (NULL = 1) AS e,
(1 = 1) AND (NULL = 1) AS f,
(1 = 2) AND (NULL = 1) AS g,
NULL IS NOT DISTINCT FROM NULL AS h,
5 IS DISTINCT FROM NULL AS i;Explica en una frase cada resultado que te haya sorprendido.
Ejercicio 2
El departamento de marketing quiere medir el programa de referidos. Sobre clientes:
- ¿Cuántos clientes llegaron por recomendación y cuántos por su cuenta? Escribe dos consultas y comprueba que suman 15.
- Escribe la consulta que devuelve, para cada cliente, su nombre completo y el nombre completo de quien lo recomendó, incluyendo los que no fueron recomendados por nadie. (Pista: relación reflexiva, 03-06.)
- En esa consulta, ¿por qué la columna del recomendador aparece como
*(null)*y no como una cadena vacía? Da las dos razones.
Ejercicio 3
Un compañero ha escrito este control de calidad y afirma que "en TiendaVerde no hay ningún pedido raro":
-- ⚠️ INCORRECTA
SELECT COUNT(*) AS pedidos_sin_comercial_valido
FROM pedidos
WHERE empleado_id <> 4
AND empleado_id <> 5
AND empleado_id <> 6;| pedidos_sin_comercial_valido |
|---|
| 0 |
- ¿Qué está midiendo realmente esa consulta y por qué el 0 no demuestra lo que él cree?
- Escribe la versión que cuenta de verdad los pedidos cuyo
empleado_idno es ni 4, ni 5, ni 6 (contando los nulos como "no es ninguno de los tres"). ¿Cuántos son? - Escribe la versión que cuenta los pedidos con un
empleado_idpresente pero distinto de esos tres. ¿Cuántos son y qué significa ese número?
Soluciones
Solución 1
| Expresión | Resultado | Razón |
|---|---|---|
a: NULL = NULL |
(null) | Dos ausencias no son comparables |
b: NULL IS NULL |
true | IS NULL no es una comparación: es una pregunta sobre el estado |
c: NULL <> NULL |
(null) | Tampoco puedes afirmar que sean distintas |
d: NOT (NULL = NULL) |
(null) | NOT UNKNOWN = UNKNOWN |
e: (1=1) OR (NULL=1) |
true | TRUE OR UNKNOWN = TRUE. Una alternativa cierta basta |
f: (1=1) AND (NULL=1) |
(null) | TRUE AND UNKNOWN = UNKNOWN. Es el caso de NOT IN |
g: (1=2) AND (NULL=1) |
false | FALSE AND UNKNOWN = FALSE. Ya está decidido |
h: NULL IS NOT DISTINCT FROM NULL |
true | Este operador sí trata los nulos como iguales |
i: 5 IS DISTINCT FROM NULL |
true | Y trata un valor como distinto de un nulo |
Las tres que suelen sorprender son d, f y g. d porque uno espera que negar algo lo convierta en verdadero; f y g porque parece contradictorio que AND con un UNKNOWN a veces dé UNKNOWN y a veces FALSE — y sin embargo es lo lógico: si un factor ya es falso, no hace falta saber el otro.
Solución 2
1. Las dos mitades:
| clientes_referidos |
|---|
| 8 |
| clientes_espontaneos |
|---|
| 7 |
8 + 7 = 15. Cierra, porque IS NULL e IS NOT NULL sí particionan la tabla. Si hubieras escrito WHERE referido_por_id <> 0 para "los referidos", habrías obtenido 8 igualmente por casualidad, y WHERE referido_por_id = 0 habría dado 0 en lugar de 7.
2. Autounión con LEFT JOIN:
SELECT c.id,
c.nombre || ' ' || c.apellidos AS cliente,
ref.nombre || ' ' || ref.apellidos AS recomendado_por
FROM clientes AS c
LEFT JOIN clientes AS ref ON c.referido_por_id = ref.id
ORDER BY c.id;| id | cliente | recomendado_por |
|---|---|---|
| 1 | Lucía Martínez Soler | (null) |
| 2 | Carlos Ferrer Ibáñez | Lucía Martínez Soler |
| 3 | Marta Sanchis Gil | Lucía Martínez Soler |
| 4 | Javier Ortega Ruiz | (null) |
| 5 | Ana Belmonte Roca | Carlos Ferrer Ibáñez |
| 6 | Pau Llorens Vidal | (null) |
| 7 | Sofia Moreira Costa | (null) |
| 8 | Tiago Almeida Nunes | Sofia Moreira Costa |
| 9 | Camille Dubois | (null) |
| 10 | Julien Moreau | Camille Dubois |
| 11 | Elena Navarro Puig | Pau Llorens Vidal |
| 12 | Diego Ramos Herrera | (null) |
| 13 | Núria Bosch Ferrer | Ana Belmonte Roca |
| 14 | Hugo Iglesias Pardo | (null) |
| 15 | Inés Carrasco Vega | Lucía Martínez Soler |
15 filas, los 15 clientes. Con INNER JOIN serían 8 y perderías a los espontáneos. Fíjate en el detalle: Lucía ha recomendado a tres clientes (2, 3 y 15), lo que la convierte en la mejor prescriptora de TiendaVerde.
3. Las dos razones por las que recomendado_por sale *(null)*:
- El
LEFT JOINno encontró pareja. Para los siete clientes conreferido_por_idnulo, la condiciónc.referido_por_id = ref.iddaUNKNOWNy no casa con ninguna fila, así que el motor rellena todas las columnas derefconNULL(03-03, sección 3). - La concatenación propaga el nulo. Aunque solo una de las dos columnas fuera nula,
ref.nombre || ' ' || ref.apellidosdaríaNULLigualmente, porque||con un operando nulo devuelve nulo (02-02 y sección 9 de esta lección).
Que sea *(null)* y no '' es importante: la cadena vacía significaría "se llama así, con cero caracteres". El nulo significa "no hay nadie ahí". Para que el informe muestre algo legible —"Sin recomendador", por ejemplo— hace falta COALESCE, en 06-04.
Solución 3
1. Qué mide realmente. Los tres factores están unidos por AND, y para los diez pedidos web cada uno vale UNKNOWN. UNKNOWN AND UNKNOWN AND UNKNOWN es UNKNOWN, así que esas diez filas se descartan. Para los otros diez, cada pedido tiene un empleado_id que es 4, 5 o 6, de modo que uno de los tres factores es FALSE y la conjunción es FALSE.
Resultado: cero filas. Y ese 0 no demuestra lo que él cree, porque la consulta es incapaz de ver la mitad de la tabla: los diez pedidos web nunca llegan a evaluarse como candidatos. Si el criterio de "pedido raro" incluye "sin comercial asignado", esta consulta jamás lo detectaría, hoy ni nunca. Un control de calidad que estructuralmente no puede dar positivo no es un control de calidad.
2. Contando los nulos como "no es ninguno de los tres":
-- ✅ CORRECTA
SELECT COUNT(*) AS pedidos_sin_comercial_conocido
FROM pedidos
WHERE empleado_id IS DISTINCT FROM 4
AND empleado_id IS DISTINCT FROM 5
AND empleado_id IS DISTINCT FROM 6;| pedidos_sin_comercial_conocido |
|---|
| 10 |
10 pedidos: exactamente los diez del canal web. IS DISTINCT FROM nunca devuelve UNKNOWN, así que la conjunción se resuelve limpiamente. La forma equivalente y más idiomática para este caso concreto sería:
SELECT COUNT(*) AS pedidos_sin_comercial_conocido
FROM pedidos
WHERE empleado_id IS NULL
OR empleado_id NOT IN (4, 5, 6);que devuelve el mismo 10.
3. Solo los que tienen comercial y no es ninguno de esos tres:
SELECT COUNT(*) AS pedidos_comercial_desconocido
FROM pedidos
WHERE empleado_id IS NOT NULL
AND empleado_id NOT IN (4, 5, 6);| pedidos_comercial_desconocido |
|---|
| 0 |
0 pedidos, y este cero sí significa algo: no hay ningún pedido asignado a un empleado que no sea Óscar, Laia o Marc. Es decir, los otros cinco empleados —Rosa, Andrés, Beatriz, Irene y Daniel— no gestionan pedidos, tal como describía 01-06.
La comprobación final: 10 (con comercial 4, 5 o 6) + 10 (sin comercial) + 0 (con otro comercial) = 20. Ahora la lógica cierra, y ese es el criterio para saber que la consulta está bien escrita.
Conclusión
La deuda queda saldada, y con una sola idea:
NULLes la ausencia de valor, no cero, ni cadena vacía, ni falso, ni igual a otroNULL. Significa desconocido.- En TiendaVerde hay tres
NULLcon tres significados de negocio distintos —canal web, cliente espontáneo, cúspide del organigrama— más los que fabrica el motor en cadaLEFT JOIN. - SQL usa lógica de tres valores:
TRUE,FALSEyUNKNOWN.WHERE,ONyHAVINGconservan solo lo que esTRUE, así queUNKNOWNse descarta igual queFALSE— pero al negarlo se comportan distinto, porqueNOT UNKNOWNsigue siendoUNKNOWN. - Las dos celdas que hay que memorizar son
TRUE OR UNKNOWN=TRUEyTRUE AND UNKNOWN=UNKNOWN. De ellas sale toda la asimetría entreINyNOT IN. IS NULL/IS NOT NULLson predicados, no comparaciones, y sí parten la tabla en dos mitades exactas.IS DISTINCT FROMcompara tratando el nulo como un valor más, y es la herramienta correcta para comparar columnas nullables.- El estándar es incoherente a propósito:
NULL = NULLes desconocido, pero enDISTINCT,GROUP BY,ORDER BYy en las operaciones de conjuntos los nulos sí se consideran iguales entre sí. En cambio, una restricciónUNIQUElos considera distintos y admite varios. - El nulo se propaga por la aritmética y la concatenación:
precio * NULLesNULL, no cero.COALESCE,NULLIFyCASE(06-04 y 06-05) son las herramientas para sustituirlo al presentar. - Los cinco casos pendientes del curso —
= NULL,<> 4, elINNER JOINque perdía pedidos, elWHEREque degradaba elLEFT JOINy elNOT INcon nulo— son el mismo caso: una comparación que daUNKNOWNy una fila que desaparece sin aviso. - Al diseñar, elige conscientemente entre
NULL, valor centinela yNOT NULL DEFAULT. Los centinelas no eliminan el problema: lo esconden dentro de las medias y los recuentos.
Con esto termina la primera mitad del módulo. Ya sabes filtrar con precisión: por patrones, por listas, por rangos y por ausencia de valor. En la lección siguiente, funciones de agregación, empieza la segunda mitad y cambia la naturaleza de lo que haces: hasta ahora cada fila del resultado venía de una fila de la tabla; a partir de ahora muchas filas entrarán y saldrá un solo valor. Verás COUNT, SUM, AVG, MIN y MAX, y la primera cosa que aprenderás de ellas es que todas ignoran los NULL —todas menos COUNT(*)—, lo que convierte a pedidos.empleado_id en el mejor banco de pruebas posible: 20, 10 y 3 según cómo cuentes. Y resolveremos, por fin, el aviso repetido tres veces en el módulo 3 sobre sumar gastos de envío después de unir con el detalle.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
