SQL cho phân tích dữ liệu · Hướng dẫn · 30 tháng 10, 2026 · 15 phút đọc

CASE WHEN trong SQL: câu lệnh điều kiện, phân loại và tổng hợp có điều kiện

Biển chỉ dẫn trong sân bay hướng hành khách đến các cửa khác nhau tùy theo vé của họ

CASE là câu lệnh if-then-else của SQL. Nó xét từng dòng, kiểm tra các điều kiện theo thứ tự, và trả về giá trị của điều kiện đầu tiên đúng. Nếu không điều kiện nào đúng, nó trả về giá trị trong ELSE, hoặc NULL nếu không có ELSE:

CASE
  WHEN condition_1 THEN value_1
  WHEN condition_2 THEN value_2
  ELSE fallback_value
END

CASE là một biểu thức chứ không phải một câu lệnh, nên nó đặt được ở bất cứ chỗ nào chấp nhận một giá trị: trong SELECT, WHERE, GROUP BY, ORDER BY, và bên trong hàm tổng hợp. Cách dùng cuối cùng, tổng hợp có điều kiện (conditional aggregation), là nơi CASE mang lại giá trị lớn nhất cho người làm phân tích.

Các ví dụ chạy trên cơ sở dữ liệu hãng hàng không của khóa SQL trên Nomto: 33.121 chuyến bay giữa 104 sân bay trong mùa hè 2026, cùng đơn đặt chỗ, vé, ghế và một chương trình khách hàng thân thiết. Tiền tính bằng đô la Mỹ. Cú pháp là PostgreSQL, và mọi kết quả đều có được bằng cách chạy truy vấn thật.

CASE tìm kiếm: điều kiện bất kỳ

Dạng phổ biến, gọi là CASE tìm kiếm (searched CASE), nhận một điều kiện đầy đủ sau mỗi WHEN. Phân loại đội bay theo tầm bay:

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

Để ý rằng nhánh thứ hai chỉ ghi range >= 3000, không phải range >= 3000 AND range < 6000. Nó không cần cận trên, vì những dòng có tầm bay từ 6.000 trở lên đã bị nhánh đầu tiên bắt mất. Đó là quy tắc giúp CASE hoạt động: các nhánh được kiểm tra từ trên xuống dưới, và nhánh khớp đầu tiên thắng.

CASE đơn giản: so sánh một biểu thức với các giá trị

Khi mọi nhánh đều so sánh cùng một biểu thức với một hằng số, dạng đơn giản (simple CASE) ngắn gọn hơn:

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' nghĩa là CASE WHEN aircraft_code = 'SU9'. Giá trị trả về cũng có thể là biểu thức: ELSE ở đây lấy từ đầu tiên trong tên mẫu máy bay. (Ở đây tình cờ nhánh nào cũng cho ra từ đầu tiên; trong dữ liệu thật, các nhánh viết rõ là nơi đặt những trường hợp ngoại lệ.) Dạng đơn giản chỉ kiểm tra được phép bằng, nên với khoảng giá trị, LIKE, IN hay kiểm tra NULL, bạn cần dạng tìm kiếm.

Chia số thành các nhóm

Biến một con số liên tục thành các khoảng là công việc hằng ngày của CASE. Giá các chặng bay phân bố thế nào?

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

Có hai mẹo nhỏ ở đây. Nhóm theo bí danh price_band chạy được trong PostgreSQL và DuckDB (SQL chuẩn nghiêm ngặt sẽ bắt bạn lặp lại cả biểu thức CASE hoặc dùng vị trí của nó, GROUP BY 1). Còn ORDER BY MIN(amount) sắp xếp các khoảng theo thứ tự thật thay vì theo bảng chữ cái, khi đó $100-299 sẽ đứng trước under $100.

Ý tưởng tương tự áp dụng cho khoảng thời gian. Các chuyến đã hạ cánh cất cánh trễ bao lâu?

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

Nhánh khớp đầu tiên thắng: thứ tự các nhánh rất quan trọng

Đặt các nhánh sai thứ tự và một nhóm sẽ âm thầm biến mất:

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

Mọi chuyến trễ hơn ba giờ cũng đều trễ hơn 15 phút, nên nhánh đầu tiên bắt lấy nó và very late không bao giờ được kích hoạt. Không lỗi, không cảnh báo, chỉ có một nhóm không dòng nào mà chẳng ai nhận ra là đang thiếu. Với điều kiện “lớn hơn”, hãy đặt ngưỡng lớn nhất lên đầu; với “nhỏ hơn”, đặt ngưỡng nhỏ nhất lên đầu, như các khoảng giá ở trên.

