SQL & Database — Thực chiến PostgreSQL/WHERE + NULL three-valued logic — vì sao age != 30 không trả NULL row
~22 phútTruy vấn cơ bản lượt xem

WHERE + NULL three-valued logic — vì sao age != 30 không trả NULL row

TRUE/FALSE/UNKNOWN. 5 NULL pitfall thực chiến: COUNT, IN/NOT IN, JOIN, string concat, ORM. Codd 1979 paper context.

Bạn query SELECT * FROM users WHERE age != 30 — không một user nào có age IS NULL được trả về. Không có error, không có warning. Chỉ im lặng. Bug? Hay đúng spec?

Codd 1979 mở rộng relational model để cover "missing data" — và spec chính thức nói NULL không phải value, là "không biết". Hệ quả: mọi so sánh với NULL không cho ra TRUE hay FALSE mà cho ra UNKNOWN — và WHERE chỉ pass row khi predicate là TRUE. Bài này giải thích three-valued logic, 5 NULL pitfall thực chiến (đặc biệt NOT IN, COUNT, JOIN), và defensive query pattern để không bao giờ bị bất ngờ nữa.

1. Analogy — NULL là "không biết", không phải "0"

Hình dung survey khảo sát nhân viên với câu hỏi "Bạn bao nhiêu tuổi?". Có ba loại phản hồi: điền số tuổi thật, điền số 0 (trả lời nhưng câu trả lời vô nghĩa), và bỏ trống (chưa trả lời — không biết). NULL tương ứng với trường hợp thứ ba: ô bỏ trống. Không phải 0. Không phải empty string. Là "dữ liệu còn thiếu, chưa biết".

Đời thựcSQL
Ô câu trả lời bỏ trốngNULL
Điền số 0age = 0 (value cụ thể)
Điền chuỗi rỗngname = '' (value cụ thể)
Điền "false"is_active = false (value cụ thể)
"Tôi không biết anh ấy bao nhiêu tuổi"Không thể so sánh — UNKNOWN
"Ô trống này có bằng ô trống kia không?"Không biết — UNKNOWN
Kiểm tra ô có bỏ trống khôngage IS NULL (TRUE/FALSE rõ ràng)
💡 Cách nhớ

NULL không phải giá trị — NULL là sự vắng mặt của giá trị. Mọi phép so sánh "giá trị nào đó" với "không biết" đều cho kết quả "không biết" (UNKNOWN). Dùng IS NULL / IS NOT NULL để kiểm tra sự vắng mặt — không dùng = NULL.

2. Three-valued logic — TRUE/FALSE/UNKNOWN

SQL không dùng logic hai giá trị (Boolean thông thường) mà dùng three-valued logic: TRUE, FALSE, và UNKNOWN. UNKNOWN xuất hiện bất cứ khi nào một toán hạng trong phép so sánh là NULL.

Bảng truth table — AND:

ANDTRUEFALSEUNKNOWN
TRUETRUEFALSEUNKNOWN
FALSEFALSEFALSEFALSE
UNKNOWNUNKNOWNFALSEUNKNOWN

Bảng truth table — OR:

ORTRUEFALSEUNKNOWN
TRUETRUETRUETRUE
FALSETRUEFALSEUNKNOWN
UNKNOWNTRUEUNKNOWNUNKNOWN

Bảng truth table — NOT:

InputNOT
TRUEFALSE
FALSETRUE
UNKNOWNUNKNOWN

Quy tắc cốt lõi: WHERE chỉ pass row khi predicate = TRUE. UNKNOWN bị treat như FALSE → NULL row bị loại.

Demo trên TaskFlow:

-- Row co assignee_id = NULL khong duoc tra ve
SELECT * FROM tasks WHERE assignee_id != 5;
-- Vi sao: NULL != 5 -> UNKNOWN -> WHERE loai row nay

-- Kiem tra: co bao nhieu task chua assigned?
SELECT COUNT(*) FROM tasks WHERE assignee_id IS NULL;
-- Dung IS NULL thay vi = NULL

