SQL 数据分析 · 指南 · 2026年10月30日 · 13 分钟阅读
SQL CASE WHEN 用法:条件判断、分组归类与条件聚合

CASE 就是 SQL 里的 if-then-else。它逐行检查条件,按顺序判断,返回第一个为真的条件对应的值。如果一个都不成立,就返回 ELSE 的值;没有 ELSE 时返回 NULL:
CASE
WHEN condition_1 THEN value_1
WHEN condition_2 THEN value_2
ELSE fallback_value
END
CASE 是表达式,不是语句,所以凡是能放值的地方都能放它:SELECT、WHERE、GROUP BY、ORDER BY,以及聚合函数内部。最后这种用法叫条件聚合,是 CASE 对分析师最有价值的地方。
下面的例子都在 Nomto SQL 课程使用的航空公司数据库上运行:2026 年夏季 104 个机场之间的 33,121 个航班,以及订单、机票、座位和会员计划数据,金额单位为美元。SQL 采用 PostgreSQL 语法,每个结果都是真实运行得到的。
搜索 CASE:任意条件
最常见的写法叫搜索 CASE(searched CASE),每个 WHEN 后面跟一个完整的条件。把机队按航程分类:
SELECT model, range,
CASE
WHEN range >= 6000 THEN 'long-haul'
WHEN range >= 3000 THEN 'medium-haul'
ELSE 'short-haul'
END AS category
FROM aircrafts
ORDER BY range DESC;
| model | range | category |
|---|---|---|
| Boeing 777-300 | 11100 | long-haul |
| Boeing 767-300 | 7900 | long-haul |
| Airbus A319-100 | 6700 | long-haul |
| Airbus A320-200 | 5700 | medium-haul |
| Airbus A321-200 | 5600 | medium-haul |
| Boeing 737-300 | 4200 | medium-haul |
| Sukhoi Superjet-100 | 3000 | medium-haul |
| Bombardier CRJ-200 | 2700 | short-haul |
| Cessna 208 Caravan | 1200 | short-haul |
注意第二个分支只写了 range >= 3000,而不是 range >= 3000 AND range < 6000。它不需要上限,因为 6,000 及以上的行已经被第一个分支拿走了。这正是 CASE 能工作的规则:分支从上到下依次检查,第一个匹配的生效。
简单 CASE:拿一个表达式和若干值比较
如果每个分支都是拿同一个表达式和常量比较,用简单 CASE(simple CASE)更短:
SELECT aircraft_code, model,
CASE aircraft_code
WHEN 'SU9' THEN 'Sukhoi'
WHEN 'CR2' THEN 'Bombardier'
WHEN 'CN1' THEN 'Cessna'
ELSE split_part(model, ' ', 1)
END AS maker
FROM aircrafts
ORDER BY aircraft_code;
| aircraft_code | model | maker |
|---|---|---|
| 319 | Airbus A319-100 | Airbus |
| 320 | Airbus A320-200 | Airbus |
| 321 | Airbus A321-200 | Airbus |
| 733 | Boeing 737-300 | Boeing |
| 763 | Boeing 767-300 | Boeing |
| 773 | Boeing 777-300 | Boeing |
| CN1 | Cessna 208 Caravan | Cessna |
| CR2 | Bombardier CRJ-200 | Bombardier |
| SU9 | Sukhoi Superjet-100 | Sukhoi |
CASE aircraft_code WHEN 'SU9' 的意思就是 CASE WHEN aircraft_code = 'SU9'。返回值也可以是表达式:这里的 ELSE 取机型名称的第一个单词。(在这个例子里,每个分支碰巧都等于第一个单词;在真实数据里,显式写出的分支就是用来放例外情况的。)简单 CASE 只能判断相等,所以要判断范围、LIKE、IN 或 NULL,就得用搜索 CASE。
把数值分档归类
把连续的数值切成几个区间,是 CASE 的日常工作。航段价格是怎么分布的?
SELECT CASE
WHEN amount < 100 THEN 'under $100'
WHEN amount < 300 THEN '$100-299'
WHEN amount < 600 THEN '$300-599'
ELSE '$600+'
END AS price_band,
COUNT(*) AS segments
FROM ticket_flights
GROUP BY price_band
ORDER BY MIN(amount);
| price_band | segments |
|---|---|
| under $100 | 359344 |
| $100-299 | 525159 |
| $300-599 | 93383 |
| $600+ | 67840 |
这里有两个小技巧。按别名 price_band 分组在 PostgreSQL 和 DuckDB 里都可以(严格的标准 SQL 会要求你把整个 CASE 再写一遍,或者用位置写 GROUP BY 1)。ORDER BY MIN(amount) 让各档按真实的大小顺序排列,而不是按字母顺序,否则 $100-299 会排在 under $100 前面。
同样的思路也适用于时间间隔。已到达航班起飞时晚了多少?
SELECT CASE
WHEN actual_departure - scheduled_departure <= INTERVAL '5 minutes' THEN 'up to 5 min'
WHEN actual_departure - scheduled_departure <= INTERVAL '15 minutes' THEN '6-15 min'
WHEN actual_departure - scheduled_departure <= INTERVAL '3 hours' THEN '15 min to 3 h'
ELSE 'over 3 h'
END AS delay_group,
COUNT(*) AS flights
FROM flights
WHERE status = 'Arrived'
GROUP BY delay_group
ORDER BY MIN(actual_departure - scheduled_departure);
| delay_group | flights |
|---|---|
| up to 5 min | 14657 |
| 6-15 min | 1254 |
| 15 min to 3 h | 209 |
| over 3 h | 587 |
第一个匹配生效:分支顺序很重要
分支顺序写错,某个类别就会悄悄消失:
SELECT CASE
WHEN actual_departure - scheduled_departure > INTERVAL '15 minutes' THEN 'late'
WHEN actual_departure - scheduled_departure > INTERVAL '3 hours' THEN 'very late'
ELSE 'on time'
END AS delay_group,
COUNT(*) AS flights
FROM flights
WHERE status = 'Arrived'
GROUP BY delay_group
ORDER BY flights DESC;
| delay_group | flights |
|---|---|
| on time | 15911 |
| late | 796 |
每个晚点超过三小时的航班,也都晚点超过 15 分钟,所以第一个分支把它拿走了,very late 永远不会触发。没有报错,也没有警告,只是一个零行的类别,没人发现它不见了。用「大于」条件时,把最大的阈值放在最前面;用「小于」条件时,把最小的阈值放在最前面,就像上面的价格分档那样。
CASE 和 NULL
CASE 遇到 NULL 时,有两条规则常让人栽跟头。
条件为 NULL 时按假处理,这一行会落到 ELSE。会员计划里有些会员没有填 birth_year。看看下面会发生什么:
SELECT CASE
WHEN birth_year < 1980 THEN 'born before 1980'
ELSE 'born 1980 or later'
END AS age_group,
COUNT(*) AS members
FROM loyalty_members
GROUP BY age_group
ORDER BY age_group;
| age_group | members |
|---|---|
| born 1980 or later | 35666 |
| born before 1980 | 17334 |
6,319 名没有出生年份的会员被标成了「born 1980 or later」。要给 NULL 单独一个分支,并且放在最前面:
SELECT CASE
WHEN birth_year IS NULL THEN 'unknown'
WHEN birth_year < 1980 THEN 'born before 1980'
ELSE 'born 1980 or later'
END AS age_group,
COUNT(*) AS members
FROM loyalty_members
GROUP BY age_group
ORDER BY age_group;
| age_group | members |
|---|---|
| born 1980 or later | 29347 |
| born before 1980 | 17334 |
| unknown | 6319 |
简单 CASE 匹配不到 NULL。 CASE birth_year WHEN NULL THEN 'unknown' ELSE 'known' END 会把全部 53,000 名会员都标成「known」,因为它判断的是 birth_year = NULL,而这永远不会为真。要用搜索 CASE 写 WHEN birth_year IS NULL。
如果省略 ELSE,没有匹配的行得到 NULL:
SELECT model,
CASE
WHEN range >= 6000 THEN 'long-haul'
WHEN range >= 3000 THEN 'medium-haul'
END AS category
FROM aircrafts
WHERE range < 3000;
| model | category |
|---|---|
| Cessna 208 Caravan | NULL |
| Bombardier CRJ-200 | NULL |
有时这正是你想要的(下面的 AVG 技巧就靠它)。不想要时,显式写一个 ELSE 'other',空缺就会显露出来。更多 NULL 的行为见 SQL NULL 值处理。
ORDER BY 里的 CASE:自定义排序
字母顺序很少符合业务上的顺序。航班状态有一个自然的生命周期:
SELECT status, COUNT(*) AS flights
FROM flights
GROUP BY status
ORDER BY CASE status
WHEN 'Scheduled' THEN 1
WHEN 'On Time' THEN 2
WHEN 'Delayed' THEN 3
WHEN 'Departed' THEN 4
WHEN 'Arrived' THEN 5
ELSE 6
END;
| status | flights |
|---|---|
| Scheduled | 15383 |
| On Time | 518 |
| Delayed | 41 |
| Departed | 58 |
| Arrived | 16707 |
| Cancelled | 414 |
这个 CASE 返回的数字只用来排序,不会出现在输出里。ELSE 6 把任何意料之外的值(这里是 Cancelled)排到最后,而不会打乱顺序。
GROUP BY 里的 CASE:自定义分组
按 CASE 分组,可以造出表里本来没有的分组。周末每天的航班比工作日多吗?
SELECT CASE
WHEN EXTRACT(ISODOW FROM scheduled_departure) IN (6, 7) THEN 'weekend'
ELSE 'weekday'
END AS day_type,
COUNT(*) AS flights,
COUNT(DISTINCT CAST(scheduled_departure AS date)) AS days,
ROUND(COUNT(*) * 1.0 / COUNT(DISTINCT CAST(scheduled_departure AS date)), 1) AS flights_per_day
FROM flights
GROUP BY day_type
ORDER BY day_type;
| day_type | flights | days | flights_per_day |
|---|---|---|---|
| weekday | 23555 | 44 | 535.3 |
| weekend | 9566 | 17 | 562.7 |
直接比较总数(23,555 对 9,566)毫无意义,因为工作日多得多。除以每组的天数才是公平的比较:周末大约忙 5%。
条件聚合
把 CASE 放进聚合函数里,就能在一次扫描中算出多个带筛选条件的指标,每个指标一列。这是分析工作里最有用的 CASE 写法。
用 SUM(CASE … THEN 1 ELSE 0 END) 计数
几个大机场各取消了多少航班,占比多少?
SELECT departure_airport,
COUNT(*) AS flights,
SUM(CASE WHEN status = 'Cancelled' THEN 1 ELSE 0 END) AS cancelled,
ROUND(100.0 * SUM(CASE WHEN status = 'Cancelled' THEN 1 ELSE 0 END) / COUNT(*), 2) AS cancel_pct
FROM flights
WHERE departure_airport IN ('SVO', 'DME', 'VKO', 'LED')
GROUP BY departure_airport
ORDER BY departure_airport;
| departure_airport | flights | cancelled | cancel_pct |
|---|---|---|---|
| DME | 3217 | 31 | 0.96 |
| LED | 1900 | 15 | 0.79 |
| SVO | 2981 | 26 | 0.87 |
| VKO | 1719 | 20 | 1.16 |
WHERE status = 'Cancelled' 做不到这一点:它会把其他航班扔掉,分母也跟着没了。条件聚合保留所有行,只数你关心的那些。注意这里写的是 100.0:如果写 100,计数又是整数,PostgreSQL 会做整数除法,返回 0。DuckDB 的 / 总是返回小数,所以同样的错误在 DuckDB 里看不出来,要到在 PostgreSQL 上运行时才暴露;DuckDB 的整数除法用 //。
条件计数有三种等价写法,还有一种很诱人的错误写法:
SELECT COUNT(*) AS arrived,
SUM(CASE WHEN actual_departure - scheduled_departure > INTERVAL '15 minutes' THEN 1 ELSE 0 END) AS late,
COUNT(CASE WHEN actual_departure - scheduled_departure > INTERVAL '15 minutes' THEN 1 END) AS late_count,
ROUND(AVG(CASE WHEN actual_departure - scheduled_departure > INTERVAL '15 minutes' THEN 1.0 ELSE 0 END) * 100, 2) AS late_pct
FROM flights
WHERE status = 'Arrived';
| arrived | late | late_count | late_pct |
|---|---|---|---|
| 16707 | 796 | 796 | 4.76 |
SUM(CASE ... THEN 1 ELSE 0 END):每匹配一行就加 1。COUNT(CASE ... THEN 1 END):能用是因为COUNT(表达式)会跳过 NULL,而没有ELSE时不匹配的行就是 NULL。AVG(CASE ... THEN 1.0 ELSE 0 END):直接得到比例,一串 1 和 0 的平均值就是 1 所占的比例。
错误的写法是 COUNT(CASE ... THEN 1 ELSE 0 END)。0 不是 NULL,COUNT 会把它也数进去,结果是 16,707,也就是所有行。用 COUNT 就不要写 ELSE;用 SUM 就保留 ELSE 0。
FILTER:更简洁的语法
PostgreSQL 和 DuckDB 支持标准的 FILTER 子句,表达同样的意思更直接。每种机型的座位分布,每个舱位一列:
SELECT aircraft_code,
COUNT(*) FILTER (WHERE fare_conditions = 'Economy') AS economy,
COUNT(*) FILTER (WHERE fare_conditions = 'Comfort') AS comfort,
COUNT(*) FILTER (WHERE fare_conditions = 'Business') AS business
FROM seats
GROUP BY aircraft_code
ORDER BY aircraft_code;
| aircraft_code | economy | comfort | business |
|---|---|---|---|
| 319 | 96 | 0 | 20 |
| 320 | 120 | 0 | 20 |
| 321 | 142 | 0 | 28 |
| 733 | 118 | 0 | 12 |
| 763 | 192 | 0 | 30 |
| 773 | 324 | 48 | 30 |
| CN1 | 12 | 0 | 0 |
| CR2 | 50 | 0 | 0 |
| SU9 | 85 | 0 | 12 |
这就是行转列(pivot):把 fare_conditions 的各行变成了列。MySQL、SQL Server 和 Oracle 不支持 FILTER,如果你的代码要在这些数据库上运行,就用 SUM(CASE ...),它在哪里都能用。
不只计数,还能求和
SUM 里的 CASE 可以返回值本身,而不是 1。按舱位拆分已到达航班的月度收入:
SELECT date_trunc('month', f.scheduled_departure)::date AS month,
ROUND(SUM(CASE WHEN tf.fare_conditions = 'Economy' THEN tf.amount ELSE 0 END)) AS economy,
ROUND(SUM(CASE WHEN tf.fare_conditions = 'Comfort' THEN tf.amount ELSE 0 END)) AS comfort,
ROUND(SUM(CASE WHEN tf.fare_conditions = 'Business' THEN tf.amount ELSE 0 END)) AS business
FROM ticket_flights tf
JOIN flights f ON f.flight_id = tf.flight_id
WHERE f.status = 'Arrived'
GROUP BY month
ORDER BY month;
| month | economy | comfort | business |
|---|---|---|---|
| 2026-07-01 | 47444895 | 1899027 | 17733606 |
| 2026-08-01 | 33400255 | 1327303 | 12473544 |
(8 月偏低,是因为数据快照截止到 8 月 11 日,当月剩下的航班还没飞。)
而不写 ELSE 时,AVG 只对匹配的行求平均,因为其他行是 NULL,聚合函数会跳过 NULL:
SELECT fare_conditions,
ROUND(SUM(amount)) AS revenue,
ROUND(SUM(CASE WHEN amount >= 600 THEN amount ELSE 0 END)) AS revenue_600plus,
ROUND(AVG(CASE WHEN amount >= 600 THEN amount END), 2) AS avg_600plus
FROM ticket_flights
GROUP BY fare_conditions
ORDER BY revenue DESC;
| fare_conditions | revenue | revenue_600plus | avg_600plus |
|---|---|---|---|
| Economy | 146956844 | 26449215 | 634.9 |
| Business | 55051796 | 31703523 | 1210.94 |
| Comfort | 5661169 | 0 | NULL |
在这里,ELSE 0 和不写 ELSE 给出的结果不同,但都是对的:SUM 需要 0,这样总和才是 0 而不是 NULL;AVG 则不能把 0 算进去,否则就成了对所有航段求平均。舒适舱没有 600 美元及以上的航段,所以它的平均值是 NULL:没有东西可以求平均。
类型:每个分支必须返回同一种类型
所有 THEN 和 ELSE 的值必须能归入同一种数据类型。CASE WHEN ... THEN 1 ELSE 'none' END 把整数和文本混在一起。PostgreSQL 会报错「invalid input syntax for type integer」。DuckDB 也会因类型转换出错,但只在某一行真的走到 'none' 分支时才报错,所以查询可能在小样本上测试通过,之后才出问题。要显式转换类型:THEN CAST(x AS VARCHAR),或者返回 NULL 而不是占位字符串。
WHERE 里的 CASE:通常用不着
你可以写 WHERE CASE WHEN ... THEN ... END,但筛选条件几乎总是用普通的 AND / OR 更好读。WHERE CASE WHEN status = 'Arrived' THEN actual_departure > '2026-08-01' ELSE false END 其实就是 WHERE status = 'Arrived' AND actual_departure > '2026-08-01'。普通条件也更方便优化器利用索引。
相关的简写
COALESCE(a, b)就是CASE WHEN a IS NOT NULL THEN a ELSE b END。NULLIF(a, b)就是CASE WHEN a = b THEN NULL ELSE a END,主要用来避免除以零。- MySQL 有
IF(cond, a, b),SQL Server 有IIF(cond, a, b)。它们更短,但不可移植;CASE在哪里都能运行。
更多用到条件计数的 GROUP BY 写法,见 GROUP BY 和 HAVING 的区别。用条件聚合做行转列也是 SQL 面试题里的常客。
常见问题
简单 CASE 和搜索 CASE 有什么区别?
简单 CASE CASE x WHEN 1 THEN ... 拿一个表达式和若干值做相等比较。搜索 CASE CASE WHEN condition THEN ... 可以接任意条件:范围、LIKE、IN、IS NULL,以及用 AND 组合的条件。简单 CASE 能做的,搜索 CASE 都能做。
没有任何条件匹配时,CASE 返回什么?
返回 ELSE 的值;没有 ELSE 时返回 NULL。
WHERE 子句里能用 CASE 吗?
能,但很少是最清楚的写法。WHERE 里的大多数 CASE 表达式都可以改写成普通的 AND / OR 条件。
SQL 怎么统计满足某个条件的行数?
用条件聚合:SUM(CASE WHEN condition THEN 1 ELSE 0 END)、COUNT(CASE WHEN condition THEN 1 END),或者在 PostgreSQL 和 DuckDB 里用 COUNT(*) FILTER (WHERE condition)。三种写法都保留了其他行,方便计算总数和百分比。
CASE 可以嵌套吗?
可以,THEN 或 ELSE 里可以再放一个 CASE。不过通常用 AND 组合条件的单个 CASE,比嵌套更好读。
其他语言: English, Español, Português (Brasil), Tiếng Việt