Subquery, CTE, LATERAL — 3 cách viết cùng một logic
Subquery (scalar/correlated/IN/EXISTS), CTE (WITH), và LATERAL — mỗi pattern có ngữ cảnh phù hợp riêng. Hiểu khi nào dùng cái nào là bước nhảy từ 'biết SQL' sang 'viết SQL có chủ đích'.
Cùng một logic truy vấn, bạn có thể viết theo 3 cách khác nhau — subquery inline, CTE có tên, hoặc LATERAL. Cả 3 cho ra kết quả giống nhau. Nhưng chúng khác nhau về readability, optimizer behavior, và reuse ability.
Đây là nơi nhiều developer dừng lại ở "nó chạy được là được". Bài này đẩy bạn thêm một bước: hiểu tại sao mỗi pattern tồn tại, khi nào planner xử lý chúng khác nhau, và làm thế nào để chọn đúng từ đầu thay vì thử mọi cách rồi đoán.
1. Analogy — 3 cách chuẩn bị nguyên liệu khi nấu ăn
Hãy hình dung bạn đang nấu một bữa với nhiều món, và cần lấy nguyên liệu ở các thời điểm khác nhau.
| Cách nấu | SQL Pattern |
|---|---|
| Grab inline khi cần — lấy gia vị thẳng từ tủ mỗi lần dùng | Subquery inline (scalar / IN / EXISTS) |
| Chuẩn bị nguyên liệu vào bowl riêng, dán nhãn, dùng nhiều lần | CTE — WITH name AS (...) |
| "Với mỗi món chính, lấy 3 topping đi kèm" — row-by-row fetch | LATERAL — query reference outer row |
| Nhiều tầng chuẩn bị: nguyên liệu A dùng nguyên liệu B đã chuẩn | CTE chain — nhiều CTE tham chiếu nhau |
| Tự làm lại một loại nước sốt liên tục cho đến khi đủ | WITH RECURSIVE — bài 2 Module 8 này |
Subquery = grab inline khi cần. CTE = bowl có nhãn, tái dùng được. LATERAL = "với mỗi outer row, chạy subquery này" — giống người đầu bếp lấy topping riêng cho từng đĩa. Chọn pattern nào phụ thuộc vào: có cần reuse không, có reference outer row không, logic có cần tên để đọc không.
2. Subquery — scalar, correlated, IN, EXISTS
Subquery là SELECT nằm bên trong một câu SQL khác. Có 4 dạng dùng phổ biến:
-- Scalar subquery: tra ve dung 1 column 1 row, dung trong SELECT clause
SELECT
u.name,
(SELECT count(*) FROM tasks t WHERE t.assignee_id = u.id) AS task_count
FROM users u;
-- Correlated subquery trong WHERE voi EXISTS
-- EXISTS tra ve TRUE neu subquery tra ve bat ky row nao
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM tasks t
WHERE t.assignee_id = u.id AND t.status = 'doing'
);
-- IN: filter membership -- project_id phai nam trong tap ket qua
SELECT * FROM tasks WHERE project_id IN (
SELECT id FROM projects WHERE owner_id = 5
);
-- NOT EXISTS: filter exclusion -- user chua co task nao
SELECT * FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM tasks t WHERE t.assignee_id = u.id
);
Planner thường rewrite thành JOIN: PostgreSQL optimizer thông minh — IN và EXISTS subquery thường được unnest thành semi-join, correlated subquery thường được rewrite thành hash join hoặc merge join. EXPLAIN cho thấy plan có thể giống nhau dù viết khác nhau.
-- Hai cach viet nay thuong co cung plan sau khi planner rewrite:
-- Cach 1: IN subquery
SELECT * FROM tasks WHERE project_id IN (SELECT id FROM projects WHERE owner_id = 5);
-- Cach 2: JOIN
SELECT t.* FROM tasks t JOIN projects p ON t.project_id = p.id WHERE p.owner_id = 5;
-- EXPLAIN ca hai -> thường thay Hash Semi Join hoac Merge Semi Join
-- Planner tu quyet dinh; khong can manual rewrite de "giup" planner
3. CTE — WITH name AS (...)
CTE (Common Table Expression) là subquery có tên, định nghĩa trước phần SELECT chính. Sức mạnh cốt lõi: đặt tên cho logic phức tạp và reference cùng tên nhiều lần.
-- CTE co ban: dat ten cho filter logic
WITH active_projects AS (
SELECT id, name, owner_id
FROM projects
WHERE archived_at IS NULL
)
SELECT ap.name, count(t.id) AS task_count
FROM active_projects ap
LEFT JOIN tasks t ON t.project_id = ap.id
GROUP BY ap.id, ap.name;
Reuse cùng CTE nhiều lần — đây là lý do chính để chọn CTE thay subquery inline:
-- Reuse recent_tasks 2 lan trong cung query
WITH recent_tasks AS (
SELECT * FROM tasks WHERE created_at > now() - interval '7 days'
)
SELECT 'created' AS metric, count(*) FROM recent_tasks
UNION ALL
SELECT 'completed', count(*) FROM recent_tasks WHERE status = 'done';
-- Khong can viet lai filter "created_at > now() - interval '7 days'" 2 lan
CTE chain — CTE sau có thể reference CTE trước:
WITH
active_projects AS (
SELECT id, name FROM projects WHERE archived_at IS NULL
),
project_task_counts AS (
SELECT ap.id, ap.name, count(t.id) AS task_count
FROM active_projects ap
LEFT JOIN tasks t ON t.project_id = ap.id
GROUP BY ap.id, ap.name
)
SELECT * FROM project_task_counts WHERE task_count > 10;
4. PG 12 CTE inlining — thay đổi quan trọng cần biết
Đây là điểm dễ gây bug ngầm nhất khi migration từ database cũ hoặc khi đọc tài liệu cũ:
Trước PG 12: CTE luôn materialize — PostgreSQL tính toán toàn bộ CTE xong, lưu kết quả vào temp buffer, rồi mới dùng. CTE là optimization barrier: planner không thể đẩy predicate từ query ngoài vào bên trong CTE.
PG 12+: CTE mặc định inline (treat như subquery). Planner có thể đẩy predicate vào bên trong, optimize aggressive như với subquery thường.
-- PG 12+: CTE mac dinh inline (NOT MATERIALIZED la default moi)
-- Planner co the push WHERE assignee_id=5 vao ben trong CTE
WITH active_tasks AS (
SELECT * FROM tasks WHERE status != 'archived'
)
SELECT * FROM active_tasks WHERE assignee_id = 5;
-- Plan: co the la Index Scan tren (assignee_id) vi planner inline CTE
-- va push ca hai predicate vao cung node scan
-- Force MATERIALIZED: tinh 1 lan, cache, reuse nhieu lan
-- Dung khi: CTE dat + reference nhieu lan + KHONG muon planner re-compute
WITH expensive_calc AS MATERIALIZED (
SELECT project_id, sum(estimated_hours) AS total_hours
FROM tasks
WHERE status != 'archived'
GROUP BY project_id -- query nang, nen tinh 1 lan
)
SELECT * FROM expensive_calc WHERE total_hours > 100
UNION ALL
SELECT * FROM expensive_calc WHERE total_hours < 10;
-- MATERIALIZED dam bao tinh 1 lan du reference 2 lan
-- Force NOT MATERIALIZED: explicit inline (redundant voi PG 12+ default,
-- nhung huu ich de document intent hoac khi migrate tu PG <12)
WITH cheap_filter AS NOT MATERIALIZED (
SELECT * FROM tasks WHERE status != 'archived'
)
SELECT * FROM cheap_filter WHERE assignee_id = 5;
-- Planner inline -> push WHERE assignee_id=5 -> Index Scan
| Behavior | PG 11 trở về | PG 12+ |
|---|---|---|
| CTE mặc định | MATERIALIZED (forced) | NOT MATERIALIZED (inline) |
| Planner push predicate vào CTE | Không thể | Có thể |
AS MATERIALIZED | Không hỗ trợ | Hỗ trợ |
AS NOT MATERIALIZED | Không hỗ trợ | Hỗ trợ |
5. LATERAL — "với mỗi outer row, chạy subquery này"
LATERAL cho phép subquery trong FROM clause reference column từ bảng đứng trước nó trong cùng FROM. Không có LATERAL, subquery trong FROM phải hoàn toàn độc lập.
-- Top 3 task moi nhat cua moi project
-- LATERAL: "voi moi project p, chay subquery nay (co dung p.id)"
SELECT p.name AS project_name, t.title, t.created_at
FROM projects p,
LATERAL (
SELECT title, created_at
FROM tasks
WHERE project_id = p.id -- reference p.id tu outer
ORDER BY created_at DESC
LIMIT 3
) t;
-- Chay 1 lan per project row, voi LIMIT 3 ap dung rieng cho tung project
LEFT JOIN LATERAL — giữ project dù không có task:
SELECT p.name, t.title
FROM projects p
LEFT JOIN LATERAL (
SELECT title FROM tasks
WHERE project_id = p.id
ORDER BY created_at DESC LIMIT 3
) t ON true;
-- "ON true": khong co dieu kien join them, chi giu row khi subquery empty
-- LEFT JOIN dam bao project khong co task van xuat hien (title = NULL)
LATERAL chạy theo cơ chế Nested Loop: với mỗi outer row, chạy inner subquery 1 lần. Giống Nested Loop Join mà bài 3 Module 7 của khoá này đã giải thích. Index trên (project_id, created_at DESC) làm inner subquery rất nhanh — O(log N) per project thay vì scan toàn bảng.
6. Bảng decision — khi nào dùng pattern nào
| Cần | Pattern phù hợp |
|---|---|
| Reuse cùng logic 2–3 lần trong query | CTE |
| "Với mỗi outer row, chạy query riêng" (top-N per group) | LATERAL |
| Filter membership đơn giản | IN / EXISTS subquery |
| Tính 1 giá trị đơn lẻ trong SELECT | Scalar subquery |
| Logic đặt tên để dễ đọc, dù chỉ dùng 1 lần | CTE |
| Tree / hierarchy / recursive | WITH RECURSIVE (bài 2 Module 8 này) |
| CTE expensive + reuse nhiều lần + PG 12+ | CTE AS MATERIALIZED |
7. Top N per group — 3 cách viết cùng logic
"Top 3 task mới nhất per project" là bài toán kinh điển. Đây là 3 cách viết phổ biến:
-- Cach 1: Window function (Module 3 cua khoa nay)
SELECT * FROM (
SELECT
t.*,
ROW_NUMBER() OVER (
PARTITION BY project_id ORDER BY created_at DESC
) AS rn
FROM tasks t
) sub
WHERE rn <= 3;
-- Planner: compute ROW_NUMBER tren toan bo tasks truoc khi filter
-- Phu hop khi can ca cot rn cho logic sau (e.g. danh so thu tu)
-- Cach 2: LATERAL (ngan nhat, doc tu nhien nhat)
SELECT p.id AS project_id, t.*
FROM projects p,
LATERAL (
SELECT * FROM tasks
WHERE project_id = p.id
ORDER BY created_at DESC LIMIT 3
) t;
-- Planner: Nested Loop, Index Scan tren (project_id, created_at) per project
-- ~1ms cho 1000 project neu co composite index
-- Cach 3: Correlated subquery (verbose, planner co the khong optimize tot)
SELECT t.* FROM tasks t
WHERE t.id IN (
SELECT id FROM tasks t2
WHERE t2.project_id = t.project_id
ORDER BY created_at DESC LIMIT 3
);
-- CANH BAO: correlated subquery voi LIMIT trong IN khong luon work dung
-- tuy SQL dialect, va planner kho optimize -- tranh dung pattern nay
LATERAL thường thắng về readability và planner behavior cho top-N-per-group: ngắn hơn window function, planner chọn Nested Loop với index trên (project_id, created_at DESC) — O(k log N) với k = số project, thay vì scan toàn bộ bảng.
8. Pitfall — scalar nhiều row, correlated N+1, CTE trước PG 12
Pitfall 1 — Scalar subquery trả về nhiều hơn 1 row: Scalar subquery trong SELECT clause phải trả về đúng 1 column và nhiều nhất 1 row. Vi phạm điều kiện này → runtime error ngay lập tức.
-- LOI: subquery co the tra ve nhieu row
SELECT (SELECT id FROM tasks WHERE assignee_id = u.id) FROM users u;
-- ERROR: more than one row returned by a subquery used as an expression
-- Fix 1: them LIMIT 1 (neu chi can 1 gia tri bat ky)
SELECT (SELECT id FROM tasks WHERE assignee_id = u.id LIMIT 1) FROM users u;
-- Fix 2: dung LATERAL neu can nhieu column hoac co ORDER BY
SELECT u.name, t.id AS latest_task_id
FROM users u
LEFT JOIN LATERAL (
SELECT id FROM tasks WHERE assignee_id = u.id
ORDER BY created_at DESC LIMIT 1
) t ON true;
Pitfall 2 — Correlated subquery trong SELECT clause = N+1: Mỗi row từ outer query trigger 1 lần chạy subquery. PG có thể unnest thành join nhưng không đảm bảo với mọi trường hợp.
-- Co the la N+1 neu planner khong unnest:
SELECT u.name,
(SELECT count(*) FROM tasks WHERE assignee_id = u.id) AS task_count
FROM users u;
-- Tot hon: explicit GROUP BY join -- planner luon chon HashAggregate
SELECT u.name, coalesce(tc.task_count, 0) AS task_count
FROM users u
LEFT JOIN (
SELECT assignee_id, count(*) AS task_count
FROM tasks GROUP BY assignee_id
) tc ON tc.assignee_id = u.id;
Pitfall 3 — Migration từ Oracle / SQL Server / PG cũ hơn 12: Oracle và SQL Server có CTE inline theo mặc định từ lâu. PG trước 12 force materialize — CTE là optimization barrier. Code chạy tốt trên Oracle có thể chậm trên PG 11 vì CTE block planner. Khi migrate: kiểm tra phiên bản PG, và nếu PG 11 trở về, cân nhắc rewrite CTE thành subquery để planner optimize được.
9. Applied — TaskFlow dashboard: CTE + LATERAL kết hợp
Bài toán thực tế: dashboard hiển thị tất cả active project, mỗi project kèm tổng số task và 3 task gần đây nhất.
-- Dashboard: active projects + task count + 3 task gan nhat moi project
WITH active_projects AS (
-- CTE: filter 1 lan, reuse 2 lan (trong LATERAL va outer SELECT)
SELECT id, name, owner_id
FROM projects
WHERE archived_at IS NULL
)
SELECT
ap.name AS project_name,
ap.owner_id,
recent.task_count,
recent.recent_titles
FROM active_projects ap,
LATERAL (
-- "Voi moi active project, tinh count va lay 3 title gan nhat"
SELECT
(SELECT count(*) FROM tasks WHERE project_id = ap.id) AS task_count,
array_agg(t.title ORDER BY t.created_at DESC) AS recent_titles
FROM (
SELECT title, created_at
FROM tasks
WHERE project_id = ap.id
ORDER BY created_at DESC LIMIT 3
) t
) recent;
Pattern này kết hợp cả hai điểm mạnh:
- CTE
active_projects: filter chung viết 1 lần, đọc rõ ý định, planner inline (PG 12+) → không tốn chi phí thêm. - LATERAL: "với mỗi active project, pull top 3 task" — thể hiện quan hệ row-by-row rõ ràng hơn subquery correlated.
- Index đề xuất:
(project_id, created_at DESC)trên bảngtasks— cho phép LATERAL inner query dùng Index Scan + LIMIT early termination.
10. Deep Dive
- PostgreSQL Documentation Ch.7.8 "WITH Queries (Common Table Expressions)" — official reference đầy đủ: CTE syntax, MATERIALIZED / NOT MATERIALIZED, WITH RECURSIVE, và data-modifying CTE (INSERT/UPDATE/DELETE trong CTE). Đọc section "Common Table Expressions" và "Materialization of CTEs" để nắm rõ PG 12 behavior change.
- Markus Winand — "Lateral Joins" (modern-sql.com) — giải thích LATERAL với visual + ví dụ SQL chuẩn. Markus Winand là tác giả "Use The Index, Luke" — cách viết của ông rất clear cho practitioner, không phải academic. Đọc để nắm intuition của LATERAL trước khi đọc PG docs.
- PostgreSQL 12 Release Notes — CTE Inlining — tìm "WITH queries" trong trang: ghi chép chính thức về thay đổi default behavior từ PG 12. Quan trọng nếu bạn maintain code chạy trên nhiều PG version hoặc đang migrate từ PG 11 trở về.
11. Tóm tắt
- Subquery (scalar / correlated / IN / EXISTS) là subquery inline — dùng khi logic đơn giản, không cần reuse, không cần reference outer row. Planner thường rewrite IN / EXISTS thành semi-join.
- Scalar subquery trong SELECT phải trả về đúng 1 column, tối đa 1 row — vi phạm gây runtime error "more than one row returned".
- CTE (
WITH name AS (...)) đặt tên cho logic phức tạp, cho phép reuse cùng tên nhiều lần trong query. Đọc dễ hơn subquery lồng nhau. - PG 12+ thay đổi quan trọng: CTE mặc định NOT MATERIALIZED (inline) — planner optimize aggressive. Trước PG 12: CTE luôn MATERIALIZED = optimization barrier.
AS MATERIALIZEDforce PG tính CTE 1 lần, cache kết quả — dùng khi CTE expensive và reference nhiều lần.AS NOT MATERIALIZEDexplicit inline (redundant với PG 12+ default, nhưng tốt để document intent).- LATERAL cho phép subquery trong FROM reference column từ bảng đứng trước. Chạy 1 lần per outer row (Nested Loop). Pattern tốt nhất cho "top N per group" — ngắn hơn window function, planner thường chọn Index Scan với LIMIT early termination.
LEFT JOIN LATERAL ... ON truegiữ outer row dù inner subquery không trả row nào.- Bảng decision: reuse 2+ lần → CTE; top-N per group → LATERAL; filter membership → IN/EXISTS; 1 giá trị đơn → scalar subquery; recursive → WITH RECURSIVE (bài 2 Module 8 này).
12. Tự kiểm tra
Q1Vì sao scalar subquery trong SELECT clause bị lỗi 'more than one row returned'? Điều kiện chính xác mà scalar subquery phải thỏa là gì? Viết 2 cách fix.▸
Scalar subquery trong SELECT clause phải trả về đúng 1 column và tối đa 1 row. PostgreSQL evaluate subquery đó per outer row và cần bind kết quả vào 1 giá trị đơn. Nếu subquery trả 2+ row, PG không biết chọn row nào → runtime error.
Fix 1 — thêm LIMIT 1: SELECT (SELECT id FROM tasks WHERE assignee_id = u.id LIMIT 1) FROM users u — đơn giản nhưng kết quả phụ thuộc thứ tự không xác định nếu không có ORDER BY.
Fix 2 — dùng LEFT JOIN LATERAL: đưa subquery ra LATERAL với LIMIT 1 ORDER BY created_at DESC — kiểm soát được row nào được chọn và có thể lấy nhiều column từ cùng subquery.
Q2PostgreSQL 12 thay đổi behavior mặc định của CTE như thế nào? Điều này ảnh hưởng gì đến code viết trên PG 11 nay chạy trên PG 14? Cho ví dụ tình huống có thể trở nên nhanh hơn và tình huống cần dùng AS MATERIALIZED.▸
Trước PG 12, CTE luôn MATERIALIZED — PG tính toán toàn bộ CTE trước, lưu vào temp buffer, rồi mới join/filter với query ngoài. Planner không thể đẩy predicate từ ngoài vào bên trong CTE → optimization barrier.
PG 12+ đổi mặc định thành NOT MATERIALIZED (inline) — planner treat CTE như subquery thường và có thể push predicate vào, chọn index phù hợp hơn.
Tình huống nhanh hơn sau upgrade: CTE có filter lớn nhưng query ngoài chỉ cần một subset nhỏ. Trên PG 11: CTE tính toàn bộ, PG 14 inline và push predicate → ít row hơn, Index Scan thay Seq Scan.
Khi cần AS MATERIALIZED: CTE expensive (GROUP BY phức tạp, nhiều join) được reference 2+ lần. Nếu inline, planner có thể re-compute mỗi lần reference → chậm hơn. AS MATERIALIZED đảm bảo tính 1 lần, cache dùng cho cả 2 reference.
Q3Giải thích tại sao LATERAL phù hợp hơn window function cho bài toán 'top 3 task mới nhất per project' về mặt planner execution. Index nào giúp LATERAL query nhanh nhất?▸
Window function (ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY created_at DESC)) phải compute ROW_NUMBER trên toàn bộ bảng tasks trước, sau đó mới filter rn <= 3. Với bảng lớn, đây là full scan.
LATERAL chạy theo Nested Loop: với mỗi project, execute inner query WHERE project_id = p.id ORDER BY created_at DESC LIMIT 3. Với index composite (project_id, created_at DESC), inner query dùng Index Scan + LIMIT early termination — chỉ đọc 3 entry per project, không scan toàn bảng.
Index phù hợp: CREATE INDEX ON tasks(project_id, created_at DESC) — leftmost column là project_id cho equality filter, created_at DESC cho sort tương thích với ORDER BY ... DESC LIMIT 3. Planner có thể dùng Index Scan với LIMIT và terminate sớm sau 3 row, không cần fetch tất cả task của project.
Q4Khi nào nên dùng `LEFT JOIN LATERAL ... ON true` thay vì comma LATERAL (implicit CROSS JOIN LATERAL)? Sự khác biệt kết quả là gì?▸
Comma LATERAL (FROM projects p, LATERAL (...) t) tương đương CROSS JOIN LATERAL: nếu inner subquery không trả row nào cho project đó (ví dụ project không có task nào), outer row (project) bị loại khỏi kết quả — giống INNER JOIN behavior.
LEFT JOIN LATERAL ... ON true: giữ outer row (project) ngay cả khi inner subquery empty. Column từ LATERAL sẽ là NULL cho project không có task — giống LEFT JOIN behavior.
Chọn: dùng comma LATERAL khi chắc chắn outer row luôn có kết quả inner (ví dụ mỗi project đều có task). Dùng LEFT JOIN LATERAL ON true khi cần giữ outer row dù inner rỗng — dashboard hiển thị project dù chưa có task nào.
Q5Bạn có một query dùng CTE reference cùng tên 3 lần. Sau khi upgrade lên PG 14, bạn chạy EXPLAIN ANALYZE và thấy CTE được tính 3 lần thay vì 1 lần. Điều này xảy ra vì sao và fix như thế nào?▸
PG 14 inline CTE theo mặc định (NOT MATERIALIZED) — planner treat CTE như subquery và có thể re-expand (re-compute) mỗi lần reference nếu inline mang lại plan tốt hơn. Với 3 lần reference, CTE có thể bị tính 3 lần nếu planner quyết định inline từng lần.
Trên PG 11: CTE luôn MATERIALIZED → tính 1 lần, cache. Trên PG 14: inline mặc định phá vỡ behavior đó nếu CTE expensive.
Fix: thêm AS MATERIALIZED explicit:
WITH expensive_cte AS MATERIALIZED (
SELECT ... FROM ... -- query nang
)
SELECT ... FROM expensive_cte -- reference 1
UNION ALL SELECT ... FROM expensive_cte -- reference 2
UNION ALL SELECT ... FROM expensive_cte; -- reference 3
-- AS MATERIALIZED: tinh 1 lan, cache, dung cho ca 3 referenceVerify bằng EXPLAIN ANALYZE: tìm "CTE Scan" node — nếu thấy 1 "CTE expensive_cte" node và 3 "CTE Scan on expensive_cte" node, MATERIALIZED đang hoạt động đúng.
Q6Viết query TaskFlow: 'Danh sách user có ít nhất 1 task đang doing trong project active'. Viết bằng EXISTS subquery, sau đó bằng IN subquery. Hai cách này có plan khác nhau không? Khi nào EXISTS tốt hơn IN?▸
Bằng EXISTS:
SELECT DISTINCT u.id, u.name
FROM users u
WHERE EXISTS (
SELECT 1 FROM tasks t
JOIN projects p ON t.project_id = p.id
WHERE t.assignee_id = u.id
AND t.status = 'doing'
AND p.archived_at IS NULL
);Bằng IN:
SELECT u.id, u.name
FROM users u
WHERE u.id IN (
SELECT t.assignee_id FROM tasks t
JOIN projects p ON t.project_id = p.id
WHERE t.status = 'doing' AND p.archived_at IS NULL
);Plan có khác nhau không? Thường không — PG planner rewrite cả hai thành semi-join (Hash Semi Join hoặc Merge Semi Join). EXPLAIN cho thấy plan tương tự. Tuy nhiên có ngoại lệ: IN với subquery trả NULL values có behavior khác EXISTS (NULL trong IN list → không match, EXISTS không bị ảnh hưởng bởi NULL content).
EXISTS tốt hơn IN khi: (1) subquery có thể trả NULL — EXISTS an toàn hơn về semantic; (2) bạn cần early termination rõ ràng (EXISTS dừng ngay khi tìm được 1 row match); (3) subquery join phức tạp mà planner khó unnest từ IN — EXISTS thường dễ optimize hơn trong các trường hợp phức tạp.
Bài tiếp theo: Recursive CTE — comment thread, project tree, manager chain
Bài này có giúp bạn hiểu bản chất không?