CASE và NULL

Có hai quy tắc về NULL khiến nhiều người mắc lỗi với CASE.

Điều kiện cho kết quả NULL được coi là sai, nên dòng đó rơi xuống ELSE. Trong chương trình khách hàng thân thiết, birth_year bị thiếu ở một số thành viên. Hãy xem chuyện gì xảy ra:

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 thành viên không có năm sinh đã bị gắn nhãn “born 1980 or later”. Hãy xử lý NULL trong một nhánh riêng, đặt lên đầu:

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 đơn giản không khớp được NULL. CASE birth_year WHEN NULL THEN 'unknown' ELSE 'known' END gắn nhãn “known” cho cả 53.000 thành viên, vì nó kiểm tra birth_year = NULL, điều không bao giờ đúng. Hãy dùng WHEN birth_year IS NULL ở dạng tìm kiếm.

Và nếu bạn bỏ ELSE, những dòng không khớp nhận giá trị 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

Đôi khi đó đúng là điều bạn muốn (nó là nền tảng của mẹo dùng AVG bên dưới). Khi không phải vậy, một ELSE 'other' rõ ràng sẽ làm lộ ra những chỗ trống. Đọc thêm về cách NULL hoạt động trong bài NULL trong SQL.

CASE trong ORDER BY: thứ tự sắp xếp tùy chỉnh

Thứ tự bảng chữ cái hiếm khi trùng với thứ tự nghiệp vụ. Trạng thái chuyến bay có một vòng đời tự nhiên:

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 trả về một con số chỉ dùng để sắp xếp; nó không xuất hiện trong kết quả. ELSE 6 đẩy mọi giá trị ngoài dự kiến, ở đây là Cancelled, xuống cuối thay vì làm hỏng thứ tự.

CASE trong GROUP BY: phân khúc của riêng bạn

Bạn có thể nhóm theo một biểu thức CASE để tạo ra những phân khúc không có sẵn dưới dạng cột. Cuối tuần có nhiều chuyến bay mỗi ngày hơn ngày thường không?

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

So sánh tổng thô (23.555 với 9.566) sẽ vô nghĩa, vì số ngày thường nhiều hơn hẳn. Chia cho số ngày trong mỗi phân khúc mới cho phép so sánh công bằng: cuối tuần đông hơn khoảng 5%.

Tổng hợp có điều kiện

Đặt CASE bên trong một hàm tổng hợp, và bạn có thể tính nhiều chỉ số đã lọc trong một lượt quét bảng, mỗi chỉ số một cột. Đây là mẫu CASE hữu ích nhất trong phân tích.

Đếm bằng SUM(CASE … THEN 1 ELSE 0 END)

Mỗi sân bay lớn hủy bao nhiêu chuyến, và chiếm tỷ lệ bao nhiêu?

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' không làm được việc này: nó sẽ loại bỏ các chuyến bay khác, và mẫu số cũng mất theo. Tổng hợp có điều kiện giữ lại mọi dòng và chỉ đếm những dòng bạn quan tâm. Để ý con số 100.0: nếu viết 100 với các số đếm nguyên, PostgreSQL sẽ chia lấy phần nguyên và trả về 0. Phép / của DuckDB luôn trả về kết quả có phần thập phân, nên cùng lỗi đó bị che giấu cho đến khi truy vấn chạy trên PostgreSQL; DuckDB dùng // cho phép chia lấy phần nguyên.

Có ba cách tương đương để đếm có điều kiện, và một cách sai rất dễ bị cám dỗ:

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

Cách sai là COUNT(CASE ... THEN 1 ELSE 0 END). Số 0 không phải NULL, nên COUNT vẫn đếm nó, và bạn nhận được 16.707: tất cả các dòng. Nếu dùng COUNT, hãy bỏ ELSE; nếu dùng SUM, hãy giữ ELSE 0.

FILTER: cú pháp gọn hơn

PostgreSQL và DuckDB hỗ trợ mệnh đề chuẩn FILTER, diễn đạt cùng ý đó trực tiếp hơn. Sơ đồ ghế của từng máy bay, mỗi hạng ghế một cột:

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

Đây là một phép xoay bảng (pivot): các dòng fare_conditions được biến thành cột. FILTER không có trong MySQL, SQL Server hay Oracle, nên nếu code của bạn phải chạy ở đó, hãy dùng SUM(CASE ...); nó chạy được ở mọi nơi.

