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

Một ghế trống giữa hàng ghế đã có người ngồi trên máy bay

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:

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:

Đế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

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), 简体中文