SQL cho phân tích dữ liệu · Hướng dẫn · 30 tháng 10, 2026 · 16 phút đọc
NULL trong SQL: IS NULL, COALESCE, NULL trong JOIN, phép đếm và phép so sánh

NULL nghĩa là “ở đây không có giá trị”: không biết, bị thiếu hoặc không áp dụng. Nó không phải số 0, không phải chuỗi rỗng và không phải false. Phần lớn những bất ngờ với NULL đến từ một quy tắc: mọi phép so sánh với NULL đều cho kết quả NULL (không xác định), không phải đúng hay sai, còn WHERE chỉ giữ lại những dòng mà điều kiện đúng. Vì vậy x = NULL không tìm thấy gì, NOT IN với một NULL trong danh sách không tìm thấy gì, và đặt NOT trước một điều kiện cũng không đẩy các dòng NULL sang phía bên kia.
Bộ công cụ thực tế rất ngắn:
- kiểm tra NULL bằng
IS NULL/IS NOT NULL, không bao giờ bằng=; - thay NULL bằng giá trị mặc định với
COALESCE; - biến một giá trị thành NULL với
NULLIF(chủ yếu để tránh chia cho 0); - nhớ rằng hàm tổng hợp bỏ qua NULL còn
COUNT(*)thì không; - trong JOIN, khóa NULL không bao giờ khớp, và
LEFT JOINtạo ra những NULL mới.
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 trong mùa hè 2026, 53.000 thành viên chương trình khách hàng thân thiết, 1,5 triệu sự kiện trong ứng dụng. 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.
NULL đến từ đâu
Bảng thật có NULL vì những lý do khác nhau, và lý do đó quyết định cách bạn xử lý chúng. Trong cơ sở dữ liệu này:
flights.actual_departurevàactual_arrivallà NULL khi sự kiện chưa xảy ra;loyalty_members.birth_yearlà NULL khi thành viên không khai báo;app_events.member_idlà NULL khi khách truy cập chưa đăng nhập;loyalty_tier_history.valid_tolà NULL với dòng hiện hành, dòng chưa có ngày kết thúc.
Đếm số giá trị khác NULL theo từng trạng thái cho thấy rõ trường hợp đầu tiên:
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 |
Các chuyến đã cất cánh (Departed) có giờ cất cánh nhưng chưa có giờ hạ cánh. Các chuyến theo lịch (Scheduled) không có cả hai. Còn tám chuyến bị hủy lại có ghi giờ, một điểm lạ của dữ liệu mà bạn chỉ thấy được khi đếm NULL.
Phép so sánh: vì sao = NULL không tìm thấy gì
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 |
Ngay cả NULL = NULL cũng không đúng: hai giá trị chưa biết thì không thể biết là chúng bằng nhau. Phép tính số học và toán tử || cũng truyền NULL đi tiếp; concat() là ngoại lệ và coi NULL như chuỗi rỗng.
Vì vậy truy vấn này trả về 0 dòng, không báo lỗi gì:
SELECT COUNT(*) FROM flights WHERE actual_departure = NULL;
| count_star() |
|---|
| 0 |
Trong khi truy vấn này tìm thấy 16.348 chuyến bay chưa cất cánh:
SELECT COUNT(*) FROM flights WHERE actual_departure IS NULL;
| count_star() |
|---|
| 16348 |
Logic ba giá trị: NOT không bắt được NULL
Một điều kiện trong SQL có thể đúng, sai hoặc không xác định. WHERE chỉ giữ lại dòng đúng. NOT của giá trị không xác định vẫn là không xác định, nên các dòng NULL bị loại ở cả hai phía:
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 |
Một điều kiện và phủ định của nó cộng lại được 46.681, không phải 53.000. Nếu bạn chia một bảng thành “khớp” và “mọi thứ còn lại” bằng WHERE cond và WHERE NOT cond, các dòng NULL sẽ không nằm ở nửa nào. Hãy thêm OR col IS NULL vào phía mà chúng thuộc về.
Điều tương tự xảy ra với <>. WHERE status <> 'Arrived' sẽ âm thầm bỏ qua những dòng có trạng thái NULL (bảng này không có, nhưng nhiều bảng khác có).
IS DISTINCT FROM: phép so sánh an toàn với NULL
Khi bạn muốn NULL được coi như một giá trị bình thường, để hai NULL được tính là bằng nhau, hãy dùng IS DISTINCT FROM và 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 |
Chúng luôn trả về đúng hoặc sai, không bao giờ trả về NULL. Chúng rất tiện cho việc kiểm tra “giá trị này có thay đổi không?” giữa hai bản chụp dữ liệu, khi việc chuyển từ NULL sang một giá trị nào đó phải được tính là thay đổi. PostgreSQL và DuckDB hỗ trợ chúng; MySQL dùng toán tử <=> thay thế.
NULL trong hàm tổng hợp
Mọi hàm tổng hợp trừ COUNT(*) đều bỏ qua NULL. Trên ba giá trị, trong đó có một 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 |
Giá trị trung bình là 4 / 2 = 2, không phải 4 / 3. COUNT(x) đếm số giá trị khác NULL, COUNT(*) đếm số dòng. Trên dữ liệu thật:
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 |
Phần lớn sự kiện đến từ khách truy cập chưa đăng nhập. COUNT(DISTINCT member_id) cũng bỏ qua NULL, nên nó trả về 33.624 thành viên thật, và khách ẩn danh không bị tính thành một “thành viên” thừa.
Bỏ qua NULL thường đúng là điều bạn muốn, còn thay chúng bằng 0 thường là sai. So sánh tuổi trung bình của thành viên khi có và không có 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 |
Cột thứ hai coi 6.319 thành viên không rõ tuổi như trẻ sơ sinh. Chỉ thay NULL bằng 0 khi NULL thật sự có nghĩa là 0, như “ngày này không có doanh số”.
SUM trên tập không có dòng nào là NULL, không phải 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 |
Không có chặng bay nào đắt đến thế, nên không có gì để cộng. COUNT trả về 0 khi không có dòng nào, nhưng SUM, AVG, MIN và MAX trả về NULL. Hãy bọc chúng trong COALESCE(..., 0) khi kết quả đi vào báo cáo hoặc một phép tính, để “không có doanh thu” hiển thị là 0 chứ không phải một ô trống.
COALESCE: giá trị khác NULL đầu tiên
COALESCE(a, b, c) trả về đối số đầu tiên khác NULL. Cách dùng điển hình là giá trị dự phòng: giờ cất cánh thực tế nếu đã cất cánh, nếu chưa thì giờ theo lịch.
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 |
Mọi đối số phải có kiểu dữ liệu tương thích. COALESCE(birth_year, 'unknown') trộn một số nguyên với văn bản: PostgreSQL từ chối nó, còn DuckDB báo lỗi “Could not convert string ‘unknown’ to INT32” ngay khi gặp một dòng NULL. Hãy ép kiểu trước:
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: biến một giá trị thành NULL
NULLIF(a, b) trả về NULL nếu a = b, ngược lại trả về a. Công việc nổi tiếng nhất của nó là bảo vệ phép chia: chia cho NULL cho ra NULL thay vì báo lỗi.
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 |
Không có NULLIF, PostgreSQL dừng cả truy vấn với lỗi “division by zero” chỉ vì hai chiếc máy bay nhỏ. (DuckDB không báo lỗi ở đây: không có NULLIF, nó hiển thị Infinity cho CR2 và CN1, giá trị này sau đó làm hỏng mọi phép tính trung bình hay tổng dựa trên nó. Vậy một truy vấn chạy được trên DuckDB vẫn có thể thất bại trên PostgreSQL, và cho ra những con số kỳ lạ ở cả hai. Hãy viết NULLIF.)
Công việc thứ hai: dọn dẹp những giá trị giả lẽ ra phải là NULL. Một số hệ thống lưu một giá trị giữ chỗ như 1900 hoặc 0 khi không biết giá trị thật. Bộ dữ liệu này có 148 thành viên sinh năm “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 |
Một hành khách thường xuyên 126 tuổi không phải là khách hàng thật. NULLIF biến giá trị giữ chỗ thành NULL, và AVG sau đó bỏ qua nó như mọi giá trị bị thiếu khác.
NULL trong GROUP BY và ORDER BY
GROUP BY là một nơi mà các NULL được coi là bằng nhau: mọi NULL tạo thành một nhóm duy nhất.
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 làm tròn xuống theo thập niên trong mọi hệ quản trị. Cách viết hiển nhiên hơn birth_year / 10 * 10 chạy được trong PostgreSQL, nơi phép chia số nguyên bỏ phần dư, nhưng không chạy đúng trong DuckDB, nơi / trả về số thập phân và bạn sẽ nhận lại 1946.)
Sắp xếp là chỗ PostgreSQL và DuckDB không thống nhất. PostgreSQL coi NULL lớn hơn mọi giá trị: NULL đứng cuối khi sắp xếp tăng dần và đứng đầu khi sắp xếp giảm dần. DuckDB mặc định đặt NULL ở cuối theo cả hai chiều. Vì vậy truy vấn này:
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 |
hiển thị thành viên 4 và 8 ở cuối trong DuckDB, như trên, nhưng ở đầu trong PostgreSQL. Một truy vấn “10 thành viên trẻ nhất” với LIMIT 10 sẽ trả về hai người không rõ tuổi trong PostgreSQL. Hãy viết rõ NULLS FIRST hoặc NULLS LAST mỗi khi cột có thể là NULL; cả hai hệ quản trị đều chấp nhận cú pháp này.
NULL trong JOIN
Khóa NULL không bao giờ khớp
Điều kiện JOIN là một phép so sánh, nên NULL ở bất kỳ phía nào cũng không bao giờ bằng thứ gì:
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 sự kiện ẩn danh không tìm thấy thành viên nào. Với INNER JOIN, chúng sẽ biến mất hoàn toàn khỏi kết quả, điều này có thể chấp nhận được với câu hỏi “số sự kiện mỗi thành viên” nhưng sai nghiêm trọng với “số sự kiện mỗi ngày”. Nếu bạn thật sự cần hai khóa NULL khớp nhau, hãy JOIN với ON a.key IS NOT DISTINCT FROM b.key, nhưng hãy cân nhắc kỹ: hai khách truy cập ẩn danh không phải là cùng một người.
LEFT JOIN tạo ra NULL
Khi một dòng bên trái không có dòng khớp, các cột bên phải được điền NULL. Đó là cách bạn tìm “bản ghi cha không có bản ghi con”, và cũng là lý do COUNT(*) sau LEFT JOIN có thể gây hiểu nhầm:
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 không có chuyến bay nào, nhưng LEFT JOIN vẫn tạo ra một dòng cho nó, với NULL ở phía chuyến bay. COUNT(*) đếm dòng đó và cho ra 1. COUNT(f.flight_id) đếm giá trị khác NULL và cho ra đúng 0. Sau LEFT JOIN, hãy đếm một cột của bảng bên phải.
Cái bẫy WHERE
Lọc bảng bên phải trong WHERE sẽ loại bỏ chính những dòng NULL mà LEFT JOIN đã tạo ra:
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 |
Tám mẫu máy bay, không phải chín. Với dòng của A320, f.status là NULL, NULL = 'Cancelled' là không xác định, và WHERE loại nó đi. Hãy chuyển điều kiện vào 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 |
Giờ bộ lọc quyết định chuyến bay nào được JOIN, và mẫu máy bay nào cũng được giữ lại. Bài JOIN trong SQL và bài về date spine cho thấy cùng cái bẫy này trong những tình huống khác.
Cái bẫy NOT IN
x NOT IN (a, b, c) nghĩa là x <> a AND x <> b AND x <> c. Nếu có phần tử nào là NULL, một trong các phép so sánh đó là không xác định, và cả điều kiện không bao giờ đúng được:
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 |
Với truy vấn con, NULL nằm khuất khỏi tầm mắt, nên còn tệ hơn. Những thành viên nào không có sự kiện nào trong ứng dụng?
SELECT COUNT(*) AS members_without_events
FROM loyalty_members
WHERE member_id NOT IN (SELECT member_id FROM app_events);
| members_without_events |
|---|
| 0 |
Truy vấn con trả về hàng trăm nghìn giá trị NULL (các sự kiện ẩn danh), nên câu trả lời là 0. NOT EXISTS so sánh từng dòng một và không bị ảnh hưởng:
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 |
Hãy dùng NOT EXISTS (hoặc anti join dạng LEFT JOIN ... WHERE right.key IS NULL) cho “những dòng không có dòng khớp”. Nếu vẫn giữ NOT IN, hãy thêm WHERE member_id IS NOT NULL bên trong truy vấn con. Xem thêm các mẫu truy vấn con trong bài truy vấn con trong SQL.
NULL trong CASE
Một nhánh CASE có điều kiện là NULL sẽ bị bỏ qua, nên các dòng NULL rơi xuống ELSE. CASE WHEN birth_year < 1980 THEN 'older' ELSE 'younger' END xếp mọi thành viên không rõ năm sinh vào nhóm “younger”. Hãy đặt WHEN birth_year IS NULL THEN 'unknown' lên đầu. Bài CASE WHEN trong SQL minh họa điều này bằng số liệu thật.
Danh sách kiểm tra
- Kiểm tra giá trị bị thiếu?
IS NULL/IS NOT NULL, không bao giờ= NULL. - Chia dòng bằng
condvàNOT cond? Hãy quyết định NULL thuộc về phía nào. - Đếm?
COUNT(*)để đếm dòng,COUNT(col)để đếm giá trị khác NULL. SauLEFT JOIN, hãy đếm một cột bên phải. - Tính trung bình? NULL bị bỏ qua. Đừng
COALESCEchúng thành 0 trừ khi NULL thật sự có nghĩa là 0. - Cộng tổng một thứ có thể không có dòng nào?
COALESCE(SUM(...), 0). - Chia?
NULLIF(mẫu_số, 0). - Sắp xếp một cột có thể chứa NULL? Viết rõ
NULLS FIRSThoặcNULLS LAST. LEFT JOINcó bộ lọc trên bảng bên phải? Đặt nó trongON.- Tìm dòng không có dòng khớp?
NOT EXISTS, không phảiNOT IN. - Có giá trị giữ chỗ như 1900, 0 hay ‘N/A’? Biến chúng thành NULL bằng
NULLIFtrước khi tổng hợp.
Câu hỏi thường gặp
Vì sao WHERE column = NULL không trả về dòng nào?
Vì so sánh bất cứ thứ gì với NULL đều cho ra NULL (không xác định), không phải đúng, và WHERE chỉ giữ lại những dòng mà điều kiện đúng. Hãy dùng WHERE column IS NULL.
COUNT(*) và COUNT(column) khác nhau thế nào?
COUNT(*) đếm số dòng. COUNT(column) đếm số dòng mà cột đó khác NULL. Trong app_events, hai con số chênh nhau 912.625: số sự kiện không gắn với thành viên nào.
COALESCE và NULLIF khác nhau thế nào?
Chúng ngược nhau. COALESCE(a, b) thay một NULL bằng một giá trị. NULLIF(a, b) thay một giá trị bằng NULL khi a bằng b, thường là NULLIF(x, 0) trước một phép chia.
NULL có bằng chuỗi rỗng không?
Không. Trong PostgreSQL và DuckDB, '' là một giá trị thật có độ dài bằng 0, và '' IS NULL là sai. Oracle là ngoại lệ: nó coi chuỗi rỗng là NULL.
NULL được sắp xếp thế nào trong SQL?
Tùy hệ quản trị. PostgreSQL xếp NULL ở cuối khi sắp xếp tăng dần và ở đầu khi sắp xếp giảm dần; DuckDB xếp chúng ở cuối theo cả hai chiều. Hãy viết NULLS FIRST hoặc NULLS LAST để có cùng kết quả ở mọi hệ hỗ trợ cú pháp này.
Ngôn ngữ khác: English, Español, Português (Brasil), 简体中文