Cộng số tiền, không chỉ đếm

CASE bên trong SUM có thể trả về chính giá trị thay vì số 1. Doanh thu hàng tháng của các chuyến đã hạ cánh, tách theo hạng ghế:

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

(Tháng 8 thấp hơn vì bản chụp dữ liệu kết thúc vào ngày 11/8; phần còn lại của tháng chưa bay.)

Còn khi không có ELSE, AVG chỉ tính trung bình trên những dòng khớp, vì các dòng khác là NULL và hàm tổng hợp bỏ qua 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

Ở đây ELSE 0 và không có ELSE cho ra hai kết quả khác nhau, và cả hai đều đúng: SUM cần các số 0 để tổng bằng 0 chứ không phải NULL, còn AVG không được tính các số 0, nếu không nó sẽ lấy trung bình trên mọi chặng bay. Hạng Comfort không có chặng nào từ $600 trở lên, nên giá trị trung bình là NULL: không có gì để tính trung bình.

Kiểu dữ liệu: mọi nhánh phải trả về cùng một kiểu

Mọi giá trị THEN và ELSE phải thuộc cùng một kiểu dữ liệu. CASE WHEN ... THEN 1 ELSE 'none' END trộn một số nguyên với văn bản. PostgreSQL từ chối nó với thông báo “invalid input syntax for type integer”. DuckDB cũng thất bại, với lỗi chuyển đổi kiểu, nhưng chỉ khi có một dòng thật sự rơi vào nhánh 'none', nên một truy vấn có thể vượt qua bài kiểm tra trên mẫu dữ liệu nhỏ rồi hỏng về sau. Hãy ép kiểu tường minh: THEN CAST(x AS VARCHAR), hoặc trả về NULL thay vì một chuỗi giữ chỗ.

CASE trong WHERE: thường không cần

Bạn có thể viết WHERE CASE WHEN ... THEN ... END, nhưng một bộ lọc gần như luôn dễ đọc hơn khi viết bằng logic AND / OR thông thường. WHERE CASE WHEN status = 'Arrived' THEN actual_departure > '2026-08-01' ELSE false END chỉ đơn giản là WHERE status = 'Arrived' AND actual_departure > '2026-08-01'. Điều kiện thông thường cũng giúp bộ tối ưu hóa dễ dùng chỉ mục hơn.

Các cách viết tắt liên quan

Để xem thêm các mẫu GROUP BY dùng phép đếm có điều kiện, hãy đọc bài GROUP BY và HAVING trong SQL. Xoay bảng bằng tổng hợp có điều kiện cũng là nội dung quen thuộc trong câu hỏi phỏng vấn SQL.

Câu hỏi thường gặp

CASE đơn giản và CASE tìm kiếm khác nhau thế nào?

CASE x WHEN 1 THEN ... đơn giản so sánh bằng một biểu thức với các giá trị. CASE WHEN điều_kiện THEN ... tìm kiếm nhận điều kiện bất kỳ: khoảng giá trị, LIKE, IN, IS NULL, kết hợp với AND. Dạng tìm kiếm làm được mọi thứ mà dạng đơn giản làm được.

CASE trả về gì nếu không điều kiện nào khớp?

Giá trị trong ELSE, hoặc NULL nếu không có ELSE.

Có dùng được CASE trong mệnh đề WHERE không?

Có, nhưng hiếm khi đó là cách rõ ràng nhất. Phần lớn biểu thức CASE trong WHERE đều viết lại được thành điều kiện AND / OR thông thường.

Làm sao đếm số dòng thỏa mãn một điều kiện trong SQL?

Dùng tổng hợp có điều kiện: SUM(CASE WHEN điều_kiện THEN 1 ELSE 0 END), COUNT(CASE WHEN điều_kiện THEN 1 END), hoặc trong PostgreSQL và DuckDB là COUNT(*) FILTER (WHERE điều_kiện). Cả ba cách đều giữ lại các dòng khác để tính tổng và tỷ lệ phần trăm.

Có lồng các biểu thức CASE vào nhau được không?

Được, một THEN hoặc ELSE có thể chứa một CASE khác. Tuy vậy, thường thì một CASE duy nhất với các điều kiện kết hợp bằng AND sẽ dễ đọc hơn việc lồng nhau.

Ngôn ngữ khác: English, Español, Português (Brasil), 简体中文