データ・分析

Django ORM で集計する — annotate と aggregate を正しく使い分ける

集計処理を 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を確認する