開発

Django ORMのクエリ設計|遅延評価と集計の順序・生SQLへ落とす判断

Django ORMのクエリ設計|遅延評価と集計の順序・生SQLへ落とす判断

Djangoのビューでfilter()を書いた時点では、データベースへのSQLは1本も飛んでいません。飛ぶのはテンプレートでループが回った瞬間です。この時差を把握しないままコードを積むと、1画面で数百本のクエリが出たり、集計値が実際の件数と合わなくなったりします。この記事では、QuerySetが評価される条件、条件をデータベース側へ寄せる書き方、書き込み系メソッドがシグナルを飛ばさない仕様、集計の順序が数字を変える仕組み、生SQLへ落とす線引きを、Django 6.1系の公式ドキュメントを根拠に整理します。

まとめ:Django ORMのクエリ設計で先に決める4つの判断

実務で効くのは、メソッドの一覧を覚えることではありません。次の4点をコードの書き始めに決めておくことです。第一に、QuerySetをどこで評価させるか。filter()やorder_by()は評価を起こさず、イテレーション・list()・bool()・添字アクセス・get()で初めてSQLが出ます。変数に受けて使い回せば1回、書くたびにModel.objects.all()と打てばその回数だけ飛びます。

第二に、条件と計算をどちら側で回すか。Pythonのリスト内包表記で絞る処理は、ほぼすべてfilter()とルックアップ、Q、F式でデータベースへ寄せられます。第三に、書き込みをまとめるか。bulk_create()とupdate()はクエリを1本に畳む代わりにsave()を呼ばず、pre_saveとpost_saveも飛ばしません。auto_nowが更新されない不具合の大半はここが原因です。

第四に、どこでORMを降りるか。公式ドキュメントは生SQLの前にORMを一通り検討するよう促していますが、ウィンドウ関数やデータベース固有機能を使う場面ではraw()のほうが読みやすくなります。降りる条件は最終章で言い切ります。なお本記事はDjangoの全体像とMVTの解説を前提知識として扱い、フレームワーク自体の入門は繰り返しません。

QuerySetが評価される瞬間とキャッシュが効かなくなる書き方

Django ORMの挙動が読みにくくなる原因は、コードの見た目とSQLの発行タイミングがずれる点にあります。まずこの境界を固定します。

filterやexcludeの時点ではSQLが飛ばない遅延評価の境界

公式ドキュメントは「QuerySets are lazy」と明記し、QuerySetを組み立てる操作とデータベースにアクセスする操作を分けています。filter()・exclude()・order_by()は新しいQuerySetを返すだけで、何回チェーンしてもクエリは発行されません。

評価が起きるのは、イテレーション、list()への変換、bool()による真偽判定、inによるメンバーシップ判定、repr()を伴う出力、添字アクセス、get()、first()といった操作です。ビュー関数の中で条件を積み、テンプレートのループで初めて1本のSELECTが出るのが標準の姿です。スライスは内部でSQLのLIMITとOFFSETへ変換されます。

同じQuerySetを使い回してクエリを1回に収める実装の型

新しく作られたQuerySetのキャッシュは空です。最初に全体が評価された時点でキャッシュが埋まり、以降の評価はキャッシュを再利用します。逆に言えば、同じ問い合わせを2回書けばクエリも2本出ます。

公式が挙げる悪い例は、Entry.objects.all()を2つの内包表記でそれぞれ書く形です。変数に受けてから2回ループすれば、データベースアクセスは1回で済みます。ビューでmembers = group.members.all()と受けておけば、その後の真偽判定、メンバーシップ判定、len()による件数取得、ループのすべてがキャッシュから返ります。

件数だけが欲しいならcount()、1件でもあるかを見たいならexists()を使い、両方を続けて呼ばないことです。exists()の直後にlen()を書けば、2本のクエリが出ます。

スライスと添字参照でキャッシュが埋まらない仕様と回避の書き方

キャッシュには例外があります。公式の記述は「QuerySetの一部だけを評価した場合、キャッシュは参照されるが、埋まっていなければ後続のクエリが返した項目はキャッシュされない」というもの。同じ添字への参照を2回書けば、データベースへは2回問い合わせが飛びます。

