Một nhóm có điểm 10, điểm 20 và một bản ghi chưa chấm. Trung bình của các điểm đã biết khác với trung bình sau khi coi bản chưa chấm là 0. Cả hai truy vấn đều chạy được, nhưng chúng biểu diễn hai quy tắc báo cáo khác nhau.
Bài đối chiếu PostgreSQL 17 và SQLite ngày 03/11/2026. SQL được chạy với Python 3.9.6, SQLite 3.54.0 trong database bộ nhớ; chưa chạy PostgreSQL server hoặc benchmark. Dữ liệu chỉ minh họa điểm đánh giá, không phải quy tắc tính điểm bắt buộc cho mọi sản phẩm.
Tạo ba nhóm có dữ liệu khác nhau
Chạy fixture trên database thử trống:
CREATE TABLE demo_scores (
id INTEGER PRIMARY KEY,
team TEXT NOT NULL,
score INTEGER
);
INSERT INTO demo_scores VALUES
(1, 'A', 10),
(2, 'A', 20),
(3, 'A', NULL),
(4, 'B', NULL),
(5, 'C', 0);
Nhóm A có ba hàng nhưng chỉ hai điểm đã biết. B có một hàng chưa chấm. C có điểm thật bằng 0. Phân biệt B với C là phần quan trọng của bài, vì việc cùng hiển thị một số 0 trên dashboard có thể che mất trạng thái thu thập dữ liệu.
Đọc trung bình cùng số lượng quan sát
SELECT team,
COUNT(*) AS rows_seen,
COUNT(score) AS scores_known,
AVG(score) AS average_known,
AVG(COALESCE(score, 0)) AS average_with_zero,
COALESCE(AVG(score), 0) AS display_fallback
FROM demo_scores
GROUP BY team
ORDER BY team;
Kết quả đã kiểm tra với SQLite:
- A có 3 hàng, 2 điểm; ba cột trung bình lần lượt là 15, 10 và 15.
- B có 1 hàng, 0 điểm; ba cột lần lượt là NULL, 0 và 0.
- C có 1 hàng, 1 điểm; ba cột đều bằng 0.
AVG(score) dùng các giá trị không NULL. AVG(COALESCE(score, 0)) thay dữ liệu từng hàng trước khi gom nhóm, nên bản chưa chấm góp một điểm 0 vào mẫu số. COALESCE(AVG(score), 0) chỉ thay kết quả cuối khi trung bình là NULL; nó không đưa hàng thiếu điểm vào trung bình của nhóm A.
Vì vậy, vị trí COALESCE là một quyết định nghiệp vụ. Với báo cáo “điểm trung bình của những lượt đã chấm”, cột average_known cùng scores_known giúp nói rõ căn cứ. Nếu sản phẩm thật sự quy định lượt chưa chấm tính 0, hãy ghi quy tắc đó và dùng công thức tương ứng.
Tập rỗng khác với một nhóm có mặt
Chạy thêm hai truy vấn:
SELECT COUNT(*) AS rows_seen, AVG(score) AS average_known,
COALESCE(AVG(score), 0) AS display_fallback
FROM demo_scores
WHERE team = 'D';
SELECT team, COALESCE(AVG(score), 0) AS display_fallback
FROM demo_scores
WHERE team = 'D'
GROUP BY team;
Truy vấn đầu đã được kiểm tra trả một hàng (0, NULL, 0). Truy vấn có GROUP BY trả không có hàng nào. COALESCE thay một giá trị NULL đã được tạo, không tự sinh một nhóm D chưa có trong đầu vào.
Nếu báo cáo phải luôn có đủ các nhóm, cần một nguồn danh sách nhóm và thiết kế truy vấn kết hợp phù hợp. Đừng chỉ thêm COALESCE rồi cho rằng mọi nhóm thiếu sẽ xuất hiện. Fixture này chưa triển khai truy vấn danh mục nhóm đầy đủ.
Cách kiểm tra trước khi triển khai
Giữ test cho nhóm có đủ điểm, thiếu một phần, toàn NULL, điểm 0 thật và không có hàng. Kiểm tra response API có phân biệt NULL với số 0 hay không; frontend không nên tự quyết định thay mọi giá trị thiếu bằng 0 nếu backend chưa định nghĩa ý nghĩa.
SQLite có quy tắc riêng với text không giống số trong AVG. Bài chỉ dùng cột có giá trị integer hoặc NULL, nên không chứng minh dữ liệu text bẩn được xử lý đúng. Với PostgreSQL, kiểu trả về cũng phụ thuộc kiểu đầu vào; driver có thể biểu diễn numeric khác float của SQLite.
Chốt ý nghĩa dữ liệu thiếu trước, rồi mới chọn vị trí COALESCE. Báo cáo dễ hiểu khi người đọc biết số nào là kết quả tính từ quan sát thật và số nào chỉ là fallback để hiển thị.