3. Pitfall 1 — NULL = NULL không phải TRUE

Lỗi phổ biến nhất: dùng = NULL thay vì IS NULL.

-- WRONG: khong tra ve gi ca
SELECT col FROM t WHERE col = NULL;
-- Ly do: NULL = NULL -> UNKNOWN -> WHERE loai

-- RIGHT: IS NULL tra ve TRUE khi col la NULL
SELECT col FROM t WHERE col IS NULL;

-- Kiem tra nhanh trong psql
SELECT NULL = NULL;    -- NULL (UNKNOWN)
SELECT NULL IS NULL;   -- TRUE

SELECT NULL != NULL;   -- NULL (UNKNOWN)
SELECT NULL IS NOT NULL; -- FALSE

Kể cả NULL = NULL cũng không phải TRUE. Hai giá trị "không biết" không nhất thiết bằng nhau — vì cả hai đều không biết. Đây là lý do SQL dùng IS NULL thay vì = NULL để kiểm tra.

Pitfall — WHERE col = NULL luôn trả về 0 row

WHERE col = NULL không bao giờ trả về row dù col thực sự là NULL. Kết quả của NULL = NULL là UNKNOWN — không phải TRUE — nên WHERE loại hết. Luôn dùng WHERE col IS NULL hoặc WHERE col IS NOT NULL.

4. Pitfall 2 — NOT IN với NULL cho empty result

Đây là NULL pitfall nghiêm trọng nhất vì không có error — chỉ trả về 0 row mà không có warning.

-- Table users co 100 row, id tu 1 den 100
-- Tat ca id deu != 1, != 2, != NULL... hay khong?
SELECT * FROM users WHERE id NOT IN (1, 2, NULL);
-- Tra ve 0 row! Du table co 100 row.

Tại sao? NOT IN (1, 2, NULL) mở rộng thành:

WHERE id != 1 AND id != 2 AND id != NULL
-- id != NULL -> UNKNOWN
-- (TRUE) AND (TRUE) AND UNKNOWN -> UNKNOWN
-- WHERE loai row co UNKNOWN -> loai moi row

Mọi row đều bị loại vì id != NULL luôn là UNKNOWN, và AND UNKNOWN làm cả biểu thức thành UNKNOWN.

-- Fix 1: loc NULL ra khoi subquery truoc
SELECT * FROM users WHERE id NOT IN (1, 2);

-- Fix 2: dung NOT EXISTS thay NOT IN
SELECT * FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM (VALUES (1), (2), (NULL)) AS sub(v)
  WHERE sub.v = u.id
);
-- NOT EXISTS an toan voi NULL vi su dung = (khong IN)

-- Fix 3: filter NULL truoc khi subquery tra ve
SELECT * FROM users
WHERE id NOT IN (
  SELECT id FROM blacklist WHERE id IS NOT NULL
);
Pitfall — NOT IN với subquery chứa NULL row

Nếu subquery trong NOT IN (SELECT ...) có thể trả về NULL row (do column nullable hoặc LEFT JOIN), toàn bộ outer query trả về 0 row. Đây là bug âm thầm — không có error. Luôn thêm WHERE col IS NOT NULL trong subquery của NOT IN, hoặc dùng NOT EXISTS để an toàn hơn.

5. Pitfall 3 — COUNT(col) skip NULL, COUNT(*) đếm tất cả row

-- TaskFlow: dem task theo trang thai assignment
SELECT
  COUNT(*)                      AS total_rows,
  COUNT(assignee_id)            AS assigned_rows,
  COUNT(DISTINCT assignee_id)   AS unique_assignees
FROM tasks;

-- Ket qua vi du:
-- total_rows=500, assigned_rows=380, unique_assignees=12
-- 120 task co assignee_id = NULL -> COUNT(assignee_id) bo qua

