Khi cần thống kê “số lượng X theo từng Y” — ví dụ doanh thu theo từng danh mục — đừng tải hết bản ghi về rồi đếm bằng vòng lặp Python. Chỉ cần đặt values() trước annotate(): Django sẽ dịch thành một câu SQL duy nhất với GROUP BY theo cột trong values() và trả về một dict cho mỗi nhóm. Cách này dùng cho mọi báo cáo và dashboard thống kê theo nhóm.
Vấn đề
Giả sử model đơn hàng liên kết tới danh mục và bạn cần báo cáo doanh thu theo từng danh mục. Cách viết “tự nhiên” nhưng dở là tải hết đơn hàng về rồi gom nhóm bằng Python:
orders = Order.objects.select_related("category").all()report = {}for order in orders: name = order.category.name report[name] = report.get(name, 0) + order.amount
Với 100.000 đơn hàng, code trên tải 100.000 bản ghi về memory chỉ để cộng vài con số. Nếu quên select_related, mỗi lần chạm vào order.category.name lại phát sinh thêm một query — đúng dạng N+1. Gom nhóm và cộng tổng trong SQL là việc database làm tốt hơn Python rất nhiều.
Giải pháp
Đặt values() trước annotate(): cột trong values() là khóa nhóm, annotate() tính toán trên mỗi nhóm:
from django.db.models import Count, Sumreport = ( Order.objects.values("category__name") .annotate(total=Sum("amount"), num_orders=Count("id")) .order_by("-total"))for row in report: print(row["category__name"], row["num_orders"], row["total"])
Django phát sinh đúng một câu SQL:
SELECT category.name, SUM(order.amount), COUNT(order.id)FROM order JOIN category ON order.category_id = category.idGROUP BY category.nameORDER BY SUM(order.amount) DESC
Ba điểm cần nhớ để dùng đúng. Thứ nhất, kết quả là dict chứ không phải model instance, nên không gọi được .save() — cần dữ liệu nào thì lấy thêm cột đó ngay trong values(). Thứ hai, thứ tự đặt filter() quyết định ý nghĩa: trước annotate() là WHERE (lọc bản ghi đầu vào), sau annotate() là HAVING (lọc theo cột vừa tính, ví dụ chỉ nhóm có doanh thu trên 1 triệu). Thứ ba, mọi cột trong order_by() đều bị đưa vào GROUP BY — chỉ sắp xếp theo cột đã gom nhóm hoặc cột annotate (như -total), và gọi .order_by() không tham số để xóa thứ tự lạ.
Áp dụng vào dự án của bạn
Lần tới khi làm dashboard hay báo cáo “theo từng…”, hãy tự hỏi: mình có đang tải cả bảng về rồi gom nhóm bằng Python không? Nếu có, chuyển sang values() + annotate() ngay — một query duy nhất. Một thói quen tốt khác: kiểm tra câu SQL thật bằng print(queryset.query) trước khi lên production — một order_by() lạ cũng đủ làm kết quả nhóm sai mà không báo lỗi.
Bài viết liên quan
- Django: tính toán trên queryset bằng annotate() thay vì vòng lặp Python
- Django: tính tổng, đếm, trung bình trong 1 query bằng aggregate(), khỏi vòng lặp Python
- Django: queryset nhẹ hơn trong 5 phút bằng values() và values_list()
Câu hỏi thường gặp
values() + annotate() khác gì annotate() đơn thuần?
annotate() đơn thuần gắn thêm cột tính toán cho mỗi bản ghi (ví dụ mỗi tác giả kèm số bài viết). values() đặt trước annotate() đổi ý nghĩa: queryset bị gom nhóm theo cột trong values() và mỗi nhóm chỉ còn một dict — tương đương GROUP BY trong SQL.
Kết quả trả về là gì, có dùng như model được không?
Không. values() biến queryset thành danh sách dict, mỗi dict gồm các cột trong values() cộng thêm các cột annotate. Bạn không gọi được phương thức model hay .save() trên dict.
Lọc theo cột vừa tính thì đặt filter() ở đâu?
Đặt filter() sau annotate() — ví dụ .annotate(total=Sum("amount")).filter(total__gt=1000000) — Django dịch thành HAVING, lọc trên kết quả đã gom nhóm. Đặt trước annotate() thì chỉ lọc bản ghi đầu vào, tương đương WHERE.
Vì sao kết quả nhóm bị tách nhỏ bất ngờ dù code nhìn đúng?
Thường do bạn sắp xếp theo cột không nằm trong nhóm, ví dụ .order_by("status"): Django đưa cột đó vào GROUP BY nên một nhóm bị tách theo từng trạng thái. Chỉ sắp xếp theo cột đã gom nhóm hoặc cột annotate, hoặc gọi .order_by() không tham số rồi sắp xếp lại bằng .order_by("-total").
💬 Bạn đã thử cách này trong dự án của mình chưa? Nếu gặp lỗi hoặc có cách làm gọn hơn, để lại bình luận bên dưới — mình đọc hết và sẽ bổ sung những ý hay vào bài.
