SQL · Guías · 30 de octubre de 2026 · 15 min de lectura
NULL en SQL: IS NULL, COALESCE, NULL en JOIN, conteos y comparaciones

NULL significa «aquí no hay valor»: desconocido, ausente o no aplicable. No es cero, ni una cadena vacía, ni false. Casi todas las sorpresas con NULL salen de una sola regla: cualquier comparación con NULL da NULL (desconocido), no verdadero ni falso, y WHERE solo conserva las filas en las que la condición es verdadera. Por eso x = NULL no encuentra nada, NOT IN con un NULL en la lista no encuentra nada y un NOT delante de una condición no pasa las filas con NULL al otro lado.
Las herramientas prácticas son pocas:
- comprueba si hay
NULLconIS NULL/IS NOT NULL, nunca con=; - sustituye
NULLpor un valor por defecto conCOALESCE; - convierte un valor en
NULLconNULLIF(sobre todo para evitar la división por cero); - recuerda que los agregados se saltan los
NULLy queCOUNT(*)no; - en los JOIN, las claves
NULLnunca coinciden, y unLEFT JOINcreaNULLnuevos.
Los ejemplos usan la base de datos de una aerolínea del curso de SQL de Nomto: 33.121 vuelos en el verano de 2026, 53.000 socios del programa de fidelización y 1,5 millones de eventos de la app (valores en inglés, dinero en dólares). El SQL usa sintaxis de PostgreSQL y cada resultado sale de ejecutar la consulta; puedes comprobarlo en la sala de SQL de Nomto.
De dónde salen los NULL
Las tablas reales tienen NULL por motivos distintos, y el motivo importa a la hora de tratarlos. En esta base de datos:
flights.actual_departureyactual_arrivalsonNULLcuando el evento todavía no ha ocurrido;loyalty_members.birth_yearesNULLcuando el socio no lo indicó;app_events.member_idesNULLcuando el visitante no había iniciado sesión;loyalty_tier_history.valid_toesNULLen la fila vigente, que aún no tiene fecha de fin.
Contar los valores no nulos por estado muestra con claridad el primer caso:
SELECT status,
COUNT(*) AS flights,
COUNT(actual_departure) AS dep,
COUNT(actual_arrival) AS arr
FROM flights
GROUP BY status
ORDER BY flights DESC;
| status | flights | dep | arr |
|---|---|---|---|
| Arrived | 16707 | 16707 | 16707 |
| Scheduled | 15383 | 0 | 0 |
| On Time | 518 | 0 | 0 |
| Cancelled | 414 | 8 | 8 |
| Departed | 58 | 58 | 0 |
| Delayed | 41 | 0 | 0 |
Los vuelos despegados (Departed) tienen hora de salida pero todavía no de llegada. Los programados no tienen ninguna. Y ocho vuelos cancelados sí tienen horas, una rareza de los datos que solo ves contando los NULL.
Comparaciones: por qué = NULL no encuentra nada
SELECT NULL = NULL AS eq,
NULL <> 1 AS ne,
NULL IS NULL AS isnull,
1 + NULL AS plus,
'a' || NULL AS concat_op,
concat('a', NULL) AS concat_fn;
| eq | ne | isnull | plus | concat_op | concat_fn |
|---|---|---|---|---|---|
| NULL | NULL | true | NULL | NULL | a |
Ni siquiera NULL = NULL es verdadero: de dos valores desconocidos no se sabe si son iguales. La aritmética y el operador || también propagan el NULL; concat() es la excepción y trata el NULL como una cadena vacía.
Así que esta consulta devuelve cero filas, sin ningún error:
SELECT COUNT(*) FROM flights WHERE actual_departure = NULL;
| count_star() |
|---|
| 0 |
Mientras que esta encuentra los 16.348 vuelos que no han despegado:
SELECT COUNT(*) FROM flights WHERE actual_departure IS NULL;
| count_star() |
|---|
| 16348 |
Lógica de tres valores: NOT no atrapa los NULL
En SQL, una condición puede ser verdadera, falsa o desconocida. WHERE solo se queda con las verdaderas. NOT desconocido sigue siendo desconocido, así que las filas con NULL se pierden por los dos lados:
SELECT COUNT(*) FILTER (WHERE birth_year < 1980) AS before_1980,
COUNT(*) FILTER (WHERE NOT (birth_year < 1980)) AS from_1980,
COUNT(*) FILTER (WHERE birth_year IS NULL) AS unknown,
COUNT(*) AS total
FROM loyalty_members;
| before_1980 | from_1980 | unknown | total |
|---|---|---|---|
| 17334 | 29347 | 6319 | 53000 |
Una condición y su negación suman 46.681, no 53.000. Si divides una tabla en «cumple» y «todo lo demás» con WHERE cond y WHERE NOT cond, los NULL no acaban en ninguna de las dos mitades. Añade OR col IS NULL al lado al que pertenezcan.
Con <> pasa lo mismo. WHERE status <> 'Arrived' se saltaría sin avisar las filas con estado NULL (esta tabla no tiene, pero muchas sí).
IS DISTINCT FROM: una comparación segura con NULL
Cuando quieres que NULL se trate como un valor más, de modo que dos NULL cuenten como iguales, usa IS DISTINCT FROM e IS NOT DISTINCT FROM:
SELECT NULL IS DISTINCT FROM NULL AS a,
1 IS DISTINCT FROM NULL AS b,
NULL IS NOT DISTINCT FROM NULL AS c;
| a | b | c |
|---|---|---|
| false | true | true |
Siempre devuelven verdadero o falso, nunca NULL. Vienen bien para comprobar «¿ha cambiado este valor?» entre dos instantáneas, donde un paso de NULL a algo tiene que contar como cambio. PostgreSQL y DuckDB los admiten; MySQL usa el operador <=>.
NULL en los agregados
Todas las funciones de agregado salvo COUNT(*) ignoran los NULL. Con tres valores, uno de ellos NULL:
SELECT AVG(x) AS avg_x, SUM(x) AS sum_x, COUNT(x) AS cnt_x, COUNT(*) AS cnt
FROM (VALUES (1), (NULL), (3)) t(x);
| avg_x | sum_x | cnt_x | cnt |
|---|---|---|---|
| 2 | 4 | 2 | 3 |
La media es 4 / 2 = 2, no 4 / 3. COUNT(x) cuenta valores no nulos y COUNT(*) cuenta filas. Con los datos reales:
SELECT COUNT(*) AS events,
COUNT(member_id) AS with_member,
COUNT(*) - COUNT(member_id) AS anonymous
FROM app_events;
| events | with_member | anonymous |
|---|---|---|
| 1494723 | 582098 | 912625 |
La mayoría de eventos vienen de visitantes sin sesión iniciada. COUNT(DISTINCT member_id) también ignora los NULL, así que devuelve 33.624 socios reales, y los visitantes anónimos no aparecen como un «socio» más.
Saltarse los NULL suele ser lo que quieres, y sustituirlos por cero suele ser un error. Compara la edad media de los socios con y sin COALESCE:
SELECT ROUND(AVG(2026 - birth_year), 1) AS avg_age_known,
ROUND(AVG(COALESCE(2026 - birth_year, 0)), 1) AS avg_age_wrong
FROM loyalty_members;
| avg_age_known | avg_age_wrong |
|---|---|
| 43.2 | 38 |
La segunda columna trata a 6.319 socios de edad desconocida como recién nacidos. Sustituye NULL por cero solo cuando NULL signifique de verdad cero, como en «ese día no hubo ventas».
SUM sin filas es NULL, no 0
SELECT SUM(amount) AS revenue,
COALESCE(SUM(amount), 0) AS revenue_zero,
COUNT(*) AS segments
FROM ticket_flights
WHERE amount > 100000;
| revenue | revenue_zero | segments |
|---|---|---|
| NULL | 0.00 | 0 |
Ningún tramo cuesta tanto, así que no hay nada que sumar. COUNT devuelve 0 cuando no hay filas, pero SUM, AVG, MIN y MAX devuelven NULL. Envuélvelos en COALESCE(..., 0) cuando el resultado vaya a un informe o a otro cálculo, para que «sin ingresos» se vea como 0 y no como una celda vacía.
COALESCE: el primer valor no nulo
COALESCE(a, b, c) devuelve el primer argumento que no es NULL. Un uso típico es un valor de reserva: la salida real si ya ocurrió y, si no, la programada.
SELECT flight_no, scheduled_departure, actual_departure,
COALESCE(actual_departure, scheduled_departure) AS best_known_departure
FROM flights
WHERE flight_no = 'PG0405'
AND scheduled_departure BETWEEN '2026-08-10' AND '2026-08-14'
ORDER BY scheduled_departure;
| flight_no | scheduled_departure | actual_departure | best_known_departure |
|---|---|---|---|
| PG0405 | 2026-08-10 09:35:00 | 2026-08-10 09:37:00 | 2026-08-10 09:37:00 |
| PG0405 | 2026-08-11 09:35:00 | 2026-08-11 09:38:00 | 2026-08-11 09:38:00 |
| PG0405 | 2026-08-12 09:35:00 | NULL | 2026-08-12 09:35:00 |
| PG0405 | 2026-08-13 09:35:00 | NULL | 2026-08-13 09:35:00 |
Todos los argumentos deben tener tipos compatibles. COALESCE(birth_year, 'unknown') mezcla un entero con texto: PostgreSQL lo rechaza, y DuckDB falla con «Could not convert string ‘unknown’ to INT32» en cuanto encuentra una fila con NULL. Haz antes el cast:
SELECT COALESCE(CAST(birth_year AS VARCHAR), 'unknown') AS birth_year,
COUNT(*) AS members
FROM loyalty_members
WHERE birth_year IS NULL OR birth_year > 2007
GROUP BY 1
ORDER BY 1;
| birth_year | members |
|---|---|
| 2008 | 574 |
| unknown | 6319 |
NULLIF: convertir un valor en NULL
NULLIF(a, b) devuelve NULL si a = b y, si no, a. Su trabajo más conocido es proteger una división: dividir por NULL da NULL en lugar de un error.
SELECT aircraft_code,
COUNT(*) AS seats,
COUNT(*) FILTER (WHERE fare_conditions = 'Business') AS business,
ROUND(COUNT(*) * 1.0
/ NULLIF(COUNT(*) FILTER (WHERE fare_conditions = 'Business'), 0), 1) AS seats_per_business
FROM seats
GROUP BY aircraft_code
ORDER BY seats DESC;
| aircraft_code | seats | business | seats_per_business |
|---|---|---|---|
| 773 | 402 | 30 | 13.4 |
| 763 | 222 | 30 | 7.4 |
| 321 | 170 | 28 | 6.1 |
| 320 | 140 | 20 | 7 |
| 733 | 130 | 12 | 10.8 |
| 319 | 116 | 20 | 5.8 |
| SU9 | 97 | 12 | 8.1 |
| CR2 | 50 | 0 | NULL |
| CN1 | 12 | 0 | NULL |
Sin NULLIF, PostgreSQL detiene toda la consulta con «division by zero» por culpa de dos aviones pequeños. (DuckDB no da error aquí: sin NULLIF muestra Infinity para CR2 y CN1, lo que luego estropea cualquier media o total que se calcule encima. Así que una consulta que funciona en DuckDB puede fallar en PostgreSQL, y dar números raros en los dos. Escribe el NULLIF).
El segundo trabajo: limpiar valores falsos que deberían haber sido NULL. Algunos sistemas guardan un valor de relleno, como 1900 o 0, cuando el dato es desconocido. Este conjunto de datos tiene 148 socios nacidos en «1900»:
SELECT COUNT(*) FILTER (WHERE birth_year = 1900) AS placeholder_1900,
ROUND(AVG(2026 - birth_year), 1) AS avg_age_raw,
ROUND(AVG(2026 - NULLIF(birth_year, 1900)), 1) AS avg_age_clean
FROM loyalty_members;
| placeholder_1900 | avg_age_raw | avg_age_clean |
|---|---|---|
| 148 | 43.2 | 42.9 |
Un viajero frecuente de 126 años no es un cliente real. NULLIF convierte el valor de relleno en NULL, y AVG se lo salta como cualquier otro valor ausente.
NULL en GROUP BY y ORDER BY
GROUP BY es un sitio donde los NULL sí se tratan como iguales: todos los NULL forman un único grupo.
SELECT birth_year - birth_year % 10 AS decade, COUNT(*) AS members
FROM loyalty_members
GROUP BY decade
ORDER BY decade NULLS FIRST;
| decade | members |
|---|---|
| NULL | 6319 |
| 1900 | 148 |
| 1940 | 47 |
| 1950 | 626 |
| 1960 | 4275 |
| 1970 | 12238 |
| 1980 | 16346 |
| 1990 | 9881 |
| 2000 | 3120 |
(birth_year - birth_year % 10 redondea hacia abajo a la década en cualquier base de datos. La opción más obvia, birth_year / 10 * 10, funciona en PostgreSQL, donde la división entera descarta el resto, pero no en DuckDB, donde / devuelve un número con decimales y te devolvería 1946 tal cual).
Al ordenar, PostgreSQL y DuckDB no se ponen de acuerdo. PostgreSQL trata NULL como mayor que cualquier valor: los NULL van al final en orden ascendente y al principio en orden descendente. DuckDB pone los NULL al final en los dos sentidos por defecto. Así que esta consulta:
SELECT member_id, birth_year
FROM loyalty_members
WHERE member_id <= 12
ORDER BY birth_year DESC;
| member_id | birth_year |
|---|---|
| 7 | 2000 |
| 6 | 1987 |
| 2 | 1983 |
| 3 | 1977 |
| 5 | 1977 |
| 9 | 1973 |
| 10 | 1972 |
| 11 | 1969 |
| 1 | 1967 |
| 12 | 1967 |
| 4 | NULL |
| 8 | NULL |
muestra a los socios 4 y 8 abajo del todo en DuckDB, como arriba, pero arriba del todo en PostgreSQL. Un «top 10 de socios más jóvenes» con LIMIT 10 devolvería dos desconocidos en PostgreSQL. Escribe NULLS FIRST o NULLS LAST de forma explícita siempre que la columna pueda ser NULL; las dos bases de datos lo aceptan.
NULL en los JOIN
Las claves NULL nunca coinciden
Una condición de JOIN es una comparación, así que un NULL en cualquiera de los dos lados nunca es igual a nada:
SELECT COUNT(*) AS events, COUNT(m.member_id) AS joined
FROM app_events e
LEFT JOIN loyalty_members m ON m.member_id = e.member_id;
| events | joined |
|---|---|
| 1494723 | 582098 |
Los 912.625 eventos anónimos no encuentran socio. Con un INNER JOIN desaparecerían del resultado por completo, lo que puede estar bien para «eventos por socio» y estar muy mal para «eventos por día». Si de verdad necesitas que dos claves NULL coincidan, une con ON a.key IS NOT DISTINCT FROM b.key, pero piénsalo dos veces: dos visitantes anónimos no son la misma persona.
Un LEFT JOIN crea NULL
Cuando una fila de la izquierda no tiene pareja, las columnas de la derecha se rellenan con NULL. Así se encuentran los «padres sin hijos», y por eso mismo COUNT(*) después de un LEFT JOIN puede engañar:
SELECT a.aircraft_code, a.model,
COUNT(*) AS count_star,
COUNT(f.flight_id) AS flights
FROM aircrafts a
LEFT JOIN flights f ON f.aircraft_code = a.aircraft_code
GROUP BY a.aircraft_code, a.model
ORDER BY flights
LIMIT 3;
| aircraft_code | model | count_star | flights |
|---|---|---|---|
| 320 | Airbus A320-200 | 1 | 0 |
| 773 | Boeing 777-300 | 610 | 610 |
| 763 | Boeing 767-300 | 1221 | 1221 |
El A320 no tiene vuelos, pero el LEFT JOIN produce igualmente una fila para él, con NULL en el lado de los vuelos. COUNT(*) cuenta esa fila y dice 1. COUNT(f.flight_id) cuenta valores no nulos y dice, correctamente, 0. Después de un LEFT JOIN, cuenta una columna de la tabla de la derecha.
La trampa del WHERE
Filtrar la tabla de la derecha en WHERE tira justo las filas con NULL que había creado el LEFT JOIN:
SELECT a.aircraft_code, COUNT(f.flight_id) AS cancelled
FROM aircrafts a
LEFT JOIN flights f ON f.aircraft_code = a.aircraft_code
WHERE f.status = 'Cancelled'
GROUP BY a.aircraft_code
ORDER BY cancelled;
| aircraft_code | cancelled |
|---|---|
| 773 | 1 |
| 321 | 3 |
| 763 | 6 |
| 733 | 15 |
| 319 | 24 |
| SU9 | 69 |
| CN1 | 115 |
| CR2 | 181 |
Ocho modelos, no nueve. En la fila del A320, f.status es NULL, NULL = 'Cancelled' es desconocido y WHERE la descarta. Lleva la condición al ON:
SELECT a.aircraft_code, COUNT(f.flight_id) AS cancelled
FROM aircrafts a
LEFT JOIN flights f ON f.aircraft_code = a.aircraft_code AND f.status = 'Cancelled'
GROUP BY a.aircraft_code
ORDER BY cancelled;
| aircraft_code | cancelled |
|---|---|
| 320 | 0 |
| 773 | 1 |
| 321 | 3 |
| 763 | 6 |
| 733 | 15 |
| 319 | 24 |
| SU9 | 69 |
| CN1 | 115 |
| CR2 | 181 |
Ahora el filtro decide qué vuelos se unen, y todos los modelos se quedan. La guía de JOIN en SQL y la del calendario de fechas, en inglés muestran la misma trampa en otros contextos.
La trampa de NOT IN
x NOT IN (a, b, c) significa x <> a AND x <> b AND x <> c. Si algún elemento es NULL, una de esas comparaciones es desconocida, y la condición completa nunca puede ser verdadera:
SELECT 1 IN (1, NULL) AS in_hit,
1 IN (2, NULL) AS in_miss,
1 NOT IN (2, NULL) AS not_in_miss,
1 NOT IN (2, 3) AS not_in_clean;
| in_hit | in_miss | not_in_miss | not_in_clean |
|---|---|---|---|
| true | NULL | NULL | true |
Con una subconsulta, el NULL no se ve, y eso lo empeora. ¿Qué socios no tienen eventos en la app?
SELECT COUNT(*) AS members_without_events
FROM loyalty_members
WHERE member_id NOT IN (SELECT member_id FROM app_events);
| members_without_events |
|---|
| 0 |
La subconsulta devuelve cientos de miles de NULL (los eventos anónimos), así que la respuesta es cero. NOT EXISTS compara fila a fila y no se ve afectado:
SELECT COUNT(*) AS members_without_events
FROM loyalty_members m
WHERE NOT EXISTS (
SELECT 1 FROM app_events e WHERE e.member_id = m.member_id
);
| members_without_events |
|---|
| 19376 |
Usa NOT EXISTS (o un anti join, LEFT JOIN ... WHERE right.key IS NULL) para «filas sin coincidencia». Si te quedas con NOT IN, añade WHERE member_id IS NOT NULL dentro de la subconsulta. Tienes más patrones en la guía de subconsultas SQL.
NULL en CASE
Una rama de CASE cuya condición es NULL se salta, así que las filas con NULL caen hasta el ELSE. CASE WHEN birth_year < 1980 THEN 'older' ELSE 'younger' END mete a todos los socios sin año de nacimiento entre los «más jóvenes». Pon primero WHEN birth_year IS NULL THEN 'unknown'. La guía de CASE WHEN en SQL lo muestra con conteos reales.
Lista de comprobación
- ¿Buscas valores ausentes?
IS NULL/IS NOT NULL, nunca= NULL. - ¿Divides filas con
condyNOT cond? Decide adónde van losNULL. - ¿Cuentas?
COUNT(*)para filas,COUNT(col)para valores no nulos. Después de unLEFT JOIN, cuenta una columna de la derecha. - ¿Promedias? Los
NULLse saltan. No los conviertas en cero conCOALESCEsalvo queNULLsignifique de verdad cero. - ¿Sumas algo que podría no tener filas?
COALESCE(SUM(...), 0). - ¿Divides?
NULLIF(denominador, 0). - ¿Ordenas una columna que admite
NULL? EscribeNULLS FIRSToNULLS LAST. - ¿
LEFT JOINcon un filtro sobre la tabla de la derecha? Ponlo en elON. - ¿Filas sin coincidencia?
NOT EXISTS, noNOT IN. - ¿Valores de relleno como 1900, 0 o ‘N/A’? Conviértelos en
NULLconNULLIFantes de agregar.
Preguntas frecuentes
¿Por qué WHERE columna = NULL no devuelve filas?
Porque comparar cualquier cosa con NULL da NULL (desconocido), no verdadero, y WHERE solo conserva las filas en las que la condición es verdadera. Usa WHERE columna IS NULL.
¿Qué diferencia hay entre COUNT(*) y COUNT(columna)?
COUNT(*) cuenta filas. COUNT(columna) cuenta las filas en las que esa columna no es NULL. En app_events se diferencian en 912.625: los eventos sin socio.
¿Qué diferencia hay entre COALESCE y NULLIF?
Son opuestos. COALESCE(a, b) sustituye un NULL por un valor. NULLIF(a, b) sustituye un valor por NULL cuando a es igual a b, normalmente NULLIF(x, 0) antes de una división.
¿NULL es lo mismo que una cadena vacía?
No. En PostgreSQL y DuckDB, '' es un valor real de longitud cero, y '' IS NULL es falso. Oracle es la excepción: trata la cadena vacía como NULL.
¿Cómo se ordenan los NULL en SQL?
Depende de la base de datos. PostgreSQL pone los NULL al final en orden ascendente y al principio en descendente; DuckDB los pone al final en los dos casos. Escribe NULLS FIRST o NULLS LAST para obtener el mismo resultado en todos los motores que lo admiten.
También en English, Português (Brasil), Tiếng Việt, 简体中文