先にlist(qs)やループで全体を評価しておけば、その後の添字アクセスはキャッシュから返ります。出力による確認も注意が必要で、QuerySetをそのまま表示する操作は先頭21件を取るスライスとして働くためキャッシュを埋めません。デバッグで一度表示してから本処理でループすると、開発環境では気づかないまま2本のクエリが出ます。

条件をPython側ではなくデータベース側へ寄せるfilterとF式の書き方

公式ドキュメントは性能改善の原則として「PythonではなくデータベースでDB作業を行う」を挙げています。ORMのどの表現がその原則に対応するかを具体的に見ます。

ルックアップとQオブジェクトで複合条件を1クエリへ畳む書き方

フィールド名にダブルアンダースコアを付けたルックアップが、SQLのWHERE句に対応します。__gtで比較、__containsでLIKE、__inでIN、__yearで日付の年抽出という具合に、条件式をそのままデータベースへ渡せます。全件を取ってからPythonでifを書く形は、インデックスが使われません。

OR条件やNOT条件にはQオブジェクトを使います。&でAND、|でOR、^でXOR、先頭の~で否定を表し、キーワード引数と併用するときはQが先です。公式の例では、質問文が特定の語で始まり、かつ公開日が2つの日付のいずれかという条件が、1本のSELECT文へまとめて変換されています。

この条件を画面のGETパラメータから組み立てる層は、django-filterで一覧検索を実装する|FilterSet設計とDRF指定の判断で扱います。

F式で他フィールドを参照して更新の取りこぼしを避ける実装手順

F式は、値をPythonへ持ってこずにデータベース側のカラムを直接参照します。コメント数がピンバック数の2倍を超える行を絞る、といった条件を、カラム同士の比較と四則演算で書けます。

効き目が大きいのは更新です。カウンタを1増やす処理をPythonで書くと、読み出し・加算・保存の3ステップの間に他のリクエストが同じ行を書き換え、片方の加算が消えます。update(number_of_pingbacks=F("number_of_pingbacks") + 1)なら加算はデータベース内で完結するため、在庫数やアクセス数のように同時更新が起こる列ではこちらを既定にしてください。

制約もあります。update()で更新できるのはモデルの主テーブルのカラムだけで、リレーション先の値を持ってきて代入する書き方はFieldErrorになります。絞り込みは関連フィールドを跨げるのに、更新は跨げないという非対称です。

values・only・deferで転送するカラムを絞る判断の順序

取得するカラムを減らす手段は3系統あり、副作用の重さが違います。辞書やタプルで受け取ってよいならvalues("headline", "pub_date")やvalues_list("headline", flat=True)が最も素直で、モデルインスタンスの生成コストも省けます。

モデルとして扱いたいが巨大なテキスト列だけ外したい場合はdefer("body")、逆に数列だけ欲しい場合はonly("headline")です。ただし遅延させたフィールドへ後から触れると追加のクエリが飛び、save()ではロード済みのフィールドしか保存されません。主キーとselect_related()で辿る接続フィールドは遅延できない仕様です。公式が「他の選択肢を尽くしたあとで使う」と位置づけているとおり、計測せずに入れる対象ではありません。

values()には別の落とし穴もあります。多対多や逆方向のリレーションを含めると、関連レコードの数だけ行が返り、親側が何度も現れます。件数を数える処理と組み合わせると数字が合いません。関連先をまとめて取る話はN+1問題の仕組みとselect_related・prefetch_relatedの使い分けで扱っています。

create・bulk_create・updateで書き込みを1クエリへ寄せる判断

読み取り側を整えても、書き込みがループの中にあると本数は減りません。一括系メソッドは強力ですが、モデル層を素通りする副作用があります。

bulk_createでsaveとシグナルが飛ばなくなる副作用の範囲

ループでcreate()を呼べばレコード数だけINSERTが出ます。bulk_create()にリストを渡せば原則1本のINSERTにまとまり、batch_sizeで分割数も指定可能。重複を無視するignore_conflicts、衝突時に更新するupdate_conflictsも用意されています。

代償として、save()が呼ばれないためモデルのsave()に書いた前処理は動かず、pre_saveとpost_saveも送出されません。多対多フィールドは扱えず、多テーブル継承の子モデルでも使えません。監査ログをシグナルで取っている実装では、一括投入の分だけログが欠けます。

update()がauto_nowとpost_saveを素通りする仕様への対処