Quy tắc:

  • COUNT(*) — đếm mọi row, kể cả row có NULL ở bất kỳ column nào.
  • COUNT(col) — chỉ đếm row có col IS NOT NULL.
  • COUNT(DISTINCT col) — đếm số distinct non-NULL value của col.
  • SUM, AVG, MIN, MAX — tất cả đều skip NULL tự động.
-- Vi du minh hoa su khac biet
SELECT
  AVG(score)                AS avg_with_null_skipped,
  SUM(score) / COUNT(*)     AS avg_counting_nulls_as_zero
FROM student_results;
-- Neu 3/10 row co score = NULL:
-- avg_with_null_skipped: tinh tren 7 row
-- avg_counting_nulls_as_zero: chia cho 10 -> ket qua thap hon

6. Pitfall 4 — string concat với NULL cho NULL

PostgreSQL: toán tử || với một toán hạng NULL làm cả expression thành NULL.

-- PostgreSQL: || operator
SELECT 'Hello, ' || NULL;       -- NULL (khong phai 'Hello, ')
SELECT 'Hello, ' || name        -- NULL neu name la NULL
FROM users WHERE name IS NOT NULL;  -- OK nhung phai loc truoc

-- Fix: COALESCE thay the NULL bang default value
SELECT 'Hello, ' || COALESCE(name, 'Friend') FROM users;
-- name = NULL -> 'Hello, Friend'
-- name = 'Alice' -> 'Hello, Alice'

So sánh với MySQL:

-- MySQL: CONCAT() co behavior giong PG -- tra NULL neu co NULL
SELECT CONCAT('Hello, ', NULL);          -- NULL

-- MySQL: CONCAT_WS() skip NULL thay vi cho NULL
SELECT CONCAT_WS(', ', 'Alice', NULL, 'Bob');  -- 'Alice, Bob'
-- CONCAT_WS = Concat With Separator, separator la arg dau tien

Khi migrate code MySQL sang PostgreSQL, mọi CONCAT_WS cần review. PostgreSQL có concat_ws() function (lowercase) với behavior tương tự, nhưng toán tử || thì không — phải bọc COALESCE thủ công.

7. Pitfall 5 — JOIN ON không match NULL

-- tasks.assignee_id = NULL khong match voi user nao
SELECT t.id, t.title, u.name
FROM tasks t
LEFT JOIN users u ON t.assignee_id = u.id;
-- Task co assignee_id = NULL: u.name = NULL (khong match)
-- Day la dung behavior voi LEFT JOIN -- nhung can biet

Với INNER JOIN, task có assignee_id = NULL bị loại hoàn toàn:

-- INNER JOIN: task voi assignee_id = NULL bi loai
SELECT t.id, t.title, u.name
FROM tasks t
INNER JOIN users u ON t.assignee_id = u.id;
-- Unassigned task khong xuat hien trong ket qua

Khi cần NULL-safe comparison — tức là coi NULL = NULL là TRUE:

-- IS NOT DISTINCT FROM: NULL-safe equality (PostgreSQL)
SELECT * FROM tasks t
LEFT JOIN users u ON t.assignee_id IS NOT DISTINCT FROM u.id;
-- NULL IS NOT DISTINCT FROM NULL -> TRUE -> match

-- IS DISTINCT FROM: nguoc lai cua IS NOT DISTINCT FROM
SELECT * FROM tasks WHERE assignee_id IS DISTINCT FROM 5;
-- Tra ve ca row co assignee_id != 5 VA assignee_id = NULL

IS NOT DISTINCT FROM là NULL-safe =. IS DISTINCT FROM là NULL-safe !=. Đây là PostgreSQL extension — không phải mọi database đều có.

8. Defensive query patterns

Bốn pattern thực tế để xử lý NULL an toàn:

-- Pattern 1: kiem tra NULL dung cach
WHERE col IS NULL
WHERE col IS NOT NULL

-- Pattern 2: include NULL row khi filter !=
-- Thay vi: WHERE assignee_id != 5
-- Dung:
WHERE assignee_id != 5 OR assignee_id IS NULL

