集計処理を Python のループで書くと、件数が増えた瞬間に遅くなります。
DB に計算させる書き方を押さえると、桁違いに速くなります。
aggregate と annotate の違い
# aggregate: 全体で1つの値を返す(辞書)
Order.objects.aggregate(total=Sum("amount"))
# → {"total": 1250000}
# annotate: 各行に値を付ける(QuerySet)
Shop.objects.annotate(order_count=Count("orders"))
# → 各 shop に .order_count が付く
「全体の1つ」なら aggregate、「行ごと」なら annotateです。
ループを置き換える
# 遅い:N+1 かつ Python 側で加算
for shop in Shop.objects.all():
total = 0
for order in shop.orders.all():
total += order.amount
# 速い:DB が計算する
shops = Shop.objects.annotate(total=Sum("orders__amount"))
複数の集計を同時に書くときの罠
これは必ず一度は踏む問題です。
Shop.objects.annotate(
order_count=Count("orders"),
review_count=Count("reviews"), # ← 両方の数が狂う
)
異なるリレーションを2つ JOIN すると行が掛け算され、
両方の件数が実際より多くなります。
対処は distinct=True を付けるか、サブクエリを使います。
from django.db.models import OuterRef, Subquery, IntegerField
order_count = Subquery(
Order.objects.filter(shop=OuterRef("pk"))
.values("shop").annotate(n=Count("id")).values("n"),
output_field=IntegerField(),
)
Shop.objects.annotate(order_count=order_count)
件数が多い場合はサブクエリのほうが速くなります。
条件付き集計
from django.db.models import Case, When, IntegerField
Shop.objects.annotate(
paid=Count(Case(When(orders__status="paid", then=1), output_field=IntegerField())),
cancelled=Count(Case(When(orders__status="cancelled", then=1), output_field=IntegerField())),
)
filter 引数を使うともっと簡潔に書けます。
Shop.objects.annotate(
paid=Count("orders", filter=Q(orders__status="paid")),
)
日次・月次の集計
from django.db.models.functions import TruncMonth
(Order.objects
.annotate(m=TruncMonth("created_at"))
.values("m")
.annotate(total=Sum("amount"), n=Count("id"))
.order_by("m"))
values() を annotate の前に置くのが要点です。
これが GROUP BY の対象になります。順序を間違えると意図しない結果になります。
確認方法
print(qs.query) # 実際のSQLを見る
print(len(connection.queries)) # クエリ数を数える
集計を書いたら、必ず発行SQLを確認します。
まとめ
- 全体の1値は
aggregate、行ごとはannotate - 複数リレーションの同時 Count は結果が狂う(
distinctかサブクエリ) - 条件付き集計は
Count(..., filter=Q(...))が簡潔 - 期間集計は
values()の位置が GROUP BY を決める - 書いたら
qs.queryでSQLを確認する