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

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 |
SUM(CASE ... THEN 1 ELSE 0 END)cộng thêm 1 cho mỗi dòng khớp.COUNT(CASE ... THEN 1 END)chạy được vìCOUNT(biểu thức)bỏ qua NULL, và khi không cóELSE, những dòng không khớp là NULL.AVG(CASE ... THEN 1.0 ELSE 0 END)cho ra tỷ lệ ngay lập tức: trung bình của các số 1 và 0 chính là tỷ lệ số 1.
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
COALESCE(a, b)làCASE WHEN a IS NOT NULL THEN a ELSE b END.NULLIF(a, b)làCASE WHEN a = b THEN NULL ELSE a END; công dụng chính của nó là tránh chia cho 0.- MySQL có
IF(cond, a, b)và SQL Server cóIIF(cond, a, b). Chúng ngắn hơn nhưng không tương thích giữa các hệ;CASEchạy được ở mọi nơi.
Để 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), 简体中文