-- Pattern 3: COALESCE cho default value trong predicate
WHERE COALESCE(assignee_id, -1) != 5
-- NULL duoc treat nhu -1, nen -1 != 5 -> TRUE -> pass

-- Pattern 4: IS NOT DISTINCT FROM cho NULL-safe comparison (PG)
WHERE col IS NOT DISTINCT FROM other_col
-- Equivalent = nhung an toan voi NULL

Khi nào dùng COALESCE vs IS NULL:

  • IS NULL — khi cần kiểm tra sự vắng mặt của value.
  • COALESCE(col, default) — khi muốn xử lý NULL như một default value cụ thể trong expression hoặc predicate.
  • IS NOT DISTINCT FROM — khi cần so sánh hai column cả hai đều có thể NULL (thường trong JOIN condition hoặc MERGE).

9. NULL trong constraint vs storage

NOT NULL constraint chặn NULL từ lúc insert — phòng ngừa tại nguồn:

-- Tao column voi NOT NULL constraint
ALTER TABLE tasks ADD COLUMN priority INT NOT NULL DEFAULT 3;
-- Insert thieu priority -> ERROR, khong insert duoc

DEFAULT value thay NULL bằng giá trị mặc định khi insert không cung cấp:

ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
-- Insert khong co status -> status = 'active' tu dong
-- Khong bao gio co NULL trong column nay

COALESCE trong query xử lý NULL ở runtime — không sửa data, chỉ thay thế khi đọc:

SELECT COALESCE(assignee_id, 0) AS assignee_or_default FROM tasks;
-- assignee_id = NULL -> tra ve 0
-- Data trong table van la NULL

Ba cách handle missing data theo thứ tự ưu tiên:

  1. NOT NULL constraint — tốt nhất, ngăn NULL vào database.
  2. DEFAULT value — thay NULL bằng giá trị có ý nghĩa khi insert.
  3. COALESCE trong query — fallback khi column đã nullable và không thể thay đổi schema.

10. Pitfall — ORM silent NULL bug

Prisma where: { age: { not: 30 } } mặc định không match NULL row — giống SQL. Nhiều developer expect ORM "thông minh hơn" nhưng ORM chỉ generate SQL chuẩn.

// KHONG tra ve user co age = null
const users = await prisma.user.findMany({
  where: { age: { not: 30 } }
});
// Generate: SELECT ... WHERE age != 30
// NULL row bi loai -- giong SQL

// Fix: explicit OR cho NULL
const users = await prisma.user.findMany({
  where: {
    OR: [
      { age: { not: 30 } },
      { age: null }
    ]
  }
});
// Generate: WHERE age != 30 OR age IS NULL
// Bao gom ca row co age = null
Pitfall — ORM không tự động include NULL row

Prisma, Spring Data JPA, SQLAlchemy đều generate SQL chuẩn — WHERE col != value không include NULL row. Đây không phải bug ORM — đây là SQL behavior. Cần explicit OR col IS NULL trong mọi trường hợp muốn include NULL row trong filter !=.

11. Applied — TaskFlow scenario

"List task của user khác user 5 — kể cả unassigned":

-- WRONG: bo qua unassigned task (assignee_id = NULL)
SELECT id, title, assignee_id
FROM tasks
WHERE assignee_id != 5;
-- NULL row bi loai vi NULL != 5 -> UNKNOWN -> WHERE loai

-- RIGHT: explicit NULL handling voi OR
SELECT id, title, assignee_id
FROM tasks
WHERE assignee_id != 5 OR assignee_id IS NULL;
-- Bao gom ca task chua assigned

-- ALT: COALESCE treat NULL nhu sentinel -1
SELECT id, title, assignee_id
FROM tasks
WHERE COALESCE(assignee_id, -1) != 5;
-- NULL -> COALESCE(NULL, -1) = -1 -> -1 != 5 -> TRUE -> pass
-- Luu y: chi dung -1 neu khong co user nao co id = -1

Kết quả khác nhau rõ ràng:

