コードレビューで見つかった問題を、勉強のためにまとめたものです(修正済み)。
この記事でわかること
- Django の
.first()が暗黙で PK 昇順になる仕様と、prefetch_related経由でその暗黙の並び順が消える理由 - 「N+1 を直す」という正しい最適化が、思わぬ回帰を呼ぶパターン
Prefetch.querysetにorder_by()を必ず書くべき理由
Django prefetch_related 後の meanings[0] が非決定的で、画面によって表示が変わる
概念
Django ORM の .first() は、明示的な .order_by() が無い QuerySet に対しても PK 昇順を暗黙で適用します (Django 公式ドキュメントに明記された挙動)。
一方 prefetch_related(Prefetch(queryset=..., to_attr="...")) で取り出した Python list は その QuerySet 自体の並びに従います。queryset に order_by() が無ければ DB のデフォルト(PostgreSQL の場合はしばしば「書き込み順」とは限らない)で、list[0] が何を返すかは実装依存になります。
修正前のコード
N+1 問題を解消するため、シリアライザに prefetch キャッシュ経路を追加した。
class AlbumPhotoSerializer(serializers.ModelSerializer):
def _first_owned_meaning(self, obj):
meanings = getattr(obj.photo, "owned_meanings", None)
if meanings is None:
# フォールバック: 旧挙動。.first() は PK 昇順を暗黙適用するので決定的
return obj.photo.meanings.filter(owner=obj.album.user).first()
# 新規: prefetch されたリストの先頭。並び順は queryset 次第
return meanings[0] if meanings else None# View 側
qs = qs.prefetch_related(
Prefetch(
"album_photos__photo__meanings",
queryset=Meaning.objects.filter(owner=self.request.user), # ← order_by 無し
to_attr="owned_meanings",
)
)同一写真に複数の Meaning を持つオーナーでは、アルバム詳細 API のレスポンスが リクエストごとに別の Meaning を返す 可能性があった。
なぜ危険か
- アルバム詳細のキャプションと「お気に入り対象」が リクエストをまたぐと一致しなくなります
- 共有アルバムが同じシリアライザを再利用しているので、別ユーザーから見るページでキャプションが揺れる可能性
対策
qs = qs.prefetch_related(
Prefetch(
"album_photos__photo__meanings",
queryset=Meaning.objects.filter(owner=self.request.user).order_by("id"),
# もしくは UI 要件に合わせて .order_by("-updated_at") など
to_attr="owned_meanings",
)
).first() フォールバック経路と 同じソート を Prefetch.queryset にも書くのが鉄則。
(補足: モデル側に Meta.ordering を持たせればそれも効くが、それに頼らず Prefetch.queryset 側でも明示しておくほうが安全 ─ 「どの層がソートを保証しているか」を読む人が1箇所で追えるようになります。)
学びポイント
- prefetch は「速くするため(N+1 解消)」に View でやります。でも "並べてね"(
order_by)も同じ View の prefetch で言っておかないと、シリアライザーがlist[0]で先頭を取るときにブレます - N+1 最適化で prefetch を入れるときは、必ず元の
.first()/.last()の並び順をqueryset.order_by()で明示複製します - テスト: 同一写真に複数 Meaning を持つ fixture を用意し、同じリクエストを 10 回連続で叩いて結果が変わらないことを assert すると回帰検出できます
おわりに
今回の話は 「正しい方向の変更」が、別の場所で回帰を呼んだパターンだった。N+1 解消は DB クエリを減らす正しい最適化なのに、その副作用として並び順の決定性が壊れる、という気付きにくい構造になっていた。
テスト環境では再現しないが本番だけ揺れる、ということが起きえます。.first() を list[0] に置き換えるときは、ソートの責任が誰にあるかを意識的に引き継ぎます。今後も使う最適化パターンなので、Prefetch.queryset の order_by() 漏れを定型チェックに入れておきます。
この記事は2026年4月時点の自分の理解で書いています。Django の
.first()の挙動は公式ドキュメントに明記されているが、ORM の細かい仕様は時とともに変わる。最新の公式ドキュメントも合わせて参照してください。