update()は直接SQLのUPDATE文へ変換されます。速い代わりに、save()が呼ばれず、pre_saveとpost_saveが送出されず、auto_nowを指定した更新日時フィールドも動きません。「一括更新したレコードだけ更新日時が古い」という不具合は、ここを踏んでいます。

シグナルやsave()の処理が必要なら、公式が示すとおりQuerySetをループして個別にsave()を呼びます。更新日時だけが目的なら、その値をupdate()の引数へ明示的に渡せば1クエリのままで辻褄が合う書き方です。書き込み系メソッドの性質は次の表で切り分けられます。削除側で連鎖と論理削除をどう設計するかはDjango on_deleteの選び方:7種の挙動と6.1のDB_*系・削除設計にまとめています。

書き方 クエリ本数 save()とシグナル auto_now
create()をループ 件数ぶん 呼ばれる 効く
bulk_create() 原則1本 呼ばれない 効かない
save()をループ 件数ぶん 呼ばれる 効く
update() 1本 呼ばれない 効かない
bulk_update() 原則1本 呼ばれない 効かない

判断の順序は単純です。副作用をモデルのsave()やシグナルに載せている箇所では一括系を避け、載せていない集計テーブルやフラグ更新では一括系へ寄せます。

ループ内のsaveを一括メソッドへ置き換える手順と多対多の扱い

既存コードを直す順序は、影響範囲の小さいものからです。

  1. 属性を書き換えたインスタンスを集め、bulk_updateに対象フィールド名を渡して1本に畳む
  2. 多対多の追加はmembers.add(me, my_friend)のように引数をまとめて1回で呼ぶ
  3. 中間テーブルに追加情報がある場合はthrough属性でモデルを取り出して一括投入する
  4. 削除は個別のremove()を並べず、Qで条件を結合したfilter().delete()に置き換える

4手ともDjango公式のデータベースアクセス改善ページに例示された形です。大量投入の前後でインデックスや制約を触る場合の適用順序は、Django migrateの本番適用と巻き戻しの手順にまとめています。

annotateとaggregateの順序が集計値を変える仕組みと重複の回避

集計はORMで最も誤りが見つけにくい領域です。エラーにならず、静かに違う数字を返すからです。

annotateとfilterの前後で件数と平均値が変わる公式の実例

aggregate()はQuerySet全体を1つの辞書へ畳む終端の操作、annotate()は各オブジェクトへ集計列を足して次の操作へ渡す非終端の操作です。この違いに加えて、annotate()とfilter()のどちらを先に書いたかで結果が変わります。

公式の例では、評価4と5の書籍を持つ出版社Aと、評価1と4を持つ出版社Bに対し、先に書籍数を数えてから評価3.0超で絞ると、AもBも2冊と数えられます。逆に評価3.0超で絞ってから数えると、Aは2冊、Bは1冊です。平均でも同様で、前者はA 4.5・B 2.5、後者はA 4.5・B 4.0になります。

実装時は「全体を数えてから絞る」のか「絞ってから数える」のかを日本語で確定し、その語順どおりにメソッドを並べてください。仕様書の一行がどちらの解釈かを確かめずにコードへ落とすと、数字がずれたまま納品されます。

複数のCountを重ねたときに件数が膨らむ原因とdistinctの位置

1つのQuerySetへ複数の集約を重ねると、JOINで行が掛け合わされます。公式の例では、著者2人・取扱店3店の書籍に対して著者数と店舗数を同時に数えると、どちらも6を返します。2×3の直積が数えられているためです。

それぞれのCountにdistinct=Trueを付けると、2と3が返ります。この現象はテーブルの規模が小さい開発環境ほど見逃されやすく、本番のデータ量で初めて不自然な数字として現れます。集計を書いたらクエリオブジェクトを文字列化し、JOINが何本入っているかを確認してください。

valuesとannotateの順序でグループ化の単位が変わる仕組み

values()をannotate()より前に置くと、指定した列がGROUP BYの単位になり、その組み合わせごとに集計されます。後に置くと、まずQuerySet全体に注釈が付き、values()は出力する列を絞るだけです。注釈列を結果へ含めたい場合は明示的に指定します。