-- Kiem tra: bao nhieu task bi WRONG query bo qua
SELECT COUNT(*) FROM tasks WHERE assignee_id IS NULL;
-- Ket qua nay = so task bi mat trong WRONG query

12. Deep Dive — NULL semantics

📚 Deep Dive — NULL semantics

Ghi chú: PG docs cho rules cụ thể để implement; Modern SQL cho cross-vendor tutorial-style dễ tiếp cận; Codd 1979 cho lịch sử và lý do thiết kế ban đầu.

13. Tóm tắt

  • NULL không phải value — là sự vắng mặt của value. Không phải 0, không phải empty string, không phải false.
  • Three-valued logic: mọi comparison với NULL cho kết quả UNKNOWN. WHERE chỉ pass row khi predicate = TRUE — UNKNOWN bị treat như FALSE.
  • 5 pitfall thực chiến: = NULL (dùng IS NULL), NOT IN với NULL (dùng NOT EXISTS hoặc filter NULL), COUNT(col) skip NULL (COUNT(*) đếm hết), string || NULL cho NULL (dùng COALESCE), JOIN không match NULL (dùng IS NOT DISTINCT FROM nếu cần).
  • Defensive pattern: WHERE col IS NULL/IS NOT NULL, WHERE col != 5 OR col IS NULL, COALESCE(col, default) trong predicate, IS NOT DISTINCT FROM cho NULL-safe equality.
  • ORM không tự động include NULL row trong != value filter — cần explicit OR col IS NULL.
  • NOT NULL constraint là cách tốt nhất ngăn NULL vào database; DEFAULT thay NULL khi insert; COALESCE xử lý NULL khi đọc.
  • Forward link: Module 3 của khoá này phân tích NULL trap trong JOIN phức tạp hơn — OUTER JOIN và NULL propagation qua nhiều bảng.

14. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao WHERE age != 30 bỏ qua row có age = NULL? Cơ chế logic cụ thể là gì?

SQL dùng three-valued logic: TRUE, FALSE, và UNKNOWN. Khi một toán hạng trong phép so sánh là NULL, kết quả luôn là UNKNOWN — không phải TRUE hay FALSE. Cụ thể: NULL != 30 cho ra UNKNOWN vì NULL là "không biết" — không thể xác định "không biết" có khác 30 hay không.

WHERE chỉ pass row vào kết quả khi predicate = TRUE. UNKNOWN bị treat như FALSE — row bị loại. Vì vậy, row có age = NULL không bao giờ được trả về bởi bất kỳ predicate kiểu age != value hay age = value. Để include NULL row, phải thêm explicit: WHERE age != 30 OR age IS NULL.

Q2
Phân biệt COUNT(*) vs COUNT(col). Trong TaskFlow: COUNT(assignee_id) đo gì?

COUNT(*) đếm tất cả row trong result set, kể cả row có NULL ở mọi column. Đây là cách đếm tổng số row.

COUNT(col) chỉ đếm row có col IS NOT NULL. NULL value bị skip hoàn toàn.

Trong TaskFlow: COUNT(assignee_id) đo số task đã được assigned — tức là số task có assignee_id IS NOT NULL. Task chưa assigned (assignee_id = NULL) không được đếm. Hiệu năng: COUNT(*) - COUNT(assignee_id) = số task chưa assigned.

Tương tự với aggregate functions khác: SUM, AVG, MIN, MAX đều skip NULL tự động — chỉ tính trên non-NULL value.

Q3
Bạn query WHERE id NOT IN (1, 2, NULL) trả 0 row dù table 100 row. Vì sao? Hai cách fix?

NOT IN (1, 2, NULL) mở rộng thành id != 1 AND id != 2 AND id != NULL. Toán hạng id != NULL luôn là UNKNOWN (vì NULL là "không biết"). Kết hợp AND UNKNOWN làm cả biểu thức thành UNKNOWN cho mọi row — dù id != 1id != 2 đều TRUE. WHERE loại hết row có UNKNOWN → 0 row được trả về.

Fix 1 — loại NULL ra khỏi list:

