SQL cho phân tích dữ liệu · Hướng dẫn · 30 tháng 10, 2026 · 16 phút đọc
Truy vấn con trong SQL (subquery): trong WHERE, SELECT và FROM, kèm ví dụ

Truy vấn con (subquery) là một câu SELECT viết bên trong một truy vấn khác, đặt trong ngoặc đơn. Truy vấn bên ngoài dùng kết quả của nó như dùng một giá trị, một danh sách hoặc một bảng. Vì vậy truy vấn con xuất hiện ở ba chỗ, với ba dạng:
- trong
WHERE, dưới dạng một giá trị duy nhất (total_amount > (SELECT AVG(...))), một danh sách (aircraft_code IN (SELECT ...)) hoặc đi cùngEXISTS; - trong
SELECT, dưới dạng một giá trị được tính cho từng dòng; - trong
FROM, dưới dạng cả một bảng để bạn truy vấn tiếp (gọi là bảng dẫn xuất, derived table).
Ngoài ra, truy vấn con hoặc là độc lập (chạy một lần và không quan tâm đến dòng bên ngoài), hoặc là tương quan (correlated: tham chiếu đến một cột của truy vấn bên ngoài và về mặt logic được tính lại cho từng dòng bên ngoài). Khi đã phân biệt được các trường hợp này, gần như mọi truy vấn con bạn gặp đều dễ đọc.
Mọi ví dụ đều 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 giữa 104 sân bay trong mùa hè 2026, 262.788 đơn đặt chỗ, vé, chặng bay và một chương trình khách hàng thân thiết. Tiền tính bằng đô la Mỹ. Cú pháp là PostgreSQL, và mọi kết quả bên dưới đều có được bằng cách chạy truy vấn thật.
Truy vấn con trong WHERE: so sánh với một giá trị
Truy vấn con kinh điển đầu tiên: những đơn đặt chỗ nào có giá trị cao hơn mức trung bình? Bạn không thể viết WHERE total_amount > AVG(total_amount), vì hàm tổng hợp không được phép dùng trong WHERE. Vậy bạn tính giá trị trung bình trong một truy vấn con:
SELECT COUNT(*) AS bookings_above_avg
FROM bookings
WHERE total_amount > (SELECT AVG(total_amount) FROM bookings);
| bookings_above_avg |
|---|
| 87224 |
Truy vấn bên trong trả về đúng một dòng và một cột: $790,26. Đó là truy vấn con vô hướng (scalar subquery), và SQL cho phép đặt nó ở bất cứ chỗ nào chấp nhận một giá trị duy nhất. Cơ sở dữ liệu chạy nó một lần, rồi so sánh từng đơn đặt chỗ với con số đó. Khoảng một phần ba trong 262.788 đơn đặt chỗ cao hơn mức trung bình, điều này bình thường với dữ liệu tiền: vài đơn lớn kéo giá trị trung bình lên.
Cùng mẫu đó giúp tìm dòng chứa giá trị cực đại:
SELECT book_ref, book_date, total_amount
FROM bookings
WHERE total_amount = (SELECT MAX(total_amount) FROM bookings);
| book_ref | book_date | total_amount |
|---|---|---|
| 3B54BB | 2026-07-01 17:08:00 | 12045.00 |
Nếu có hai đơn cùng đạt giá trị lớn nhất, bạn sẽ nhận được cả hai. Đó thường đúng là điều bạn muốn, và là điểm khác với ORDER BY total_amount DESC LIMIT 1.
IN: so sánh với một danh sách
Khi truy vấn con trả về một cột nhưng nhiều dòng, hãy dùng IN. Những mẫu máy bay nào cất cánh từ Sheremetyevo (SVO)?
SELECT aircraft_code, model
FROM aircrafts
WHERE aircraft_code IN (
SELECT aircraft_code FROM flights WHERE departure_airport = 'SVO'
)
ORDER BY aircraft_code;
| aircraft_code | model |
|---|---|
| 319 | Airbus A319-100 |
| 321 | Airbus A321-200 |
| 733 | Boeing 737-300 |
| 763 | Boeing 767-300 |
| 773 | Boeing 777-300 |
| CN1 | Cessna 208 Caravan |
| CR2 | Bombardier CRJ-200 |
| SU9 | Sukhoi Superjet-100 |
Truy vấn bên trong trả về 2.981 mã, mỗi chuyến cất cánh từ SVO một mã, lặp lại rất nhiều. IN không quan tâm: mỗi mẫu máy bay hoặc có trong danh sách, hoặc không, nên mỗi mẫu chỉ xuất hiện một lần.
So sánh với phép JOIN mà bạn có thể viết thay thế:
SELECT COUNT(*) AS rows_returned, COUNT(DISTINCT a.aircraft_code) AS models
FROM aircrafts a
JOIN flights f ON f.aircraft_code = a.aircraft_code
WHERE f.departure_airport = 'SVO';
| rows_returned | models |
|---|---|
| 2981 | 8 |
JOIN trả về một dòng cho mỗi chuyến bay khớp, nên bạn sẽ cần DISTINCT để quay về tám mẫu máy bay. Đây là khác biệt thực tế chính giữa hai cách: truy vấn con trong WHERE lọc dòng mà không nhân chúng lên; JOIN thì có thể nhân lên. Khi bạn chỉ cần biết có tồn tại dòng khớp hay không, truy vấn con (hoặc EXISTS, bên dưới) là công cụ gọn hơn. Bài JOIN trong SQL giải thích kỹ hiện tượng nhân dòng này.
Truy vấn con trả về danh sách có thể có GROUP BY và HAVING riêng. Những thành phố nào có nhiều hơn một sân bay, và có bao nhiêu?
SELECT city, COUNT(*) AS airports
FROM airports
WHERE city IN (
SELECT city FROM airports GROUP BY city HAVING COUNT(*) > 1
)
GROUP BY city
ORDER BY city;
| city | airports |
|---|---|
| Moscow | 3 |
| Ulyanovsk | 2 |
ANY và ALL
IN là cách viết tắt của = ANY (...). Các dạng ANY và ALL cũng dùng được với >, < và các phép so sánh khác. Những máy bay nào bay xa hơn mọi chiếc Airbus trong đội bay?
SELECT model, range
FROM aircrafts
WHERE range > ALL (SELECT range FROM aircrafts WHERE model LIKE 'Airbus%')
ORDER BY range DESC;
| model | range |
|---|---|
| Boeing 777-300 | 11100 |
| Boeing 767-300 | 7900 |
> ALL (...) tương đương với > (SELECT MAX(...)), và đa số mọi người thấy phiên bản MAX dễ đọc hơn. Bạn sẽ gặp ANY và ALL trong code cũ và trong câu hỏi phỏng vấn nhiều hơn là trong các phân tích mới.
EXISTS và NOT EXISTS: có ít nhất một dòng khớp không?
EXISTS (truy vấn con) đúng nếu truy vấn con trả về ít nhất một dòng. Truy vấn con chọn gì không quan trọng, đó là lý do người ta viết SELECT 1. EXISTS gần như luôn là tương quan: truy vấn bên trong tham chiếu đến dòng bên ngoài.
Có bao nhiêu thành viên chương trình khách hàng thân thiết chưa từng mua vé?
SELECT COUNT(*) AS members_without_tickets
FROM loyalty_members m
WHERE NOT EXISTS (
SELECT 1 FROM ticket_members tm WHERE tm.member_id = m.member_id
);
| members_without_tickets |
|---|
| 11426 |
Hãy đọc từng dòng một: với mỗi thành viên m, tìm bất kỳ vé nào gắn với m.member_id; giữ thành viên đó lại nếu không có vé nào. Trong 53.000 thành viên, 11.426 người đã tham gia chương trình nhưng không có vé nào trong dữ liệu này.
EXISTS cũng xử lý được điều kiện nằm cách hai bảng. Có bao nhiêu đơn đặt chỗ chứa ít nhất một chặng hạng thương gia?
SELECT COUNT(*) AS bookings_with_business
FROM bookings b
WHERE EXISTS (
SELECT 1
FROM tickets t
JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
WHERE t.book_ref = b.book_ref
AND tf.fare_conditions = 'Business'
);
| bookings_with_business |
|---|
| 83730 |
Một đơn đặt chỗ có năm chặng hạng thương gia chỉ được đếm một lần. Viết cùng điều đó bằng JOIN sẽ cần COUNT(DISTINCT b.book_ref) để khử các dòng trùng.
Cái bẫy NOT IN
NOT EXISTS và NOT IN trông có vẻ thay thế được cho nhau, và thường cho cùng kết quả. Mẫu máy bay nào không bao giờ xuất hiện trong lịch bay?
SELECT aircraft_code
FROM aircrafts
WHERE aircraft_code NOT IN (SELECT aircraft_code FROM flights);
| aircraft_code |
|---|
| 320 |
Chiếc Airbus A320 có trong đội bay nhưng không bay chuyến nào trong mùa hè này. Truy vấn chạy đúng vì flights.aircraft_code không có NULL. Giờ hãy thử cùng mẫu đó trên một cột có NULL. Trong app_events, member_id là NULL với những khách truy cập chưa đăng nhập. 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 |
Không người nào, và điều đó sai. Phiên bản NOT EXISTS cho câu trả lời thật:
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 |
x NOT IN (danh sách) nghĩa là “x khác mọi phần tử”. Nếu danh sách chứa một NULL, phép so sánh x <> NULL cho kết quả không xác định (unknown), nên cả điều kiện không bao giờ đúng và mọi dòng đều bị loại. Truy vấn bên trong ở đây trả về 912.625 giá trị NULL. Cách sửa là dùng NOT EXISTS, hoặc NOT IN (SELECT member_id FROM app_events WHERE member_id IS NOT NULL), cách này cũng trả về đúng 19.376. Thói quen đáng rèn: dùng NOT EXISTS cho “những dòng không có dòng khớp”. Bài NULL trong SQL giải thích logic ba giá trị đứng sau chuyện này.
Truy vấn con tương quan: so sánh mỗi dòng với nhóm của chính nó
Truy vấn con vô hướng cũng có thể là tương quan. Có bao nhiêu chặng bay có giá cao hơn mức trung bình của chính hạng ghế đó?
SELECT tf.fare_conditions, COUNT(*) AS segments
FROM ticket_flights tf
WHERE tf.amount > (
SELECT AVG(t2.amount)
FROM ticket_flights t2
WHERE t2.fare_conditions = tf.fare_conditions
)
GROUP BY tf.fare_conditions
ORDER BY segments DESC;
| fare_conditions | segments |
|---|---|
| Economy | 291102 |
| Business | 29126 |
| Comfort | 6919 |
Truy vấn bên trong dùng tf.fare_conditions của dòng bên ngoài, nên giá trị trung bình nó trả về phụ thuộc vào dòng đó: $159,60 cho Economy, $327,41 cho Comfort, $511,43 cho Business. Hãy đặt cho hai bản sao của bảng hai bí danh khác nhau (tf và t2); nếu không, bạn không thể nói rõ mình đang muốn dùng bản nào.
“Về mặt logic được tính lại cho từng dòng” không có nghĩa là cơ sở dữ liệu thật sự chạy truy vấn bên trong cả triệu lần. Cả PostgreSQL lẫn DuckDB đều viết lại nhiều truy vấn con tương quan thành JOIN. Dù vậy, nếu một truy vấn con tương quan chạy chậm, cùng logic đó thường rõ ràng hơn khi viết bằng hàm cửa sổ: amount > AVG(amount) OVER (PARTITION BY fare_conditions) trong một bảng dẫn xuất. Xem bài hàm cửa sổ trong SQL.
Truy vấn con trong SELECT: một giá trị cho mỗi dòng
Truy vấn con vô hướng trong danh sách SELECT thêm một giá trị được tính vào mỗi dòng kết quả. Mỗi mẫu máy bay có bao nhiêu ghế, và khai thác bao nhiêu chuyến bay?
SELECT a.model,
(SELECT COUNT(*) FROM seats s WHERE s.aircraft_code = a.aircraft_code) AS seats,
(SELECT COUNT(*) FROM flights f WHERE f.aircraft_code = a.aircraft_code) AS flights
FROM aircrafts a
ORDER BY seats DESC;
| model | seats | flights |
|---|---|---|
| Boeing 777-300 | 402 | 610 |
| Boeing 767-300 | 222 | 1221 |
| Airbus A321-200 | 170 | 1952 |
| Airbus A320-200 | 140 | 0 |
| Boeing 737-300 | 130 | 1274 |
| Airbus A319-100 | 116 | 1239 |
| Sukhoi Superjet-100 | 97 | 8504 |
| Bombardier CRJ-200 | 50 | 9048 |
| Cessna 208 Caravan | 12 | 9273 |
Đây là cách gọn để đặt hai số đếm từ hai bảng khác nhau cạnh nhau. Nếu JOIN aircrafts với cả seats lẫn flights rồi nhóm lại, số ghế sẽ bị nhân với số chuyến bay và cho ra kết quả vô nghĩa. Cũng để ý rằng A320 nhận giá trị 0 chứ không bị mất dòng: COUNT(*) trên tập không có dòng nào bằng 0.
Truy vấn con vô hướng không tương quan trong SELECT rất tiện để tính tỷ trọng trên tổng:
SELECT model,
(SELECT COUNT(*) FROM seats s WHERE s.aircraft_code = a.aircraft_code) AS seats,
ROUND(100.0 * (SELECT COUNT(*) FROM seats s WHERE s.aircraft_code = a.aircraft_code)
/ (SELECT COUNT(*) FROM seats), 1) AS pct_of_all_seats
FROM aircrafts a
ORDER BY seats DESC
LIMIT 4;
| model | seats | pct_of_all_seats |
|---|---|---|
| Boeing 777-300 | 402 | 30 |
| Boeing 767-300 | 222 | 16.6 |
| Airbus A321-200 | 170 | 12.7 |
| Airbus A320-200 | 140 | 10.5 |
Truy vấn chạy được, nhưng phép đếm ghế giờ bị viết hai lần. Khi thấy cùng một truy vấn con lặp lại, đó là lúc nên chuyển nó vào một CTE hoặc một bảng dẫn xuất.
Truy vấn con trong FROM: bảng dẫn xuất
Truy vấn con trong FROM trả về cả một bảng, và truy vấn bên ngoài coi nó như mọi bảng khác. Đây là cách tổng hợp hai lần: trước tiên theo từng đơn đặt chỗ, sau đó trên tất cả các đơn. Một đơn đặt chỗ điển hình có bao nhiêu vé?
SELECT tickets_in_booking, COUNT(*) AS bookings
FROM (
SELECT book_ref, COUNT(*) AS tickets_in_booking
FROM tickets
GROUP BY book_ref
) AS per_booking
GROUP BY tickets_in_booking
ORDER BY tickets_in_booking;
| tickets_in_booking | bookings |
|---|---|
| 1 | 173390 |
| 2 | 75793 |
| 3 | 12686 |
| 4 | 896 |
| 5 | 23 |
Truy vấn bên trong tạo ra một dòng cho mỗi đơn đặt chỗ kèm số vé của nó. Truy vấn bên ngoài nhóm các dòng đó thêm một lần nữa. Bạn không thể làm việc này trong một GROUP BY duy nhất, vì COUNT(COUNT(*)) không được phép. Cùng bảng dẫn xuất đó cho ra giá trị trung bình, 1,396 vé mỗi đơn, với SELECT ROUND(AVG(tickets_in_booking), 3) FROM (...) AS per_booking.
Bảng dẫn xuất cũng kết hợp được với JOIN. Ở đây số chuyến cất cánh được đếm theo từng sân bay trước, sau đó mới JOIN với airports để lấy tên thành phố:
SELECT a.city, d.departures
FROM (
SELECT departure_airport, COUNT(*) AS departures
FROM flights
WHERE status = 'Arrived'
GROUP BY departure_airport
) AS d
JOIN airports a ON a.airport_code = d.departure_airport
ORDER BY d.departures DESC
LIMIT 5;
| city | departures |
|---|---|
| Moscow | 1618 |
| Moscow | 1503 |
| St. Petersburg | 956 |
| Moscow | 869 |
| Novosibirsk | 536 |
Moscow xuất hiện ba lần vì thành phố này có ba sân bay (DME, SVO, VKO), và việc nhóm diễn ra theo từng sân bay. Nếu bạn muốn mỗi thành phố một dòng, hãy nhóm theo a.city sau khi JOIN: khi đó Moscow có 3.990 chuyến cất cánh. Không cách nào sai cả; truy vấn con chỉ làm rõ bạn đã tổng hợp ở cấp nào.
LATERAL: truy vấn con trong FROM nhìn thấy dòng bên ngoài
Một bảng dẫn xuất thông thường không thể tham chiếu đến các bảng khác trong cùng FROM. LATERAL gỡ bỏ giới hạn đó, nhờ vậy bài toán “top N mỗi nhóm” trở nên dễ viết. Hai điểm đến thường xuyên nhất từ SVO và LED:
SELECT ap.airport_code, ap.city, top.arrival_airport, top.flights
FROM airports ap
CROSS JOIN LATERAL (
SELECT f.arrival_airport, COUNT(*) AS flights
FROM flights f
WHERE f.departure_airport = ap.airport_code
GROUP BY f.arrival_airport
ORDER BY flights DESC, f.arrival_airport
LIMIT 2
) AS top
WHERE ap.airport_code IN ('SVO', 'LED')
ORDER BY ap.airport_code, top.flights DESC;
| airport_code | city | arrival_airport | flights |
|---|---|---|---|
| LED | St. Petersburg | SVO | 305 |
| LED | St. Petersburg | DME | 244 |
| SVO | Moscow | LED | 305 |
| SVO | Moscow | BZK | 183 |
LATERAL chạy được trong PostgreSQL, DuckDB và MySQL 8; SQL Server viết là CROSS APPLY. Với đa số nhà phân tích, cách làm top N bằng ROW_NUMBER() phổ biến hơn, nhưng bạn nên nhận ra LATERAL khi gặp.
Truy vấn con, JOIN hay CTE?
| Bạn cần | Thường tốt nhất |
|---|---|
| Lọc theo “có dòng khớp” / “không có dòng khớp” | EXISTS / NOT EXISTS |
| Lọc theo một danh sách ngắn hoặc một giá trị tính ra | IN / truy vấn con vô hướng trong WHERE |
| Cột từ một bảng khác trong kết quả | JOIN |
| Tổng hợp, rồi tổng hợp tiếp | bảng dẫn xuất hoặc CTE |
| Cùng một kết quả trung gian dùng hai lần, hoặc lồng nhau quá hai cấp | CTE |
| Một giá trị cho mỗi dòng từ bảng khác, không nhân dòng | truy vấn con vô hướng trong SELECT hoặc JOIN với bảng đã tổng hợp trước |
Hiệu năng hiếm khi là yếu tố quyết định: các bộ tối ưu hóa hiện đại tự chuyển IN, EXISTS và nhiều truy vấn con tương quan thành JOIN bên trong. Hãy chọn cách viết diễn đạt câu hỏi trực tiếp nhất, rồi xem kế hoạch thực thi nếu truy vấn chạy chậm.
Các lỗi thường gặp
1. Truy vấn con vô hướng trả về nhiều hơn một dòng
SELECT (SELECT aircraft_code FROM flights WHERE departure_airport = 'SVO') AS x;
PostgreSQL dừng lại với thông báo “more than one row returned by a subquery used as an expression”, DuckDB cũng báo lỗi tương tự. Truy vấn con dùng như một giá trị duy nhất phải trả về tối đa một dòng: hãy tổng hợp nó (MAX, COUNT), lọc xuống còn một dòng, hoặc chuyển sang IN nếu bạn thật sự muốn một danh sách. Nếu nó không trả về dòng nào, giá trị là NULL, và điều đó có thể âm thầm làm rỗng một điều kiện WHERE.
2. NOT IN trên một cột có NULL
Đã nói ở trên: chỉ một NULL trong danh sách cũng khiến NOT IN không trả về gì. Hãy dùng NOT EXISTS.
3. Cột không ghi rõ bảng âm thầm tham chiếu đến truy vấn bên ngoài
SELECT COUNT(*) AS models
FROM aircrafts
WHERE aircraft_code IN (SELECT aircraft_code FROM airports);
| models |
|---|
| 9 |
Bảng airports không có cột aircraft_code, vậy mà truy vấn vẫn chạy và trả về cả chín mẫu máy bay. SQL tìm aircraft_code trong bảng bên trong, không thấy, nên lấy nó từ dòng aircrafts bên ngoài, và điều kiện trở thành “aircraft_code bằng chính nó”. Hãy luôn ghi rõ bí danh bảng cho các cột bên trong truy vấn con; khi đó một lỗi gõ nhầm sẽ thành thông báo lỗi chứ không thành câu trả lời sai.
4. Bảng dẫn xuất không có bí danh
SELECT COUNT(*) FROM (SELECT 1 FROM aircrafts) chạy được trong DuckDB và trong PostgreSQL 16 trở lên. PostgreSQL 15 trở về trước, cùng một số hệ quản trị khác, từ chối nó với thông báo “subquery in FROM must have an alias”. Viết thêm AS t không tốn gì và chạy được ở mọi nơi.
5. ORDER BY bên trong truy vấn con
ORDER BY bên trong một bảng dẫn xuất không đảm bảo thứ tự của kết quả cuối cùng. Hãy sắp xếp ở truy vấn ngoài cùng. Bên trong truy vấn con, ORDER BY chỉ có ý nghĩa khi đi cùng LIMIT, như trong ví dụ LATERAL.
Câu hỏi thường gặp
Truy vấn con trong SQL là gì?
Là một câu lệnh SELECT lồng bên trong một câu lệnh khác, đặt trong ngoặc đơn. Truy vấn bên ngoài dùng kết quả của nó như một giá trị duy nhất, một danh sách giá trị hoặc một bảng, tùy vào vị trí đặt truy vấn con.
Truy vấn con tương quan và không tương quan khác nhau thế nào?
Truy vấn con không tương quan không tham chiếu đến truy vấn bên ngoài và có thể chạy riêng; nó được tính một lần. Truy vấn con tương quan tham chiếu đến một cột của truy vấn bên ngoài (WHERE t2.fare_conditions = tf.fare_conditions), nên kết quả của nó phụ thuộc vào dòng bên ngoài hiện tại.
Truy vấn con có chậm hơn JOIN không?
Về bản chất thì không. PostgreSQL, DuckDB và các hệ quản trị hiện đại khác viết lại phần lớn truy vấn con dùng IN và EXISTS thành JOIN. Hãy viết phiên bản diễn đạt câu hỏi tốt nhất, và chỉ xem kế hoạch thực thi khi truy vấn thật sự chậm.
Khi nào nên dùng EXISTS thay vì IN?
Dùng EXISTS khi điều kiện phụ thuộc vào dòng bên ngoài hoặc liên quan đến nhiều cột, và luôn ưu tiên NOT EXISTS thay vì NOT IN cho “những dòng không có dòng khớp”, vì NOT IN hỏng khi danh sách chứa NULL.
Truy vấn con có trả về nhiều cột được không?
Trong FROM thì được: bảng dẫn xuất có thể có bao nhiêu cột cũng được. Trong WHERE với IN, truy vấn con thường trả về một cột, dù PostgreSQL cũng hỗ trợ so sánh theo dòng như (a, b) IN (SELECT x, y ...). Trong SELECT, truy vấn con phải trả về một giá trị duy nhất.
Ngôn ngữ khác: English, Español, Português (Brasil), 简体中文