紡Book の勉強ノートに戻る
コードレビュー学習2026.04.16

「先頭の1件」は並び順を指定しないと毎回変わる — Django prefetch_related の落とし穴

コードレビューDjangoORMリファクタ

コードレビューで見つかった問題を、勉強のためにまとめたものです(修正済み)。


この記事でわかること

  • Django の .first() が暗黙で PK 昇順になる仕様と、prefetch_related 経由でその暗黙の並び順が消える理由
  • 「N+1 を直す」という正しい最適化が、思わぬ回帰を呼ぶパターン
  • Prefetch.queryset に order_by() を必ず書くべき理由

概念

Django ORM の .first() は、明示的な .order_by() が無い QuerySet に対しても PK 昇順を暗黙で適用します (Django 公式ドキュメントに明記された挙動)。

一方 prefetch_related(Prefetch(queryset=..., to_attr="...")) で取り出した Python list は その QuerySet 自体の並びに従います。queryset に order_by() が無ければ DB のデフォルト(PostgreSQL の場合はしばしば「書き込み順」とは限らない)で、list[0] が何を返すかは実装依存になります。

正本は PostgreSQL の photos_meaning 表。prefetch の list はメモリで、保存しない。旧 .first()PostgreSQLphotos_meaning 表閉じても残る・正本PK 昇順を足して読む画面のキャプションいつも同じ行画面・保存しない新 list[0]PostgreSQL同じ photos_meaning 表閉じても残る・正本写すメモリ owned_meaningsPython list・保存しない並びは queryset 次第何も足さない画面のキャプション先頭がブレる画面・保存しない.first() も Prefetch も関所で、置き場所ではない。並びを足すのは旧だけ。新の list は写した順のまま先頭を取る。

修正前のコード

N+1 問題を解消するため、シリアライザに prefetch キャッシュ経路を追加した。

python
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
python
# 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 を返す 可能性があった。

同じシリアライザが、メモリを先に見る。無ければ DB へ戻る。無い(prefetch 無し)PostgreSQLphotos_meaning 表閉じても残る・正本.first() が PK 昇順を足す画面のキャプションいつも同じ行画面・保存しないある(prefetch 済み)メモリ owned_meaningsPython list・保存しないPhoto にぶら下がるlist[0] は何も足さない画面のキャプション先頭がブレる画面・保存しない読む先が違う。正本は photos_meaning 表。prefetch の list は保存しない。ある経路だけ順序が違う。シリアライザは関所で、置き場所ではない。

なぜ危険か

  • アルバム詳細のキャプションと「お気に入り対象」が リクエストをまたぐと一致しなくなります
  • 共有アルバムが同じシリアライザを再利用しているので、別ユーザーから見るページでキャプションが揺れる可能性

対策

python
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 の細かい仕様は時とともに変わる。最新の公式ドキュメントも合わせて参照してください。