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ụ

Một bảng giờ khởi hành nhỏ lồng bên trong một bảng giờ khởi hành lớn ở sân bay

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:

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