Django の annote() は、クエリパフォーマンス改善にも使える便利な機能ですが、裏でいろいろと自動的に処理してくれているので思わぬ落とし穴にはまることがあります。
本記事では、そんな思わぬ事故を防ぐために、どんな挙動になっているのかを整理しています。
基本ルール
以下のモデルを想定
class Author(models.Model):
name = models.CharField(max_length=100)
class Book(models.Model):
title = models.CharField(max_length=100)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
rating = models.FloatField()
基本構文
QuerySet.annotate(新しく追加するフィールド名=集計関数や式)
* 代表的な集計関数: Count, Sum, Avg, Min, Max
- 例: 著者ごとの本の平均評価を取得する
from django.db.models import Avg
authors = Author.objects.annotate(avg_rating=Avg('book__rating'))
for author in authors:
print(author.name, author.avg_rating)
主キーでGROUP BYされる
annotate()を使うと、Djangoは暗黙的に主キー(idなど)を使ってGROUP BYします。
Author.objects.annotate(book_count=Count('book'))
SELECT "app_author"."id", "app_author"."name",
COUNT("app_book"."id") AS "book_count"
FROM "app_author"
LEFT JOIN "app_book" ON "app_author"."id" = "app_book"."author_id"
GROUP BY "app_author"."id"; -- ← 暗黙の主キーGROUP BY
主キーでGROUP BYされるため、各オブジェクトに対して1つずつ集計結果が付与されます。
authors = Author.objects.annotate(book_count=Count('book'))
for author in authors:
print(author.name, author.book_count)
values()でGROUP BYを明示する
values()とannotate()を併用すると、SQLのGROUP BYと同じ機能が実現できます。
Book.objects.values('author__name').annotate(total_books=Count('id'))
# 以下のような結果になる
# <QuerySet [{'author__name': '田中', 'total_books': 4}, {'author__name': '鈴木', 'total_books': 2}]>
自動的に関連モデルへのJOINを発生させる
annotate()で関連フィールド(ForeignKeyやManyToMany)を指定すると、JOINが自動で発生します。
Book.objects.annotate(author_name=F('author__name'))
SELECT "app_book"."id", "app_book"."title",
"app_author"."name" AS "author_name"
FROM "app_book"
LEFT JOIN "app_author" ON "app_book"."author_id" = "app_author"."id"; -- ← 自動JOIN
JOINを明示的に制御する方法
特に使われるのが以下の2つのメソッドです:
- select_related(): ForeignKeyやOneToOneの関連を即座にJOINする
- prefetch_related(): ManyToManyやOneToManyの関連を別クエリで取得する (JOINしない)
JOINを明示的に制御しない場合(デフォルト)
books = Book.objects.annotate(author_name=F('author__name'))
SELECT
"app_book"."id", "app_book"."title",
"app_author"."name" AS "author_name"
FROM "app_book"
LEFT JOIN "app_author"
ON "app_book"."author_id" = "app_author"."id";
select_related()を利用する場合
- 外部キー(ForeignKey)やOneToOneFieldで使える。
- 関連テーブルを即座にJOINして同時に取得する。
- SQLレベルでJOINを行い、クエリ数が削減される。
books = Book.objects.select_related('author').annotate(author_name=F('author__name'))
SELECT
"app_book"."id", "app_book"."title",
"app_author"."id", "app_author"."name",
"app_author"."name" AS "author_name"
FROM "app_book"
INNER JOIN "app_author"
ON ("app_book"."author_id" = "app_author"."id");
prefetch_related()を使用する場合
- ManyToManyFieldやOneToMany(逆方向参照)に使う。
- JOINを使わず、複数のクエリに分けて取得。
- Pythonのコード内で結合する仕組み(JOINは発生しない)。
authors = Author.objects.prefetch_related('book_set').annotate(book_count=Count('book'))
-- ① 著者の取得クエリ
SELECT "app_author"."id", "app_author"."name",
COUNT("app_book"."id") AS "book_count"
FROM "app_author"
LEFT JOIN "app_book" ON ("app_author"."id" = "app_book"."author_id")
GROUP BY "app_author"."id";
-- ② 本の取得クエリ (prefetch_relatedで自動追加)
SELECT "app_book"."id", "app_book"."title", "app_book"."author_id"
FROM "app_book"
WHERE "app_book"."author_id" IN (1, 2, 3, ...);
注意事項
ManyToManyやOneToManyの集計は重複に注意が必要(distinctの利用)
多対多や一対多の関連では、JOINでレコードの重複が起きることがあり、集計値がずれます。
この場合はdistinct=Trueを使い、重複を防止する必要があります。
Tag.objects.annotate(book_count=Count('book', distinct=True))
SELECT "app_tag"."id", "app_tag"."name",
COUNT(DISTINCT "app_book_tags"."book_id") AS "book_count"
FROM "app_tag"
LEFT JOIN "app_book_tags" ON "app_tag"."id" = "app_book_tags"."tag_id"
GROUP BY "app_tag"."id";
filterはannotateでフィールドを定義した後に
集計値に基づいてフィルタリングしたい場合、annotate()とfilter()を組み合わせます。
例えば、「本を2冊以上書いた著者」を取得する場合:
authors = Author.objects.annotate(book_count=Count('book')).filter(book_count__gte=2)
注意点として、annotateで定義した集計フィールドは、定義後のフィルタリング条件として使えますが、定義前には使えません。
# ❌ 間違い:annotateする前にフィルタリングは不可
Author.objects.filter(book_count__gte=2).annotate(book_count=Count('book'))
# ✅ 正しい:annotate後にフィルタリング
Author.objects.annotate(book_count=Count('book')).filter(book_count__gte=2)
補足
annotate() と aggregate() の違い
- annotate(): クエリセットの各オブジェクトに適用。各オブジェクトに集計値が追加されたクエリセット
- aggregate(): クエリセット全体に対して適用。クエリセット全体で1つの値(辞書型)を返す
# annotate():各著者に対して適用
Author.objects.annotate(book_count=Count('book'))
# aggregate():全体に対して適用
Author.objects.aggregate(total_books=Count('book'))