Viết logic rẽ nhánh ngay trong SQL bằng CASE, xử lý NULL bằng COALESCE và NULLIF, và chuyển đổi kiểu an toàn bằng CAST.
1. CASE
CASE là câu lệnh if-then-else của SQL. Nó là biểu thức, nên dùng được ở bất cứ đâu chấp nhận một giá trị.
1.1. Dạng tìm kiếm (searched CASE)
Linh hoạt nhất — mỗi nhánh là một điều kiện boolean độc lập:
CASE WHEN dieu_kien_1 THEN ket_qua_1 WHEN dieu_kien_2 THEN ket_qua_2 [ELSE ket_qua_mac_dinh]END
SELECT title, length, CASE WHEN length < 50 THEN 'Rất ngắn' WHEN length BETWEEN 50 AND 120 THEN 'Trung bình' WHEN length > 120 THEN 'Dài' ELSE 'Không xác định' END AS phan_loaiFROM filmORDER BY lengthLIMIT 8;
CASE dừng ở nhánh đầu tiên đúng
Thứ tự các WHEN rất quan trọng:
-- ❌ Nhánh thứ hai không bao giờ chạyCASE WHEN diem >= 5 THEN 'Đạt' WHEN diem >= 8 THEN 'Giỏi' -- diem = 9 đã khớp nhánh trên rồiEND-- ✅ Từ hẹp tới rộngCASE WHEN diem >= 8 THEN 'Giỏi' WHEN diem >= 5 THEN 'Đạt' ELSE 'Chưa đạt'END
Không có ELSE thì trả về NULL
SELECT CASE WHEN false THEN 'a' END; -- NULL
Luôn viết ELSE trừ khi bạn thực sự muốn NULL.
1.2. Dạng đơn giản (simple CASE)
So sánh một biểu thức với các giá trị:
CASE bieu_thuc WHEN gia_tri_1 THEN ket_qua_1 WHEN gia_tri_2 THEN ket_qua_2 [ELSE ket_qua_mac_dinh]END
SELECT title, rating, CASE rating WHEN 'G' THEN 'Mọi lứa tuổi' WHEN 'PG' THEN 'Cần hướng dẫn của phụ huynh' WHEN 'PG-13' THEN 'Không phù hợp dưới 13 tuổi' WHEN 'R' THEN 'Dưới 17 cần người lớn đi kèm' WHEN 'NC-17' THEN 'Chỉ từ 18 tuổi' ELSE 'Chưa phân loại' END AS y_nghiaFROM filmLIMIT 5;
Dạng đơn giản không so sánh được với NULL
CASE cot WHEN NULL THEN 'rỗng' ELSE 'có' END-- Luôn trả về 'có', kể cả khi cot IS NULL
Vì nó dùng phép =, mà NULL = NULL cho ra NULL. Phải dùng dạng tìm kiếm:
CASE WHEN cot IS NULL THEN 'rỗng' ELSE 'có' END
1.3. CASE trong hàm tổng hợp — bảng chéo (pivot)
SELECT count(*) AS tong, count(CASE WHEN rating = 'G' THEN 1 END) AS g, count(CASE WHEN rating = 'PG' THEN 1 END) AS pg, count(CASE WHEN rating = 'PG-13' THEN 1 END) AS pg13, count(CASE WHEN rating = 'R' THEN 1 END) AS r, count(CASE WHEN rating = 'NC-17' THEN 1 END) AS nc17FROM film;
SELECT staff_id, round(sum(amount) FILTER (WHERE EXTRACT(MONTH FROM payment_date) = 2), 2) AS thang_2, round(sum(amount) FILTER (WHERE EXTRACT(MONTH FROM payment_date) = 3), 2) AS thang_3, round(sum(amount) FILTER (WHERE EXTRACT(MONTH FROM payment_date) = 4), 2) AS thang_4, round(sum(amount), 2) AS tongFROM paymentGROUP BY staff_idORDER BY staff_id;
sum vs count với CASE
count(CASE ... THEN 1 END) đếm đúng vì count bỏ qua NULL. sum(CASE WHEN ... THEN 1 ELSE 0 END) cũng đúng.
Nhưng count(CASE WHEN ... THEN 1 ELSE 0 END)sai — nó đếm cả các giá trị 0.
1.4. CASE trong ORDER BY — sắp xếp tùy chỉnh
-- Sắp theo mức độ ưu tiên nghiệp vụ, không phải bảng chữ cáiSELECT title, ratingFROM filmORDER BY CASE rating WHEN 'G' THEN 1 WHEN 'PG' THEN 2 WHEN 'PG-13' THEN 3 WHEN 'R' THEN 4 WHEN 'NC-17' THEN 5 END, title;
Mẹo đưa một nhóm lên đầu:
ORDER BY CASE WHEN trang_thai = 'khan_cap' THEN 0 ELSE 1 END, ngay_tao DESC;
1.5. CASE trong UPDATE
UPDATE filmSET rental_rate = CASE WHEN length > 150 THEN 4.99 WHEN length > 90 THEN 2.99 ELSE 0.99 ENDWHERE rating = 'PG';
1.6. CASE trong GROUP BY
SELECT CASE WHEN amount < 2 THEN 'Nhỏ' WHEN amount < 5 THEN 'Vừa' WHEN amount < 10 THEN 'Lớn' ELSE 'Rất lớn' END AS phan_khuc, count(*) AS so_giao_dich, round(sum(amount), 2) AS tong_tienFROM paymentGROUP BY phan_khucORDER BY tong_tien DESC;
PostgreSQL cho phép dùng bí danh trong GROUP BY — tiện hơn so với phải lặp lại toàn bộ CASE.
1.7. Mọi nhánh phải cùng kiểu
-- ❌ LỖISELECT CASE WHEN true THEN 1 ELSE 'không' END;-- ERROR: invalid input syntax for type integer: "không"-- ✅ Ép về cùng kiểuSELECT CASE WHEN true THEN 1::text ELSE 'không' END;
-- Thay NULL bằng giá trị mặc định khi hiển thịSELECT customer_id, COALESCE(return_date::text, 'Chưa trả') AS trang_thaiFROM rentalLIMIT 5;-- Tránh NULL trong phép tínhSELECT COALESCE(sum(amount), 0) AS doanh_thuFROM paymentWHERE customer_id = 99999; -- không có dòng nào → sum trả NULL → COALESCE cho 0-- Chọn giá trị theo thứ tự ưu tiênSELECT COALESCE(ten_hien_thi, biet_danh, ho_ten, 'Ẩn danh') AS tenFROM nguoi_dung;-- Nối chuỗi an toànSELECT COALESCE(ho, '') || ' ' || COALESCE(ten, '') FROM nguoi_dung;-- Hoặc gọn hơn:SELECT concat_ws(' ', ho, ten) FROM nguoi_dung;
2.2. Ví dụ tự chứa
DROP TABLE IF EXISTS items;CREATE TABLE items ( id SERIAL PRIMARY KEY, product VARCHAR(100) NOT NULL, price NUMERIC NOT NULL, discount NUMERIC);INSERT INTO items (product, price, discount)VALUES ('A', 1000, 10), ('B', 1500, 20), ('C', 800, 5), ('D', 500, NULL);
-- ❌ SAI: sản phẩm D cho ra NULLSELECT product, (price - discount) AS gia_cuoi FROM items;
product | gia_cuoi---------+---------- A | 990 B | 1480 C | 795 D | [NULL]
-- ✅ ĐÚNGSELECT product, (price - COALESCE(discount, 0)) AS gia_cuoi FROM items;
product | gia_cuoi---------+---------- A | 990 B | 1480 C | 795 D | 500
2.3. COALESCE đánh giá lười
COALESCEdừng ngay khi gặp giá trị không NULL — các đối số sau không được tính:
SELECT COALESCE(1, 1/0); -- 1, không lỗi chia cho 0
Hữu ích khi đối số sau là subquery đắt đỏ.
2.4. COALESCE không thay được chuỗi rỗng
SELECT COALESCE('', 'mặc định'); -- '' (chuỗi rỗng KHÔNG phải NULL)
Kết hợp với NULLIF:
SELECT COALESCE(NULLIF(ten, ''), 'Ẩn danh') FROM nguoi_dung;
-- ❌ ERROR: division by zeroSELECT tu_so / mau_so FROM t;-- ✅ Trả về NULL thay vì lỗiSELECT tu_so / NULLIF(mau_so, 0) FROM t;-- ✅ Trả về 0 thay vì NULLSELECT COALESCE(tu_so / NULLIF(mau_so, 0), 0) FROM t;
Ví dụ thực tế — tính tỷ lệ:
SELECT c.name AS the_loai, count(*) AS so_phim, round(100.0 * count(*) FILTER (WHERE f.length > 120) / NULLIF(count(*), 0), 1) AS phan_tram_phim_daiFROM category cJOIN film_category fc ON fc.category_id = c.category_idJOIN film f ON f.film_id = fc.film_idGROUP BY c.nameORDER BY phan_tram_phim_dai DESC;
-- Giới hạn giá trị trong khoảng [0, 100]SELECT LEAST(GREATEST(diem, 0), 100) AS diem_hop_le FROM ket_qua;-- Lấy thời điểm cập nhật gần nhất trong nhiều cộtSELECT GREATEST(sua_luc, tao_luc, xoa_luc) AS thay_doi_cuoi FROM t;
SELECT 'abc'::INTEGER;-- ERROR: invalid input syntax for type integer: "abc"SELECT ''::INTEGER;-- ERROR: invalid input syntax for type integer: ""SELECT '30/07/2026'::DATE;-- ERROR (nếu datestyle không phải DMY)
Cách an toàn:
-- Kiểm tra trước bằng regexSELECT CASE WHEN gia_tri ~ '^-?\d+$' THEN gia_tri::INTEGER ELSE NULL END AS soFROM staging;-- Chuỗi rỗng → NULL trước khi épSELECT NULLIF(trim(gia_tri), '')::NUMERIC FROM staging;-- Ngày với định dạng cụ thểSELECT to_date('30/07/2026', 'DD/MM/YYYY');SELECT to_timestamp('30/07/2026 14:30', 'DD/MM/YYYY HH24:MI');
5.3. Cách làm mạnh nhất — hàm tự viết
CREATE OR REPLACE FUNCTION ep_so_an_toan(txt TEXT)RETURNS NUMERIC AS $$BEGIN RETURN txt::NUMERIC;EXCEPTION WHEN OTHERS THEN RETURN NULL;END;$$ LANGUAGE plpgsql IMMUTABLE;SELECT ep_so_an_toan('123.45'); -- 123.45SELECT ep_so_an_toan('abc'); -- NULL
Từ PostgreSQL 16 có sẵn cú pháp chuẩn:
SELECT CAST('abc' AS INTEGER DEFAULT NULL ON ERROR); -- NULLSELECT CAST('abc' AS INTEGER DEFAULT -1 ON ERROR); -- -1
5.4. Ép kiểu ngầm định và trình tối ưu hóa
Ép kiểu ở cột làm mất tác dụng index
-- ❌ Index trên ma_kh không dùng đượcWHERE ma_kh::TEXT = '12345'-- ✅ Ép ở phía hằng sốWHERE ma_kh = '12345'::INT
Quy tắc chung: giữ cột nguyên vẹn ở một vế, ép kiểu ở vế còn lại.
Tương tự với hàm:
WHERE date_trunc('day', tao_luc) = '2026-07-30' -- ❌ không dùng indexWHERE tao_luc >= '2026-07-30' AND tao_luc < '2026-07-31' -- ✅
5.5. Ép kiểu trong ORDER BY
-- Cột TEXT chứa số → sắp xếp saiSELECT ma FROM t ORDER BY ma;-- '1', '10', '2', '20', '3'-- Ép về số để sắp đúngSELECT ma FROM t ORDER BY ma::INT;-- '1', '2', '3', '10', '20'
6. Bảng tổng hợp
Hàm
Công dụng
CASE WHEN ... THEN ... END
Rẽ nhánh logic tổng quát
COALESCE(a, b, c)
Giá trị không NULL đầu tiên
NULLIF(a, b)
NULL nếu a = b
GREATEST(...) / LEAST(...)
Lớn nhất / nhỏ nhất theo chiều ngang
CAST(x AS t) / x::t
Chuyển đổi kiểu
FILTER (WHERE ...)
Điều kiện cho hàm tổng hợp
IS DISTINCT FROM
So sánh coi NULL là giá trị bình thường
Ba mẫu ghi nhớ:
COALESCE(NULLIF(x, ''), 'mac_dinh') -- xử lý cả NULL lẫn chuỗi rỗnga / NULLIF(b, 0) -- chống chia cho 0LEAST(GREATEST(x, min), max) -- giới hạn giá trị trong khoảng
7. Bài tập
Phân loại phim theo rental_rate: dưới 1 = “Rẻ”, 1–3 = “Trung bình”, trên 3 = “Đắt”. Đếm số phim mỗi loại.
Tạo bảng chéo: số phim theo rating (hàng) × phân loại độ dài (cột).
Tính tỷ lệ phần trăm phim dài trên 120 phút của từng thể loại, tránh lỗi chia cho 0.
Hiển thị return_date của bảng rental, thay NULL bằng “Chưa trả”.
Sắp xếp phim theo thứ tự rating: G → PG → PG-13 → R → NC-17.
Viết truy vấn an toàn ép cột text sang số, dòng nào không hợp lệ trả NULL.
Vì sao câu sau luôn trả về 'có giá trị' dù ghi_chu IS NULL? Sửa lại:
SELECT CASE ghi_chu WHEN NULL THEN 'rỗng' ELSE 'có giá trị' END FROM t;
Cho COALESCE(a, b) và CASE WHEN a IS NOT NULL THEN a ELSE b END — chúng có luôn tương đương không?
Đáp án
1.
SELECT CASE WHEN rental_rate < 1 THEN 'Rẻ' WHEN rental_rate <= 3 THEN 'Trung bình' ELSE 'Đắt' END AS phan_loai, count(*) AS so_phimFROM filmGROUP BY phan_loaiORDER BY so_phim DESC;
2.
SELECT rating, count(*) FILTER (WHERE length < 60) AS ngan, count(*) FILTER (WHERE length BETWEEN 60 AND 120) AS trung_binh, count(*) FILTER (WHERE length > 120) AS dai, count(*) AS tongFROM filmGROUP BY ratingORDER BY rating;
3.
SELECT c.name AS the_loai, count(*) AS tong, count(*) FILTER (WHERE f.length > 120) AS phim_dai, round(100.0 * count(*) FILTER (WHERE f.length > 120) / NULLIF(count(*), 0), 1) AS phan_tramFROM category cLEFT JOIN film_category fc ON fc.category_id = c.category_idLEFT JOIN film f ON f.film_id = fc.film_idGROUP BY c.category_id, c.nameORDER BY phan_tram DESC NULLS LAST;
Dùng LEFT JOIN để không mất thể loại rỗng — đó chính là lúc NULLIF phát huy tác dụng.
4.
SELECT rental_id, customer_id, COALESCE(to_char(return_date, 'DD/MM/YYYY HH24:MI'), 'Chưa trả') AS ngay_traFROM rentalORDER BY rental_idLIMIT 20;
5.
SELECT title, ratingFROM filmORDER BY CASE rating WHEN 'G' THEN 1 WHEN 'PG' THEN 2 WHEN 'PG-13' THEN 3 WHEN 'R' THEN 4 WHEN 'NC-17' THEN 5 ELSE 99 END, title;
6.
SELECT gia_tri, CASE WHEN trim(gia_tri) ~ '^-?\d+(\.\d+)?$' THEN trim(gia_tri)::NUMERIC ELSE NULL END AS soFROM staging;
Hoặc trên PostgreSQL 16+: CAST(gia_tri AS NUMERIC DEFAULT NULL ON ERROR).
7. Dạng CASE đơn giản dùng phép so sánh =, mà ghi_chu = NULL luôn cho ra NULL (không phải true), nên nhánh WHEN NULL không bao giờ khớp. Sửa bằng dạng tìm kiếm:
SELECT CASE WHEN ghi_chu IS NULL THEN 'rỗng' ELSE 'có giá trị' END FROM t;
8. Về mặt kết quả thì tương đương, nhưng có hai khác biệt:
COALESCE đánh giá bchỉ khia IS NULL; CASE cũng vậy — nên về mặt này giống nhau.
COALESCE nhận nhiều đối số và ngắn gọn hơn nhiều khi có 3+ giá trị.
Trường hợp COALESCE(a, b) với a là biểu thức đắt đỏ: nó được tính hai lần trong CASE (một lần ở WHEN, một lần ở THEN), nhưng chỉ một lần trong COALESCE. Đây là lý do nên ưu tiên COALESCE.
8. Tóm tắt
CASE dừng ở nhánh đúng đầu tiên — sắp xếp điều kiện từ hẹp tới rộng.
Không có ELSE thì CASE trả NULL.
Dạng CASE đơn giản không so sánh được với NULL — dùng dạng tìm kiếm với IS NULL.
FILTER (WHERE ...) rõ ràng hơn CASE trong hàm tổng hợp.
COALESCE trả giá trị không NULL đầu tiên, đánh giá lười.
NULLIF(a, 0) chống chia cho 0; NULLIF(x, '') biến chuỗi rỗng thành NULL.
Mẫu vàng: COALESCE(NULLIF(x, ''), 'mac_dinh').
:: là cú pháp ép kiểu riêng của PostgreSQL, ngắn gọn hơn CAST.
Ép kiểu ở phía cột làm mất tác dụng index — luôn ép ở phía hằng số.