月次の売上を部門別に出すような集計は、この順序でレポートの粒度そのものが変わります。アプリケーションのデータベースへ長時間の集計クエリを投げ始めた段階が、設計を見直す分岐点です。基幹の書き込みと分析の読み取りを同じインスタンスで抱えるとロック競合と待ち時間が増えます。切り離し先の設計や運用体制はデータ分析基盤の構築とMLOpsの支援で扱っており、ORM側で粘るか基盤側へ寄せるかの判断材料になります。

atomicとselect_for_updateをコードのどこに置くかの実装判断

Djangoは既定でオートコミットで動きます。トランザクションを意識するのは、複数の書き込みを1つの単位にしたいときと、行を掴んだまま更新したいときです。

atomicの内側でDatabaseErrorを捕まえてはいけない理由

transaction.atomic()はデコレータとしてもコンテキストマネージャとしても使えます。入れ子にした場合、外側が実際のトランザクションを開始し、内側はセーブポイントを作る動作です。内側が正常終了すればセーブポイントは解放され、変更は外側のトランザクションへ含まれます。

やってはいけないのは、atomicブロックの内側でDatabaseErrorを捕まえて処理を続けることです。データベースエラーが起きた時点でトランザクションは壊れた状態になり、そのままクエリを投げるとTransactionManagementErrorが発生します。公式が示す正しい形は、失敗しうる処理を内側のatomicで囲み、その外側でIntegrityErrorを捕まえるものです。内側の変更だけが巻き戻り、直前の書き込みと後続の処理は生きたまま進みます。

設定側にはATOMIC_REQUESTSもあります。全ビューをトランザクションで包む一方、公式はトラフィックが増えたときの負荷を警告しており、読み取り中心のビューまで包むのは割に合いません。既定は個別のatomicとし、包みたい管理系だけ設定を有効にしてnon_atomic_requestsで例外を作る順序が扱いやすい構成です。

select_for_updateがデータベースごとに効かなくなる条件の切り分け

在庫の引き当てや採番のように、読んでから書くまでの間に他のリクエストを待たせたい場面ではselect_for_update()で行ロックを取ります。ただし利用条件が細かく、環境によって静かに無効化されます。

データベース nowait skip_locked of no_key
PostgreSQL 対応 対応 対応 対応
MySQL 対応 対応 対応 非対応
MariaDB 対応 対応 非対応 非対応
SQLite 効かない 効かない 効かない 効かない

まず自動コミットモードでは使えず、TransactionManagementErrorになるためatomicの内側に置きます。次に、開発環境のSQLiteでは例外も警告も出ないまま効果だけが消える点。ローカルで通ったコードが本番のPostgreSQLで初めてロック待ちを起こします。nowaitとskip_lockedは同時に指定できません。null許容のリレーションにも適用できないため、該当行をexclude()で除外してから呼びます。接続先による差はPostgreSQLとMySQLの違いと使い分けを参照してください。

PostgreSQL側の接続設定そのもの、とくに永続接続とプールの分岐やPgBouncer併用時にiterator()が壊れる条件は、DjangoとPostgreSQLの接続設定にまとめています。

ORMのまま押し切る条件と生SQLへ落とす条件の線引きと実装手順

ここからは判断を明確にします。生SQLは禁じ手ではありませんが、落とす条件を決めずに使うと保守側が壊れます。

rawとconnection.cursorで戻り値と使える操作が変わる違い

生SQLの入口は2つです。Manager.raw()はSQLの結果をモデルインスタンスへマップして返すため、テンプレートやシリアライザへそのまま渡せます。ただし常に新しいクエリを実行し、前段のfilter()を引き継ぎません。

connection.cursor()はモデル層を完全に迂回します。cursor.execute(sql, [params])で実行し、fetchone()やfetchall()で結果を受け取る形です。モデルにマップできない集計や、UPDATE・INSERT・DELETEを直接投げたい場合はこちらになります。戻り値は辞書ではなくタプルの並びで、列の順序に依存したコードになりがちです。

生SQLへ落としてよい3条件とORMで押し切るべき場面の見分け方

落としてよいのは次の3条件のいずれかに当てはまるときです。第一に、ウィンドウ関数や再帰CTEのように、ORMの式で書くとネストが深くなりレビューで読めなくなる集計。第二に、全文検索や地理情報型のようなデータベース固有機能を使い、移植性を捨てる判断が済んでいる場合。第三に、一度きりのデータ移行バッチで、アプリケーションの実行経路に残らない場合です。