SELECT * FROM users WHERE id NOT IN (1, 2);

Fix 2 — dùng NOT EXISTS (an toàn hơn khi subquery nullable):

SELECT * FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM blacklist b
WHERE b.id = u.id
  AND b.id IS NOT NULL
);
Q4
COALESCE(col, default) vs IS NULL trong predicate — khi nào dùng cái nào?

col IS NULL — dùng khi cần kiểm tra sự vắng mặt của value. Trả về TRUE hoặc FALSE, không bao giờ UNKNOWN. Dùng để lọc tìm row NULL hoặc non-NULL.

WHERE assignee_id IS NULL       -- tim task chua assigned
WHERE assignee_id IS NOT NULL   -- tim task da assigned

COALESCE(col, default) — dùng khi muốn thay thế NULL bằng một giá trị cụ thể để tham gia vào expression hoặc predicate. NULL trở thành default, có thể so sánh bình thường.

WHERE COALESCE(assignee_id, -1) != 5
-- NULL duoc treat nhu -1 -> -1 != 5 -> TRUE -> pass
-- Bao gom ca row NULL

Rule chung: IS NULL để kiểm tra sự vắng mặt. COALESCE để tham gia vào phép tính hoặc so sánh mà muốn NULL có behavior cụ thể.

Q5
Pattern 'Hello, ' || name cho name = NULL trả NULL. Khi migrate code MySQL sang PostgreSQL dùng CONCAT_WS, cần check những gì?

Trong MySQL, CONCAT_WS(sep, a, b, c) skip NULL argument — ví dụ CONCAT_WS(', ', 'Alice', NULL, 'Bob') cho 'Alice, Bob' (NULL bị bỏ qua). PostgreSQL toán tử || không skip — nếu bất kỳ toán hạng nào là NULL thì cả expression là NULL.

Khi migrate MySQL → PostgreSQL cần check:

  • Mọi chỗ dùng CONCAT_WS trong MySQL có thể có nullable argument — chuyển sang concat_ws() function trong PG (lowercase, behavior tương tự, skip NULL).
  • Mọi chỗ dùng CONCAT(a, b) trong MySQL — CONCAT(a, NULL) = NULL ở cả hai; behavior giống nhau, không cần đổi.
  • Mọi chỗ dùng toán tử || trong PG code mới — bọc nullable column trong COALESCE(col, '') nếu không muốn NULL propagate.
  • Kiểm tra output có bị NULL ở column text built từ concat — so sánh kết quả trước và sau migrate.
Q6
Phân biệt NOT NULL constraint, DEFAULT value, và COALESCE trong query — ba cách handle missing data khác nhau như thế nào?

NOT NULL constraint — phòng ngừa tại nguồn, thời điểm insert/update. Database từ chối lưu NULL vào column. Tốt nhất cho column luôn phải có giá trị (primary key, email, status). Không giúp ích gì nếu data đã có NULL từ trước khi thêm constraint.

ALTER TABLE tasks ADD COLUMN priority INT NOT NULL DEFAULT 3;

DEFAULT value — tự động điền giá trị khi INSERT không cung cấp column đó. Kết hợp với NOT NULL để đảm bảo column luôn có giá trị có nghĩa. Data trong database không bao giờ NULL nếu dùng cả hai.

ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active';

COALESCE trong query — xử lý NULL ở runtime khi đọc, không sửa data gốc. Dùng khi column đã nullable và không thể hoặc không muốn thay đổi schema. Chỉ thay thế trong kết quả query — database vẫn lưu NULL.

SELECT COALESCE(assignee_id, 0) AS assignee_or_default FROM tasks;

Thứ tự ưu tiên: NOT NULL constraint > DEFAULT value > COALESCE. Dùng constraint và default để tránh NULL từ đầu; dùng COALESCE như fallback cho legacy column hoặc optional field.

Bài tiếp theo: ORDER BY + pagination — vì sao OFFSET 100k chậm 200x

Bài này có giúp bạn hiểu bản chất không?