SQL cho phân tích dữ liệu · Hướng dẫn · 30 tháng 10, 2026 · 12 phút đọc
GROUP BY và HAVING trong SQL: khác nhau giữa WHERE và HAVING, kèm ví dụ

GROUP BY và HAVING không phải hai lựa chọn thay thế nhau: chúng làm hai việc khác nhau trong cùng một truy vấn. GROUP BY gộp các dòng thành nhóm (mỗi sân bay, mỗi ngày, mỗi hạng ghế một dòng kết quả) để các hàm tổng hợp như COUNT và AVG được tính theo từng nhóm. HAVING lọc các nhóm đó sau khi đã có giá trị tổng hợp. WHERE là mảnh ghép thứ ba: nó lọc từng dòng riêng lẻ trước khi việc nhóm diễn ra. Vậy quy tắc ngắn gọn là: điều kiện trên một cột gốc đặt trong WHERE; điều kiện trên một giá trị tổng hợp (COUNT(*) > 1000, AVG(amount) > 300) đặt trong HAVING.
Mọi ví dụ bên dưới đề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, cùng đơn đặt chỗ, vé và chặng bay. Tiền tính bằng đô la Mỹ. Bạn có thể dán từng truy vấn vào Sân tập SQL của Nomto và tự kiểm tra kết quả.
GROUP BY làm gì
Khi không có GROUP BY, hàm tổng hợp biến cả bảng thành một dòng. Khi có nó, bạn nhận được một dòng cho mỗi giá trị khác nhau của các cột nhóm.
SELECT status, COUNT(*) AS flights
FROM flights
GROUP BY status
ORDER BY flights DESC;
| status | flights |
|---|---|
| Arrived | 16707 |
| Scheduled | 15383 |
| On Time | 518 |
| Cancelled | 414 |
| Departed | 58 |
| Delayed | 41 |
Sáu trạng thái, sáu dòng. Mỗi dòng là một nhóm, và COUNT(*) được tính riêng trong từng nhóm.
Bạn có thể nhóm theo nhiều cột, và theo biểu thức. Đây là số ghế theo từng mẫu máy bay, kèm số ghế hạng thương gia được đếm có điều kiện trong cùng một lượt:
SELECT aircraft_code,
COUNT(*) AS seats,
SUM(CASE WHEN fare_conditions = 'Business' THEN 1 ELSE 0 END) AS business
FROM seats
GROUP BY aircraft_code
ORDER BY seats DESC;
| aircraft_code | seats | business |
|---|---|---|
| 773 | 402 | 30 |
| 763 | 222 | 30 |
| 321 | 170 | 28 |
| 320 | 140 | 20 |
| 733 | 130 | 12 |
| 319 | 116 | 20 |
| SU9 | 97 | 12 |
| CR2 | 50 | 0 |
(Mẫu thứ chín, CN1, có 12 ghế và không có hạng thương gia.)
HAVING làm gì
HAVING giữ lại hoặc loại bỏ cả nhóm. Nó được tính sau khi đã có giá trị tổng hợp, nên có thể tham chiếu tới chúng.
SELECT departure_airport, COUNT(*) AS departures
FROM flights
WHERE status = 'Arrived'
GROUP BY departure_airport
HAVING COUNT(*) > 1000
ORDER BY departures DESC;
| departure_airport | departures |
|---|---|
| DME | 1618 |
| SVO | 1503 |
Hãy đọc theo thứ tự mà cơ sở dữ liệu thực hiện: lấy mọi chuyến bay, chỉ giữ những chuyến đã hạ cánh (WHERE), nhóm theo sân bay khởi hành, đếm từng nhóm, chỉ giữ những nhóm có hơn 1.000 dòng (HAVING). Hai sân bay ở Moscow, Domodedovo và Sheremetyevo, vượt qua điều kiện.
HAVING có thể dùng bất kỳ hàm tổng hợp nào, không chỉ hàm bạn đã chọn trong SELECT. Mẫu máy bay nào hoàn toàn không có hạng thương gia?
SELECT aircraft_code, COUNT(*) AS seats
FROM seats
GROUP BY aircraft_code
HAVING SUM(CASE WHEN fare_conditions = 'Business' THEN 1 ELSE 0 END) = 0
ORDER BY seats DESC;
| aircraft_code | seats |
|---|---|
| CR2 | 50 |
| CN1 | 12 |
Đó là Bombardier CRJ-200 và Cessna 208 Caravan.
Khác nhau giữa WHERE và HAVING
| WHERE | HAVING | |
|---|---|---|
| Lọc gì | từng dòng | nhóm |
| Chạy khi nào | trước GROUP BY |
sau GROUP BY và tổng hợp |
| Dùng được hàm tổng hợp | không | có |
| Dùng được cột gốc | mọi cột | chỉ cột đã nhóm (hoặc bên trong hàm tổng hợp) |
Dùng được khi không có GROUP BY |
có | có, toàn bộ kết quả là một nhóm |
Hệ quả thực tế: điều kiện trong WHERE thay đổi dữ liệu đi vào hàm tổng hợp; điều kiện trong HAVING thì không. So sánh hai truy vấn sau về giá chặng bay.
Giá trung bình theo hạng ghế, chỉ giữ những chặng có giá trên $300:
SELECT fare_conditions, COUNT(*) AS segments, ROUND(AVG(amount), 2) AS avg_price
FROM ticket_flights
WHERE amount > 300
GROUP BY fare_conditions
ORDER BY avg_price DESC;
| fare_conditions | segments | avg_price |
|---|---|---|
| Business | 63467 | 739.73 |
| Economy | 90837 | 500.99 |
| Comfort | 6919 | 474.92 |
Giá trung bình theo hạng ghế, chỉ giữ những hạng có giá trung bình trên $300:
SELECT fare_conditions, COUNT(*) AS segments, ROUND(AVG(amount), 2) AS avg_price
FROM ticket_flights
GROUP BY fare_conditions
HAVING AVG(amount) > 300
ORDER BY avg_price DESC;
| fare_conditions | segments | avg_price |
|---|---|---|
| Business | 107642 | 511.43 |
| Comfort | 17291 | 327.41 |
Truy vấn thứ nhất trả lời câu hỏi “trong mỗi hạng, một chặng đắt tiền trung bình giá bao nhiêu”; mọi giá trị trung bình đều bị đẩy lên vì các chặng rẻ đã bị loại, và hạng phổ thông (Economy) thậm chí vượt cả hạng Comfort. Truy vấn thứ hai trả lời “hạng nào đắt tính trung bình”; ở đây giá trung bình là con số thật (của Economy là $159,60, nên nó bị loại). Cùng các cột, cùng con số 300, nhưng là hai câu hỏi hoàn toàn khác nhau.
Khi đặt ở đâu cũng được, hãy dùng WHERE
Điều kiện trên một cột đã nhóm về mặt kỹ thuật có thể đặt ở cả hai chỗ:
SELECT departure_airport, COUNT(*) AS departures
FROM flights
WHERE departure_airport IN ('SVO', 'DME', 'VKO')
GROUP BY departure_airport
ORDER BY departures DESC;
| departure_airport | departures |
|---|---|
| DME | 3217 |
| SVO | 2981 |
| VKO | 1719 |
Chuyển điều kiện sang HAVING departure_airport IN ('SVO', 'DME', 'VKO') vẫn trả về đúng ba dòng đó. Nhưng phiên bản HAVING phải nhóm và đếm cả 104 sân bay trước, rồi mới bỏ đi 101 nhóm. Nhiều bộ tối ưu hóa sẽ tự đẩy điều kiện như vậy xuống, nhưng bạn không nên trông chờ vào điều đó; hơn nữa WHERE còn cho người đọc thấy ý định của bạn: những dòng này nằm ngoài phạm vi ngay từ đầu.
Thứ tự SQL thực thi một truy vấn
Bạn viết SELECT trước tiên, nhưng cơ sở dữ liệu xử lý các mệnh đề theo thứ tự logic sau:
FROMvàJOIN: tạo tập dòngWHERE: loại bỏ dòngGROUP BY: tạo nhóm- tính các hàm tổng hợp cho từng nhóm
HAVING: loại bỏ nhómSELECT: tính các cột kết quả và bí danhDISTINCTORDER BYLIMIT/OFFSET
Gần như mọi lỗi với GROUP BY / HAVING đều suy ra được từ danh sách này:
WHEREchạy trước khi có giá trị tổng hợp, nênWHERE COUNT(*) > 1000là không thể.SELECTchạy sauGROUP BY, nên mỗi cột được chọn phải có một giá trị duy nhất trong mỗi nhóm.ORDER BYchạy sauSELECT, nên có thể dùng bí danh cột nhưdepartures.- Trong PostgreSQL,
HAVINGchạy trướcSELECT, nên không dùng được bí danh trongSELECT: hãy viếtHAVING COUNT(*) > 1000, không phảiHAVING departures > 1000. Một số hệ quản trị (MySQL, DuckDB) chấp nhận bí danh, đó là lý do code chép từ hệ này sang hệ khác đôi khi hỏng ở chỗ này.
Các lỗi thường gặp
1. Dùng hàm tổng hợp trong WHERE
SELECT departure_airport, COUNT(*)
FROM flights
WHERE COUNT(*) > 1000
GROUP BY departure_airport;
Truy vấn này báo lỗi. PostgreSQL thông báo “aggregate functions are not allowed in WHERE”. Hãy chuyển điều kiện sang HAVING.
2. Cột được chọn không được nhóm cũng không được tổng hợp
SELECT departure_airport, arrival_airport, COUNT(*)
FROM flights
GROUP BY departure_airport;
Truy vấn này cũng báo lỗi: “column arrival_airport must appear in the GROUP BY clause or be used in an aggregate function”. Một sân bay khởi hành có nhiều sân bay đến, nên cơ sở dữ liệu không thể chọn một giá trị cho mỗi nhóm. Hãy thêm arrival_airport vào GROUP BY (mỗi tuyến một dòng) hoặc tổng hợp nó, ví dụ COUNT(DISTINCT arrival_airport).
PostgreSQL có một ngoại lệ: nếu bạn nhóm theo khóa chính của một bảng, bạn được chọn các cột khác của bảng đó. Nhóm theo b.book_ref cho phép chọn b.total_amount. Liệt kê cả hai trong GROUP BY thì chạy được ở mọi nơi, nên các truy vấn ở đây đều viết như vậy:
SELECT b.book_ref, COUNT(*) AS tickets, b.total_amount
FROM bookings b
JOIN tickets t ON t.book_ref = b.book_ref
GROUP BY b.book_ref, b.total_amount
HAVING COUNT(*) >= 5
ORDER BY b.total_amount DESC
LIMIT 5;
| book_ref | tickets | total_amount |
|---|---|---|
| 65A6EA | 5 | 10656.00 |
| 7734A5 | 5 | 8291.00 |
| 148C46 | 5 | 8135.00 |
| 070682 | 5 | 8012.00 |
| 8DA112 | 5 | 6150.00 |
Trong toàn bộ cơ sở dữ liệu chỉ có 23 đơn đặt chỗ có từ năm vé trở lên.
3. Xếp hạng nhóm nhỏ cạnh nhóm lớn
Độ trễ cất cánh trung bình theo sân bay, chỉ tính các chuyến đã thực sự cất cánh:
SELECT departure_airport,
ROUND(AVG(EXTRACT(EPOCH FROM actual_departure - scheduled_departure) / 60), 1) AS avg_delay_min,
COUNT(*) AS departed
FROM flights
WHERE status IN ('Arrived', 'Departed')
GROUP BY departure_airport
ORDER BY avg_delay_min DESC
LIMIT 3;
| departure_airport | avg_delay_min | departed |
|---|---|---|
| ULY | 24.4 | 97 |
| BQS | 22.8 | 31 |
| LPK | 22.7 | 22 |
Những sân bay “tệ nhất” này chỉ có từ 22 đến 97 chuyến cất cánh, nên vài lần trễ dài đã chi phối giá trị trung bình. Đặt một kích thước nhóm tối thiểu trong HAVING giúp so sánh công bằng hơn:
SELECT departure_airport,
COUNT(*) AS departed,
ROUND(AVG(EXTRACT(EPOCH FROM actual_departure - scheduled_departure) / 60), 1) AS avg_delay_min
FROM flights
WHERE status IN ('Arrived', 'Departed')
GROUP BY departure_airport
HAVING COUNT(*) >= 300
ORDER BY avg_delay_min DESC
LIMIT 5;
| departure_airport | departed | avg_delay_min |
|---|---|---|
| SVX | 348 | 14.7 |
| VKO | 870 | 13.4 |
| BZK | 306 | 13.2 |
| KJA | 358 | 13.1 |
| LED | 960 | 12.6 |
Chú ý sự phân công: WHERE quyết định chuyến nào được tính là đã cất cánh, HAVING quyết định sân bay nào có đủ số chuyến.
4. Dùng COUNT(*) khi ý bạn là COUNT(column)
COUNT(*) đếm số dòng; COUNT(column) đếm số giá trị khác NULL. Trong truy vấn có nhóm, sự khác biệt này thường chính là điều quan trọng:
SELECT status, COUNT(*) AS flights, COUNT(actual_departure) AS with_departure
FROM flights
GROUP BY status
ORDER BY flights DESC;
| status | flights | with_departure |
|---|---|---|
| Arrived | 16707 | 16707 |
| Scheduled | 15383 | 0 |
| On Time | 518 | 0 |
| Cancelled | 414 | 8 |
| Departed | 58 | 58 |
| Delayed | 41 | 0 |
Tám chuyến bị hủy lại có ghi giờ cất cánh, một điểm lạ của dữ liệu mà bạn sẽ không bao giờ nhận ra nếu chỉ dùng COUNT(*). Thêm HAVING COUNT(actual_departure) > 0 vào truy vấn này sẽ giữ lại ba nhóm chứ không phải sáu.
5. Nhóm theo một biểu thức ở chỗ này nhưng không ở chỗ kia
Nếu bạn nhóm theo ngày, hãy nhóm theo đúng biểu thức mà bạn chọn:
SELECT CAST(scheduled_departure AS date) AS day, COUNT(*) AS cancelled
FROM flights
WHERE status = 'Cancelled'
GROUP BY CAST(scheduled_departure AS date)
HAVING COUNT(*) >= 15
ORDER BY day;
| day | cancelled |
|---|---|
| 2026-09-07 | 18 |
| 2026-09-08 | 21 |
| 2026-09-09 | 51 |
| 2026-09-10 | 287 |
Nếu nhóm theo mốc thời gian scheduled_departure gốc, mỗi phút cất cánh sẽ thành một nhóm, và HAVING sẽ không tìm thấy gì. (Đống chuyến bị hủy dồn ở cuối lịch bay đáng được xem kỹ trước khi ai đó báo cáo nó như một cuộc khủng hoảng: 403 trong số 414 chuyến bị hủy nằm trong lịch tháng 9 tương lai.)
HAVING không có GROUP BY
Nếu truy vấn có hàm tổng hợp và HAVING nhưng không có GROUP BY, toàn bộ kết quả được coi là một nhóm. Truy vấn trả về một dòng hoặc không dòng nào:
SELECT COUNT(*) AS flights
FROM flights
HAVING COUNT(*) > 30000;
| flights |
|---|
| 33121 |
Đổi ngưỡng thành 40.000 và bạn nhận được 0 dòng. Điều này đôi khi hữu ích khi kiểm tra dữ liệu: một truy vấn chỉ trả về dòng khi có gì đó sai.
Danh sách kiểm tra nhanh
- Điều kiện có dùng
COUNT,SUM,AVG,MIN,MAXhay hàm tổng hợp nào khác không? Đặt nó trongHAVING. - Điều kiện nói về giá trị của một dòng (trạng thái, ngày, giá của một chặng)? Đặt nó trong
WHERE. - Điều kiện chỉ liên quan đến cột đã nhóm? Đặt ở đâu cũng chạy; ưu tiên
WHERE. - Mọi cột trong
SELECTđều nằm trongGROUP BYhoặc bên trong hàm tổng hợp chưa? Nếu chưa, truy vấn sai. - Trong PostgreSQL, bạn đã viết lại biểu thức tổng hợp trong
HAVINGthay vì dùng bí danh của nó chưa?
Nếu có JOIN, hãy kiểm tra xem số dòng có bị nhân lên không trước khi tin bất kỳ SUM hay COUNT nào; bài JOIN trong SQL giải thích chuyện này. Khi bạn cần một giá trị tổng hợp bên cạnh mỗi dòng thay vì gộp dòng, thứ bạn cần là hàm cửa sổ, không phải GROUP BY. Muốn tự luyện ngay, hãy làm các bài 4 đến 8 trong bài tập SQL có lời giải.
Câu hỏi thường gặp
Có dùng được HAVING mà không có GROUP BY không?
Có. Toàn bộ kết quả trở thành một nhóm, và truy vấn trả về một dòng hoặc không gì cả, tùy vào điều kiện trong HAVING.
HAVING có chậm hơn WHERE không?
Với điều kiện trên giá trị tổng hợp thì không có lựa chọn nào khác, nên tốc độ không phải vấn đề. Với điều kiện trên cột gốc, WHERE cho phép cơ sở dữ liệu loại dòng trước khi nhóm, không bao giờ chậm hơn và thường nhanh hơn. Hãy dùng WHERE mỗi khi điều kiện không cần hàm tổng hợp.
Có dùng được bí danh cột trong HAVING không?
Trong PostgreSQL và SQL chuẩn thì không: HAVING được tính trước SELECT, nên lúc đó bí danh chưa tồn tại. Hãy viết lại biểu thức. MySQL và một số hệ quản trị khác cho phép dùng bí danh như một phần mở rộng.
WHERE và HAVING có dùng chung trong một truy vấn được không?
Được, và thường xuyên như vậy: WHERE chọn những dòng đi vào hàm tổng hợp, HAVING chọn những nhóm được giữ lại. Các ví dụ về chuyến cất cánh và độ trễ ở trên đều dùng cả hai.
GROUP BY và DISTINCT khác nhau thế nào?
SELECT DISTINCT status FROM flights và SELECT status FROM flights GROUP BY status trả về cùng sáu giá trị. GROUP BY còn cho phép tính giá trị tổng hợp theo nhóm và lọc nhóm bằng HAVING; DISTINCT chỉ loại bỏ các dòng trùng.
Ngôn ngữ khác: Español, Português (Brasil), English, 简体中文