SQL 数据分析 · 指南 · 2026年10月30日 · 14 分钟阅读
SQL NULL 值处理:IS NULL、COALESCE,以及 NULL 在连接、计数和比较中的行为

NULL 的意思是「这里没有值」:未知、缺失或不适用。 它不是 0,不是空字符串,也不是 false。大多数关于 NULL 的意外都来自同一条规则:任何和 NULL 的比较结果都是 NULL(未知),既不是真也不是假,而 WHERE 只保留条件为真的行。所以 x = NULL 什么也查不到,列表里有 NULL 的 NOT IN 什么也查不到,在条件前面加 NOT 也不会把 NULL 行翻到另一边去。
实用的工具并不多:
- 判断 NULL 用
IS NULL/IS NOT NULL,永远不要用=; - 用
COALESCE把 NULL 换成默认值; - 用
NULLIF把某个值变成 NULL(主要用来避免除以零); - 记住聚合函数会跳过 NULL,而
COUNT(*)不会; - 连接时,NULL 键永远匹配不上,而
LEFT JOIN会产生新的 NULL。
下面的例子都在 Nomto SQL 课程使用的航空公司数据库上运行:2026 年夏季的 33,121 个航班、53,000 名会员、150 万条 App 事件。SQL 采用 PostgreSQL 语法,每个结果都是真实运行得到的。
NULL 从哪里来
真实的表里出现 NULL 有不同的原因,而原因决定了你该怎么处理它们。在这个数据库里:
flights.actual_departure和actual_arrival为 NULL,表示事件还没发生;loyalty_members.birth_year为 NULL,表示会员没有填写;app_events.member_id为 NULL,表示访客没有登录;loyalty_tier_history.valid_to在当前那一行为 NULL,因为它还没有结束时间。
按状态统计非 NULL 值的数量,第一种情况一目了然:
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 |
已起飞的航班有起飞时间,还没有到达时间;计划中的航班两者都没有。还有 8 个已取消的航班居然有时间记录,这种数据上的怪现象只有统计 NULL 才看得出来。
比较:为什么 = NULL 查不到数据
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 |
连 NULL = NULL 都不为真:两个未知的值,不能确定它们相等。算术运算和 || 运算符同样会把 NULL 传下去;concat() 是例外,它把 NULL 当作空字符串。
所以下面这个查询返回 0,而且不会报任何错:
SELECT COUNT(*) FROM flights WHERE actual_departure = NULL;
| count_star() |
|---|
| 0 |
而这个查询能找到 16,348 个还没起飞的航班:
SELECT COUNT(*) FROM flights WHERE actual_departure IS NULL;
| count_star() |
|---|
| 16348 |
三值逻辑:NOT 抓不住 NULL
SQL 里的条件可以是真、假或未知。WHERE 只保留真。NOT 未知 仍然是未知,所以 NULL 行在两边都会被丢掉:
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 |
一个条件和它的否定加起来是 46,681,而不是 53,000。如果你用 WHERE cond 和 WHERE NOT cond 把一张表分成「符合」和「其余」两半,NULL 哪一半都不会进。要在 NULL 该去的那一边加上 OR col IS NULL。
<> 也一样。WHERE status <> 'Arrived' 会悄悄跳过 status 为 NULL 的行(这张表里没有,但很多表有)。
IS DISTINCT FROM:对 NULL 安全的比较
如果你希望把 NULL 当作普通值,让两个 NULL 算作相等,就用 IS DISTINCT FROM 和 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 |
它们永远返回真或假,不会返回 NULL。在比较两个快照「这个值变了没有」时很好用,因为从 NULL 变成某个值也必须算作变化。PostgreSQL 和 DuckDB 支持它们;MySQL 用的是 <=> 运算符。
聚合函数里的 NULL
除了 COUNT(*),所有聚合函数都忽略 NULL。三个值,其中一个是 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 |
平均值是 4 / 2 = 2,而不是 4 / 3。COUNT(x) 数的是非 NULL 值,COUNT(*) 数的是行。看真实数据:
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 |
大多数事件来自没登录的访客。COUNT(DISTINCT member_id) 同样忽略 NULL,所以它返回 33,624 个真实会员,匿名访客不会被算成多出来的一个「会员」。
跳过 NULL 通常正是你想要的,而把 NULL 换成 0 通常是错的。对比一下加不加 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 |
第二列把 6,319 名年龄未知的会员当成了新生儿。只有当 NULL 真的意味着 0 时(比如「这一天没有销售」),才把它换成 0。
零行的 SUM 是 NULL,不是 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 |
没有哪个航段这么贵,所以没有东西可以求和。零行时 COUNT 返回 0,但 SUM、AVG、MIN 和 MAX 返回 NULL。如果结果要进报表或参与计算,就用 COALESCE(..., 0) 包起来,让「没有收入」显示为 0,而不是空单元格。
COALESCE:第一个非 NULL 的值
COALESCE(a, b, c) 返回第一个不是 NULL 的参数。典型用法是兜底:如果已经起飞就用实际起飞时间,否则用计划起飞时间。
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 |
所有参数的类型必须兼容。COALESCE(birth_year, 'unknown') 把整数和文本混在一起:PostgreSQL 会拒绝执行,DuckDB 一遇到 NULL 行就会报错「Could not convert string ‘unknown’ to INT32」。先转换类型:
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:把某个值变成 NULL
NULLIF(a, b) 在 a = b 时返回 NULL,否则返回 a。它最出名的用途是保护除法:除以 NULL 得到 NULL,而不是报错。
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 |
没有 NULLIF 的话,PostgreSQL 会因为两架小飞机报「division by zero」,整个查询停止执行。(DuckDB 在这里不会报错:没有 NULLIF 时,它给 CR2 和 CN1 显示 Infinity,之后在它上面算的任何平均值或总数都会被污染。所以在 DuckDB 里能跑的查询,到了 PostgreSQL 上仍然可能失败,而且在两边都给出奇怪的数字。把 NULLIF 写上。)
第二个用途:清理本该是 NULL 的假值。有些系统在值未知时会存一个占位值,比如 1900 或 0。这份数据里有 148 名会员的出生年份是「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 |
一位 126 岁的常旅客不是真实客户。NULLIF 把占位值变成 NULL,AVG 就会像跳过其他缺失值一样跳过它。
GROUP BY 和 ORDER BY 里的 NULL
GROUP BY 是少数把 NULL 当作相等的地方:所有 NULL 归为同一组。
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 在任何数据库里都能向下取整到年代。更直观的 birth_year / 10 * 10 在 PostgreSQL 里能用,因为整数除法会丢掉余数;在 DuckDB 里却不行,因为 / 返回小数,你会得到原来的 1946。)
排序是 PostgreSQL 和 DuckDB 意见不一的地方。PostgreSQL 把 NULL 当作比任何值都大:升序时 NULL 排在最后,降序时排在最前。DuckDB 默认在两个方向上都把 NULL 排在最后。 所以这个查询:
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 |
在 DuckDB 里,4 号和 8 号会员排在最下面(如上所示),在 PostgreSQL 里却排在最上面。用 LIMIT 10 查「最年轻的 10 名会员」,在 PostgreSQL 里会返回两个年龄未知的人。只要列可能为 NULL,就显式写上 NULLS FIRST 或 NULLS LAST;两个数据库都支持。
连接里的 NULL
NULL 键永远匹配不上
连接条件本身就是比较,所以任何一边是 NULL,都不会和任何值相等:
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 |
912,625 条匿名事件找不到会员。如果用内连接,它们会从结果里彻底消失:算「每个会员的事件数」可能没问题,算「每天的事件数」就错得离谱。如果你真的需要两个 NULL 键相互匹配,可以写 ON a.key IS NOT DISTINCT FROM b.key,但要三思:两个匿名访客并不是同一个人。
LEFT JOIN 会产生 NULL
左表的某一行没有匹配时,右表的列会用 NULL 填充。这就是找「没有子记录的父记录」的方法,也是 LEFT JOIN 之后 COUNT(*) 容易误导人的原因:
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 |
A320 没有航班,但 LEFT JOIN 仍然为它产生了一行,航班那一侧全是 NULL。COUNT(*) 数到了这一行,说是 1;COUNT(f.flight_id) 只数非 NULL 值,正确地给出 0。LEFT JOIN 之后,要数右表的某一列。
WHERE 陷阱
在 WHERE 里筛选右表,恰好会把 LEFT JOIN 产生的那些 NULL 行扔掉:
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 |
只有 8 种机型,不是 9 种。对 A320 那一行来说,f.status 是 NULL,NULL = 'Cancelled' 是未知,于是 WHERE 把它丢了。把条件挪到 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 |
现在筛选条件决定的是哪些航班参与连接,每种机型都保留了下来。SQL JOIN 详解和日期序列指南展示了同一个陷阱在其他场景下的表现。
NOT IN 陷阱
x NOT IN (a, b, c) 的意思是 x <> a AND x <> b AND x <> c。只要有一个元素是 NULL,其中一个比较就是未知,整个条件就永远不可能为真:
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 |
换成子查询,NULL 就看不见了,情况更糟。哪些会员没有任何 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 |
子查询返回了几十万个 NULL(那些匿名事件),所以答案是 0。NOT EXISTS 逐行比较,不受影响:
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 |
找「没有匹配的行」,用 NOT EXISTS(或者 LEFT JOIN ... WHERE right.key IS NULL 这种反连接)。如果坚持用 NOT IN,就在子查询里加上 WHERE member_id IS NOT NULL。更多子查询写法见 SQL 子查询。
CASE 里的 NULL
条件为 NULL 的 CASE 分支会被跳过,所以 NULL 行会落到 ELSE。CASE WHEN birth_year < 1980 THEN 'older' ELSE 'younger' END 会把所有出生年份未知的会员都归到「younger」里。要先写 WHEN birth_year IS NULL THEN 'unknown'。SQL CASE WHEN 用法里用真实数字演示了这一点。
检查清单
- 要找缺失值?用
IS NULL/IS NOT NULL,永远不要写= NULL。 - 用
cond和NOT cond拆分行?先决定 NULL 归哪边。 - 计数?
COUNT(*)数行,COUNT(col)数非 NULL 值。LEFT JOIN之后,数右表的列。 - 求平均?NULL 会被跳过。除非 NULL 真的代表 0,否则不要用
COALESCE把它变成 0。 - 对可能没有行的数据求和?用
COALESCE(SUM(...), 0)。 - 做除法?用
NULLIF(分母, 0)。 - 对可为 NULL 的列排序?写明
NULLS FIRST或NULLS LAST。 LEFT JOIN时要筛选右表?把条件放进ON。- 找没有匹配的行?用
NOT EXISTS,不要用NOT IN。 - 有 1900、0 或 ‘N/A’ 这样的占位值?聚合之前先用
NULLIF把它们变成 NULL。
常见问题
为什么 WHERE column = NULL 一行都查不到?
因为任何值和 NULL 比较的结果都是 NULL(未知),而不是真,WHERE 只保留条件为真的行。要写 WHERE column IS NULL。
COUNT(*) 和 COUNT(column) 有什么区别?
COUNT(*) 数行数。COUNT(column) 只数该列不为 NULL 的行。在 app_events 里,两者相差 912,625,也就是没有会员的那些事件。
COALESCE 和 NULLIF 有什么区别?
它们正好相反。COALESCE(a, b) 把 NULL 换成一个值。NULLIF(a, b) 在 a 等于 b 时把值换成 NULL,典型用法是在除法前写 NULLIF(x, 0)。
NULL 等于空字符串吗?
不等于。在 PostgreSQL 和 DuckDB 里,'' 是一个长度为零的真实值,'' IS NULL 为假。Oracle 是例外:它把空字符串当作 NULL。
SQL 里 NULL 怎么排序?
取决于数据库。PostgreSQL 升序时把 NULL 排在最后,降序时排在最前;DuckDB 在两个方向上都排在最后。在支持的数据库里写上 NULLS FIRST 或 NULLS LAST,就能在各处得到相同的结果。
其他语言: English, Español, Português (Brasil), Tiếng Việt