逆に、押し切るべき場面も条件付きで挙げます。ORMで書けるのに「生SQLのほうが速そう」という推測だけで落とすのは採用しません。単純なJOINや集約は式で表現でき、速度差もほとんど出ないためです。もう1つ、モデルのマネージャに権限やテナントの絞り込みを載せている実装では、生SQLへ落としてはいけません。raw()もcursorもそのマネージャを通らず、他社データが混ざる事故に直結します。中間手段としてannotate()の中でRawSQL式を使い、SQLの断片だけを差し込む書き方も公式に示されています。まずこちらを試してください。

生SQLを残すときにパラメータ化と回帰テストで守る範囲の決め方

生SQLを残す判断をしたら、守る対象を3つに絞ります。パラメータ化が破れる箇所とDjango本体側のSQLインジェクション修正の経緯はDjangoの脆弱性への対応で扱っています。ユーザーが操作できる値は必ずparamsで渡し、文字列連結で組み立てないこと。これはSQLインジェクションへの唯一の防御線で、例外を作らない箇所です。

次に、生SQLが参照する列名をスキーマ変更の影響範囲リストへ登録すること。ORMのクエリは列名を変えれば追随しますが、文字列のSQLは実行時まで壊れたことが分かりません。最後に、置き換え前後で件数と代表値が一致するかを比較するテストを残すこと。計測にはQuerySet.explain()とdjango-debug-toolbarが使え、公式も「まず計測せよ」を最初の原則に置いています。

よくある質問

Django ORMの設計で実装者から挙がりやすい質問を、公式ドキュメントの記述と対応づけて整理します。

Django ORMが発行したSQLを確認する方法はありますか?

3通りあります。QuerySetのquery属性を文字列化すれば、組み立てられたSQL文が読めます。実行計画まで見たい場合はQuerySet.explain()を使い、データベース側がどのインデックスを選んだかを確認してください。画面単位でクエリ本数と所要時間を追うなら、django-debug-toolbarのSQLパネルが手早い方法です。

get()とfilter().first()はどちらを使うべきですか?

1件に必ず定まるはずの取得にはget()を使います。該当が無ければDoesNotExist、2件以上あればMultipleObjectsReturnedが投げられ、データの前提が崩れたことをその場で検出できるためです。無いかもしれない値を取るならfilter().first()が適します。こちらは該当が無ければNoneを返すだけで、例外処理を書かずに分岐できます。

select_relatedとprefetch_relatedはどちらを使いますか?

おおまかには、1対1と多対1の関係はselect_related()でJOINして1クエリにまとめ、多対多と逆方向の関係はprefetch_related()で別クエリを1本追加してPython側で結合します。ただしonly()との併用制約や、事前取得の後にfilter()を呼ぶとクエリが復活する挙動など、踏みやすい落とし穴もある領域。詳細はN+1問題の検出方法とORM別の対策で整理しています。

bulk_create()で主キーは受け取れますか?

サーバー側の自動採番に対応したデータベースであれば、作成されたオブジェクトへ主キーが設定されます。対応していない環境では設定されないため、投入直後に主キーを前提とした後続処理を書いていると動きません。主キーを確実に使う処理では、投入後に一意キーで引き直す設計にしておくと環境差の影響を受けません。

Django ORMのままで性能が出ないときは何から見ますか?

順序は、計測、クエリ本数、転送量、データベース設計の4段階です。まずdjango-debug-toolbarで1画面あたりの本数を数え、同じSQLが繰り返されていればN+1を疑います。本数が少ないのに遅いならexplain()で実行計画を見てください。ここまでで解決しない場合に初めて、取得カラムの削減や集計処理の切り出し、生SQLへの部分的な置き換えを検討します。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

ほか 2 件の記事からもリンクされています。

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.09 テックブログ IDCFクラウド(IDCフロンティア)不正アクセス・ランサムウェア:影響先・復旧・データは戻るか
  2. 2025.11.04 テックブログ Runpodとは?料金・使い方・支払い方法とGPUクラウドの選び方【2026年10月】
  3. 2026.01.09 コラム 違背実験とは?読み方・意味と具体例、ガーフィンケル原典の数値とエスノメソドロジー
  4. 2026.10.09 テックブログ ニッスイのサイバー攻撃で日水物流の入出荷停止|委託先クラウド障害に荷主が備える手順

RELATED POSTS 関連記事

目次