0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

本記事は、Django ORMで育った私が生SQLを学び直すシリーズの3本目、クエリ編です。シリーズは次の4本立てです。

  • モデル定義編: models.pyの定義がどんなテーブルになるのか
  • モデル変更編: makemigrationsとmigrateの裏でどんなALTER TABLEが走るのか
  • クエリ編(本記事): filterやupdateの裏でどんなクエリが走っているのか
  • パフォーマンスチューニング編: N+1問題とselect_related・prefetch_related、集計の裏側

前回までの2本で扱ってきたのは、CREATE TABLEやALTER TABLEといったDDL、つまりテーブルという入れ物を作り、変える話でした。入れ物の準備はできたので、今回からいよいよ中身のデータを操作するSQLに入ります。User.objects.get(id=2)と書いたとき、裏ではどんなSELECTが発行されているのか。createやupdateは、どんなINSERTやUPDATEになるのか。普段いちばんよく書いているORMのコードを、対応するSQLと並べてひたすら確認していく回です。

こうしたデータを操作するSQLはDDLと区別してDML(Data Manipulation Language、データ操作言語)と呼ばれます。モデル定義編の冒頭で「SELECTやWHERE、JOINといった単語は知っているものの、正確に説明できない」と書きましたが、いよいよそこへ踏み込みます。UPDATEだけはモデル変更編の最後に少しだけ先取りしましたが、今回はSELECTを主役に、INSERT・UPDATE・DELETEまでまとめて扱います。

モデルは前回までと同じサンプルモデル(User・Profile・Post・Tag・Comment)をそのまま使います。定義の全文とER図はモデル定義編を参照してください。ひとつ違うのは、今回はテーブルの中にデータが必要なことです。DDLは空のテーブルが相手でも読めましたが、クエリは結果が返ってこないと面白くありません。手元では、ユーザー3人・記事6本に、タグとコメントを紐付けた状態で確認しています。

独自に調べた内容のため、誤りを含むかもしれません。気づいた点があれば指摘してもらえると大変助かります。

ORMが発行するSQLを確認する方法

作業はpython manage.py shellで起動するDjangoシェルの中で行います。普段は、QuerySetのquery属性を見るだけで十分でした。

>>> qs = Post.objects.filter(status="draft")
>>> print(qs.query)
SELECT "blog_post"."id", "blog_post"."author_id", "blog_post"."title", "blog_post"."subtitle", "blog_post"."slug", "blog_post"."content", "blog_post"."status", "blog_post"."view_count", "blog_post"."published_at", "blog_post"."created_at", "blog_post"."updated_at" FROM "blog_post" WHERE "blog_post"."status" = draft

filter(status="draft")と書いた条件が、そのままWHERE句になっている様子が見えます。手軽で十分便利なので、本記事ではこの方法を基本に進めます。

なお、より厳密に「実行されたSQLそのもの」を見たいときは、connection.queriesでも確認できます1

なお、SELECTの後ろに全カラム名が列挙されて長いので、以降は本題に関係ない部分をSELECT ... FROMのように省略して表記します。この「全カラム列挙」自体にもちゃんと意味があるのですが、その話はvaluesの章でやります。

SELECT文の基本形を先に押さえる

個別のメソッドへ進む前に、SELECT文の骨格を一枚だけ見ておきます。この記事に出てくるSELECTは、結局すべてこの形の変奏だからです。

SELECT 列名, ...   -- どの列を
FROM テーブル名     -- どのテーブルから
WHERE 条件         -- どの行に絞って
ORDER BY 列名      -- どう並べて
LIMIT 件数;        -- 何件返すか

必須なのはSELECTとFROMだけで、残りは必要なときに足す部品です。そしてこの骨格は、普段書いているQuerySetのメソッドときれいに対応しています。

QuerySetの操作 SQLの句
filter / exclude WHERE
order_by ORDER BY
スライス([:10]) LIMIT / OFFSET
values / values_list SELECTの列指定
author__nameのような関連を辿るダンダー JOIN(FROMの拡張)

つまりQuerySetのメソッドチェーンは、この骨格の部品を1つずつ埋めていく行為です。本記事はこの対応表を、実際のSQLで1行ずつ確かめていきます。

まずデータを作る: INSERT

読み取りのクエリはデータがないと面白くないので、書き込む側から確かめます。いちばんよく使うcreateからです。

>>> User.objects.create(name="dave", email="dave@example.com")
INSERT INTO "blog_user" ("name", "email") VALUES ('dave', 'dave@example.com') RETURNING "blog_user"."id"

行の挿入はINSERT文の仕事です。基本形は、列のリストと値のリストを対応させる形です2

INSERT INTO テーブル名 (列名, ...) VALUES (, ...);

出力を基本形と見比べると、気づくことが2つあります。まず、列リストにidがありません。idはモデル定義編で見たとおりGENERATED BY DEFAULT AS IDENTITYの列なので、指定しなければDB側で採番されます。

もうひとつが末尾のRETURNINGです。基本形にはない部品ですが、これはINSERTした行の指定列を返してもらうPostgreSQLの拡張構文です2。DB側で採番されたidを、INSERTと同じ1往復で受け取るために付いています。createの戻り値でuser.idがすぐ使えるのは、このRETURNINGのおかげでした。

save()はUPDATEを先に試す

createの実体は「インスタンスを作ってsave()を呼ぶ」なので、save()も見ておきます。気になるのは、save()がINSERTを打つかUPDATEを打つかをどう決めているかです。試すと、分かれ目はpkでした。pkが空のインスタンスなら、上とまったく同じINSERTです。面白いのはpkに値が入っている場合で、存在しないid=999を指定してsave()すると、SQLが2本出てきます。

UPDATE "blog_user" SET "name" = 'eve', "email" = 'eve@example.com' WHERE "blog_user"."id" = 999
INSERT INTO "blog_user" ("id", "name", "email") VALUES (999, 'eve', 'eve@example.com') RETURNING "blog_user"."id"

存在するid=1で同じことをすると、1本目のUPDATEだけで終わります。つまりsave()は、その行が存在するかをSELECTで確かめたりしません。とりあえずUPDATEを打ってみて、更新された行数が0だったらINSERTに切り替える、という2段構えでした。存在確認のためだけのクエリを1本節約する設計です。

bulk_createは1本のINSERT

複数件まとめて作るときにループでcreateすると、3件なら3本のINSERTが飛びます。bulk_createなら1本にまとまります。

>>> Tag.objects.bulk_create([Tag(name="web"), Tag(name="db"), Tag(name="dev")])
INSERT INTO "blog_tag" ("name") SELECT * FROM UNNEST(('{web,db,dev}')::varchar[]) RETURNING "blog_tag"."id"

1本にはなったのですが、予想と形が違いました。VALUESはVALUES ('web'), ('db'), ('dev')のように行を並べて書けるので、それが来ると思っていたのです。実際に出てきたのは初見の関数UNNESTでした。

調べてみると、まずINSERTにはVALUESの代わりにSELECTの結果を流し込むINSERT INTO ... SELECT ...という構文があります2。そしてUNNESTは、配列を行に展開するPostgreSQLの関数です。つまりこのSQLは、'{web,db,dev}'というvarcharの配列を3行に展開して、その結果をINSERTに流し込んでいます。これはDjango 5.2で入ったPostgreSQL向けの最適化で、以前は予想どおりVALUESを並べる形でした3。配列ごと1つの値として渡せるので、件数が増えてもSQL文の形は変わりません。そこが効くようです。

get_or_createは1本のSQLではない

「あれば取得、なければ作成」のget_or_createはどうなるのか。既存にない名前で呼ぶと、4文も出てきました。

>>> Tag.objects.get_or_create(name="new-tag")
SELECT "blog_tag"."id", "blog_tag"."name" FROM "blog_tag" WHERE "blog_tag"."name" = 'new-tag' LIMIT 21
BEGIN
INSERT INTO "blog_tag" ("name") VALUES ('new-tag') RETURNING "blog_tag"."id"
COMMIT

名前のとおり「SELECTして、なければINSERT」で、こういう複合的なメソッドは1本の特別なSQLではなく複数クエリの組み合わせでした。BEGINとCOMMITは、モデル定義編の冒頭でマイグレーション全体を包んでいたトランザクションの構文です。DjangoはこのINSERTをトランザクションで包んでいて、別の処理が同時に同じ名前を作って一意制約違反になっても、中途半端な状態を残さず処理できるようにしています。ちなみに、すでにトランザクションの中で呼ばれた場合は、この部分がSAVEPOINT/RELEASE SAVEPOINT(トランザクション途中の中間セーブポイント)に変わります。兄弟分のupdate_or_createも試したところ、こちらはSELECTの末尾にFOR UPDATEという行ロックの指定が付いていました。トランザクションの使いこなしや行ロックはこのシリーズでは深追いせず、そういう守りが入っていることだけ覚えて進みます。

それより気になるのは1文目のSELECTです。頼んでいないのに、LIMIT 21という中途半端な数字が付いています。実はこれ、get_or_createが内部でgetを使っているからです。次の章の主役に進みます。

1件取得と全件取得

getとLIMIT 21

>>> User.objects.get(id=2)
SELECT "blog_user"."id", "blog_user"."name", "blog_user"."email" FROM "blog_user" WHERE "blog_user"."id" = 2 LIMIT 21

get(id=2)はWHERE句の"id" = 2になりました。SELECT文としてはfilterの結果と同じ形で、getだから特別なSQLになるわけではありません。ただ、やはりLIMIT 21が付いています。1件取得のメソッドなのにLIMITなし(全件)でもLIMIT 1でもなく、21。

調べてみると、Djangoのソースコードにはそのものずばりの定数MAX_GET_RESULTS = 21がありました。getの仕様から逆算された数字です。getは、0件ならDoesNotExist、2件以上ならMultipleObjectsReturnedを送出します。この「2件以上」を検出するだけなら2件取れば十分ですが、Djangoはエラーメッセージでヒット件数を報告してくれます。かといって件数を正確に数えるために全件読み込むと、巨大なテーブルを誤ってgetしたときに大惨事です。その妥協点が21件で打ち切り、でした。実際に件数を変えて試してみます。

>>> Post.objects.get(author_id=1)  # 3件ヒットする条件
MultipleObjectsReturned: get() returned more than one Post -- it returned 3!

>>> Tag.objects.get(name__startswith="t")  # 25件ヒットする条件
MultipleObjectsReturned: get() returned more than one Tag -- it returned more than 20!

20件までは正確に数えて報告し、21件目が見えたら「20件超」とだけ報告する。LIMIT 21は、エラーメッセージの親切さと安全性を両立させるための数字でした。

ちなみにget(pk=2)と書いても、発行されるSQLはget(id=2)と一字一句同じです。pkはプライマリキー(このモデルではid)のエイリアスで、SQLが組み立てられるより前の段階でidへ解決されています。

firstは順序を補う

同じ1件取得でも、firstはどうでしょうか。

>>> User.objects.first()
SELECT "blog_user"."id", "blog_user"."name", "blog_user"."email" FROM "blog_user" ORDER BY "blog_user"."id" ASC LIMIT 1

LIMIT 1は納得ですが、頼んでいないORDER BY id ASCが付いています。これはモデル定義編のMeta.orderingの節で学んだ「テーブルに行の順序という概念はない」の裏返しでした。順序のないものに「先頭の1件」は定義できません。そこで順序が未指定のときは、Djangoはpk昇順を補ってから先頭を取っているわけです。自分で順序を指定していれば、もちろんそちらが使われます。

>>> Post.objects.order_by("-published_at").first()
SELECT ... FROM "blog_post" ORDER BY "blog_post"."published_at" DESC LIMIT 1

並べて見ると、getとfirstの性格の違いがSQLにそのまま現れています。getは「条件に合う行は1件のはず」という表明で、違ったらエラーにするためのLIMIT 21。firstは「並べて先頭の1件だけほしい(なければNoneでよい)」で、そのためのORDER BY + LIMIT 1。どちらを使うかは、複数ヒットをエラーにしたいかどうかで選ぶものだ、というのがSQLから見えてきます。

all

全件取得のall()も見ておきます。

>>> list(User.objects.all())
SELECT "blog_user"."id", "blog_user"."name", "blog_user"."email" FROM "blog_user"

WHEREもLIMITもない、この記事でいちばん短いSELECTです。絞り込む部品を何も足していないので、骨格の必須部分だけが残った形です。ちなみにSQLには全列を意味するSELECT *という書き方がありますが、Djangoは*を使わず、モデルに定義したフィールドを毎回列挙します。テーブル側に何列あろうと、モデルが知っているフィールドだけを定義した順で受け取る形です。

WHERE句を組み立てる: filterとexclude

ここからはWHERE句です。filterが単純なWHEREになることは確認方法の章で見たので、条件を2つ渡すところから始めます。宣言どおり、ここからはSELECTの列の列挙を...と省略して表記します。

>>> list(Post.objects.filter(status="published", view_count__gte=100))
[<Post: DjangoのORM入門>, <Post: SQLを学び直す>, <Post: PostgreSQLのインデックス>]
SELECT ... FROM "blog_post" WHERE ("blog_post"."status" = 'published' AND "blog_post"."view_count" >= 100)

filterに並べた条件は、ANDで結ばれて1つのWHERE句になりました。WHEREの条件に使えるのは=だけではなく、>=のような比較演算子も使えます。このview_count__gte=100>= 100になる変換ルールは、次の節でまとめて扱います。

次にexcludeです。「draft以外」を取ってみます。

>>> list(Post.objects.exclude(status="draft"))
SELECT ... FROM "blog_post" WHERE NOT ("blog_post"."status" = 'draft')

excludeはWHERE句の中身をNOTで包む形でした。SQLには「等しくない」を表す<>演算子もあります。それを使わずNOTで包むのは、excludeに渡されるのが1条件とは限らないからのようです。条件を2つ渡すと、こうなります。

SELECT ... FROM "blog_post" WHERE NOT ("blog_post"."status" = 'draft' AND "blog_post"."view_count" <= 10)

NOT (A AND B)、つまり「AかつBである行を除く」というまとめての否定です。個々の条件を否定するのではなく、excludeに渡した条件のかたまり全体を1つのNOTで包む、と押さえておくと読み間違えません。filterと組み合わせた場合も同じ構図です。filter(author_id=1).exclude(status="draft")のWHERE句は("author_id" = 1 AND NOT ("status" = 'draft'))となり、filter側はそのまま、exclude側だけがNOTに包まれます。

最後に、filterをチェーンで2回に分けて書いたら、SQLはどうなるのかを見ます。

>>> list(Post.objects.filter(status="published").filter(view_count__gte=100))
[<Post: DjangoのORM入門>, <Post: SQLを学び直す>, <Post: PostgreSQLのインデックス>]
SELECT ... FROM "blog_post" WHERE ("blog_post"."status" = 'published' AND "blog_post"."view_count" >= 100)

発行されたSQLは1本で、冒頭で条件を2つ並べて渡したときと一字一句同じWHERE句でした。1回目のfilter結果に2回目のfilterをかける2段階処理ではなく、最終的に1つのWHERE句として組み立てられているのが分かります。

フィールドルックアップとSQLの対応

前の節のview_count__gte=100のように、フィールド名の後ろにダンダー(アンダースコア2つ)で繋げる書き方はフィールドルックアップと呼ばれています。これがSQLで何になるのか、代表的なものを順に確かめます。

まず部分一致のcontainsです。

>>> list(Post.objects.filter(title__contains="SQL"))
[<Post: SQLを学び直す>, <Post: PostgreSQLのインデックス>]
SELECT ... FROM "blog_post" WHERE "blog_post"."title"::text LIKE '%SQL%'

出てきたのはLIKE演算子です。LIKEはパターンで文字列を照合する演算子で、%が「0文字以上の任意の文字列」を表します。'%SQL%'は「前後に何があってもいいので、どこかにSQLを含む」という意味で、これが部分一致の正体でした。ちなみに途中の::textはPostgreSQLの型キャスト構文で、varcharの列をtext型に揃えてから比較しています。結果に影響する部分ではないので、定型句として読み流して大丈夫です。

次が今回の発見でした。大文字小文字を無視するicontainsです。PostgreSQLには大文字小文字を無視するILIKEという演算子があるので、てっきりそれが出てくると思っていたのですが、違いました。

>>> list(Post.objects.filter(title__icontains="sql"))
[<Post: SQLを学び直す>, <Post: PostgreSQLのインデックス>]
SELECT ... FROM "blog_post" WHERE UPPER("blog_post"."title"::text) LIKE UPPER('%sql%')

両辺をUPPER(大文字化する関数)で揃えてから、普通のLIKEで比較しています。大文字にしてしまえば大文字小文字の違いは消える、という素朴で確実なやり方です。ILIKEがPostgreSQLの方言なのに対して、UPPERとLIKEの組み合わせはどのデータベースでも通用します。Djangoが複数のDBバックエンドで同じ挙動を保つための書き方のようです。ここでも「SQL方言との対応づけよりも、全バックエンド共通のやり方を選ぶ」というモデル定義編から繰り返し見てきたDjangoの設計思想が顔を出しました。

INも見ておきます。

>>> list(Post.objects.filter(status__in=["draft", "archived"]))
[<Post: 下書きのメモ>, <Post: アーカイブした古い記事>]
SELECT ... FROM "blog_post" WHERE "blog_post"."status" IN ('draft', 'archived')

__inはそのままIN演算子でした。「列挙した値のどれかに一致する」という意味で、=をORで並べるのと等価な書き方です。

ここまでで要領は掴めたので、残りは対応表にまとめます。すべて手元で実行して確認したものです。

ルックアップ WHERE句での姿
__gt / __gte / __lt / __lte > / >= / < / <=
__in=[...] IN (...)
__contains LIKE '%x%'
__icontains UPPER(...) LIKE UPPER('%x%')
__startswith LIKE 'x%'
__isnull=True IS NULL
__year=2026 BETWEEN '2026-01-01...' AND '2026-12-31...'

2つ補足します。__isnullは、NULLの判定に=が使えないためIS NULLという専用の書き方になる、というモデル変更編でやった話そのままです。ただ、いまのサンプルモデルはモデル変更編でpublished_atをNOT NULL化した結果、NULLになりうる列がなくなってしまったので、実演は次の章(リレーションを組み合わせると出番があります)に回します。__yearは、EXTRACTのような日付関数で年を取り出すのかと思いきや、「年初から年末まで」のBETWEEN(両端を含む範囲演算子)に展開されていました。

そして本節のまとめです。フィールドルックアップという名前は仰々しいですが、SQLと並べてしまえば、ダンダーの後ろはWHERE句の演算子を選んでいるだけでした。=か、>=か、INか、LIKEか。ルックアップ名と演算子の対応さえ頭にあれば、filterの行からWHERE句が読めるようになります。ただし、ダンダーにはもうひとつ別の顔があります。次の章で扱います。

並べ替えとページネーション: order_byとスライス

次は並べ替えです。「閲覧数トップ3」を取ってみます。

>>> list(Post.objects.order_by("-view_count")[:3])
[<Post: SQLを学び直す>, <Post: PostgreSQLのインデックス>, <Post: DjangoのORM入門>]
SELECT ... FROM "blog_post" ORDER BY "blog_post"."view_count" DESC LIMIT 3

order_by("-view_count")はORDER BY句になりました。フィールド名先頭のマイナスがDESC(降順)に対応していて、マイナスなしならASC(昇順)です。firstの節で出てきたORDER BY "id" ASCと同じ部品ですね。スライスの[:3]は、これもfirstで見たLIMITです。ここで押さえておきたいのは適用の順序で、LIMITが切り出すのは並べ替えが終わったあとの先頭3件です。WHEREで絞り、ORDER BYで並べ、LIMITで切る、という順で効きます。

では、始点のあるスライスはどうなるのか。

>>> list(Post.objects.order_by("id")[3:6])
[<Post: Python 3.14の新機能>, <Post: PostgreSQLのインデックス>, <Post: アーカイブした古い記事>]
SELECT ... FROM "blog_post" ORDER BY "blog_post"."id" ASC LIMIT 3 OFFSET 3

OFFSETが増えました。OFFSETは「先頭からn行を読み飛ばす」という指定で、[3:6]は「3件飛ばして3件取る」、つまりLIMIT 3 OFFSET 3です。Pythonのスライスの感覚が、そのままSQLに翻訳されていました。

そしてこれが、ページネーションの種明かしでもあります。1ページ10件の3ページ目は[20:30]、SQLならLIMIT 10 OFFSET 20。Djangoが用意しているPaginatorも、ページ番号をこのOFFSETに換算しているだけです。どのサービスでも見かけるページ送りのUIの裏側は、実はこの2部品だけで動いていました。ひとつだけ注意点を挙げると、OFFSETは「読み飛ばす」と言いつつ、データベースは飛ばす行も一度読んでから捨てています。OFFSET 10000なら10000行読んで捨てるので、深いページほど遅くなる性質があります。ページが深くなりうる設計のときに思い出したいポイントです。

最後に、モデル定義編の宿題を回収します。あのときMetaオプションのorderingは「DDLには一切現れない。テーブルに行の順序という概念がなく、順序はSELECTのORDER BY句で毎回指定するものだから」と学びました。その後半の意味が、この章でようやく実感できます。もしPostのMetaにordering = ["-created_at"]と書いていたら、order_byを付けていないこの記事のすべてのSELECTに、ORDER BY "created_at" DESCが暗黙に差し込まれていました。テーブル定義を変えずにクエリだけを変える設定なので、orderingの追加や変更にマイグレーションでのスキーマ変更が要らないのも、同じ理屈からの帰結です。順序はテーブルが持つものではなく、クエリが毎回作るもの。この感覚は、SQLを読み書きするうえでずっと効いてくる気がします。

リレーションをまたぐfilterはJOINになる

ここまでのWHERE句は、すべて1つのテーブルの中で完結していました。しかし実際のアプリでは「作者の名前で記事を絞る」のように、別のテーブルにある列で絞りたくなります。Djangoではダンダーでリレーションを辿るだけです。

>>> list(Post.objects.filter(author__name="alice"))
[<Post: DjangoのORM入門>, <Post: SQLを学び直す>, <Post: 下書きのメモ>]
SELECT ... FROM "blog_post" INNER JOIN "blog_user" ON ("blog_post"."author_id" = "blog_user"."id") WHERE "blog_user"."name" = 'alice'

JOINが出てきました。構文はFROM テーブルA INNER JOIN テーブルB ON 結合条件で、2つのテーブルを繋いで1つの大きな表を作る操作です。肝心なのはON句で、「どの行とどの行を対応させるか」をここで指定します。今回は"blog_post"."author_id" = "blog_user"."id"、つまり記事のauthor_idとユーザーのidが一致する行同士を繋いでいます。モデル定義編で「相手のidの数字を入れているだけ」と学んだ外部キーの列が、ここで2つのテーブルを渡る道として使われているわけです。繋いでできた大きな表に対して、WHERE句は"blog_user"."name"というblog_user側の列で絞り込み、SELECTはblog_post側の列だけを返しています。

そしてここで、ルックアップの節の理解をアップデートする必要が出てきました。author__nameのダンダーは、演算子を選んでいません。1つ目のダンダーはリレーションを辿る道順(author、つまりblog_userへのJOIN)を表し、その先のnameがWHEREで使う列です。ダンダーはWHERE句の演算子を選ぶだけでなく、JOINの道順も表す。これがルックアップのもうひとつの顔でした。

「持っていない」を探すLEFT OUTER JOIN

ルックアップの節の宿題、__isnullの実演もここでできます。プロフィールを作っていないユーザーを探してみます。

>>> list(User.objects.filter(profile__isnull=True))
[<User: carol>]
SELECT ... FROM "blog_user" LEFT OUTER JOIN "blog_profile" ON ("blog_user"."id" = "blog_profile"."user_id") WHERE "blog_profile"."id" IS NULL

JOINの種類が変わりました。先ほどのINNER JOINは「相手が見つかった行だけ」を残します。その挙動のままでは、プロフィールを持たないcarolはJOINの時点で消えてしまい、探しようがありません。LEFT OUTER JOINは、左側(FROM側のblog_user)の行をすべて残し、相手がいない行はNULLで埋めて繋ぎます。carolの行はプロフィール側の列が全部NULLになった状態で生き残るので、それをWHERE "blog_profile"."id" IS NULLで拾う、という組み立てです。「存在しないものを探す」というSQLの定番テクニックで、Djangoは条件に応じてJOINの種類まで選び分けていました。

逆参照は書き方でSQLが変わる

リレーションは逆からも辿れます。related_name="posts"を定義してあるので、aliceの記事はalice.posts.all()で取れますが、このSQLにはJOINがありません。

SELECT ... FROM "blog_post" WHERE "blog_post"."author_id" = 1

考えてみれば当然で、手元のaliceのインスタンスがid=1だと知っているので、author_idを直接WHEREで絞れば済みます。テーブルを繋ぐ必要があるのは「相手の列の値で絞る」ときだけです。その形、つまりfilterで逆参照を辿ると、今度はJOINになります。「公開記事を持っているユーザー」を探してみると、面白いことが起きました。

>>> list(User.objects.filter(posts__status="published"))
[<User: alice>, <User: alice>, <User: bob>, <User: bob>]
SELECT ... FROM "blog_user" INNER JOIN "blog_post" ON ("blog_user"."id" = "blog_post"."author_id") WHERE "blog_post"."status" = 'published'

aliceとbobが2回ずつ返ってきています。バグのようですがバグではなく、JOINの仕組みそのものです。JOINが作るのは行の対応づけなので、公開記事を2本持つaliceの行は、記事1本ごとに1行、計2行に増えます。SELECTはblog_user側の列だけを返すので、中身のまったく同じ行が2つ見える、というわけです。重複をなくすには、SELECTにDISTINCT(重複行を取り除く指定)を足します。QuerySetならdistinct()です。

>>> list(User.objects.filter(posts__status="published").distinct())
[<User: alice>, <User: bob>]
SELECT DISTINCT "blog_user"."id", "blog_user"."name", "blog_user"."email" FROM "blog_user" INNER JOIN "blog_post" ON ("blog_user"."id" = "blog_post"."author_id") WHERE "blog_post"."status" = 'published'

「リレーションを跨いだfilterの結果がなぜか重複する」は、Djangoを書いていて実際に踏みがちな罠です。SQL側から見れば、JOINで行が増えるのは正常な動作で、増えたまま返すか畳んで返すかを選ぶのはこちらの仕事、ということでした。

ManyToManyはJOINが2回

最後にManyToManyです。「sqlタグが付いた記事」を探します。

>>> list(Post.objects.filter(tags__name="sql"))
SELECT ... FROM "blog_post" INNER JOIN "blog_post_tags" ON ("blog_post"."id" = "blog_post_tags"."post_id") INNER JOIN "blog_tag" ON ("blog_post_tags"."tag_id" = "blog_tag"."id") WHERE "blog_tag"."name" = 'sql'

INNER JOINが2つ並びました。モデル定義編で、ManyToManyFieldの実体はblog_post_tagsという中間テーブルだと学びましたが、その構造がクエリにそのまま現れています。ORMのtags__nameはダンダー1つの1ホップに見えて、SQLでは記事→中間テーブル→タグと2回JOINして渡っています。中間テーブルはモデル定義編では「作られるもの」としての登場でしたが、クエリでは毎回この経由地として働いているわけです。

JOINそのものの深掘りは、次回のパフォーマンスチューニング編の主戦場です。結合がどう処理されてどれくらいコストがかかるのか、関連オブジェクトをまとめて取るselect_relatedとどう関係するのかはそちらに譲り、ここでは「ダンダーで関連を辿った数だけJOINが増える」まで押さえて先へ進みます。

取得するカラムを絞る: valuesとvalues_list

この章では、ずっと...と省略してきたSELECT句の列挙を、初めて自分の意志で絞ります。これまでのSELECTが全フィールドを列挙していたのは、Postのインスタンスを組み立てるには全フィールドの値が必要だからです。しかし、記事の一覧を出すのにidとtitleしか使わない、という場面はよくあります。そのためのvaluesです。

>>> list(Post.objects.values("id", "title")[:3])
[{'id': 1, 'title': 'DjangoのORM入門'}, {'id': 2, 'title': 'SQLを学び直す'}, {'id': 3, 'title': '下書きのメモ'}]
SELECT "blog_post"."id" AS "id", "blog_post"."title" AS "title" FROM "blog_post" LIMIT 3

SELECT句が指定した2列だけになり、返り値もモデルのインスタンスではなくdictのリストに変わりました。途中のASは列に別名を付けるSQLのキーワードで、ここでは同じ名前を付け直しているだけなので読み流して大丈夫です。

兄弟分のvalues_listも見てみます。

>>> list(Post.objects.values_list("id", "title")[:3])
[(1, 'DjangoのORM入門'), (2, 'SQLを学び直す'), (3, '下書きのメモ')]
SELECT "blog_post"."id" AS "id", "blog_post"."title" AS "title" FROM "blog_post" LIMIT 3

返り値はdictからtupleに変わりましたが、SQLはvaluesと一字一句同じです。つまりvaluesとvalues_listの違いは、データベースから届いた同じ結果をPython側でどう包むかだけでした。1列だけ取ってtupleの入れ子を剥がすvalues_list("title", flat=True)も、SQLは素直な1列のSELECTです。

効果として分かりやすいのは転送量です。本文(content)のようなTextFieldは大きくなりがちで、一覧表示のたびに全記事の本文を運ぶのは無駄が大きい。SELECT句を絞れば、その転送がなくなります。そしてこの「SELECT句を自分で指定する」操作は、次回パフォーマンスチューニング編で集計(annotate)と組み合わせたとき、GROUP BYと一緒に本領を発揮します。

更新と削除

読み取りが一通り終わったので、最後は書き換える側です。INSERTの章で見たsave()の相棒たち、UPDATEとDELETEを確かめます。

update

QuerySetにはupdateというメソッドがあり、filterで絞った行をまとめて書き換えられます。

>>> Post.objects.filter(status="draft").update(status="published")
1
UPDATE "blog_post" SET "status" = 'published' WHERE "blog_post"."status" = 'draft'

UPDATE文はモデル変更編の最後に先取りした、SETで値を、WHEREで対象の行を指定する形そのままです。あのときはマイグレーションの中でNULLを埋める道具として出てきましたが、ORMから日常的に発行されるのもまったく同じ構文でした。注目したいのはWHERE句で、filterが組み立てる部品はSELECTのときと同じものが、そのままUPDATEにも刺さっています。返り値の1は、UPDATEが実際に書き換えた行数です。

もうひとつ、SET句をよく見ると、updated_atがありません。モデル定義編で「auto_nowの日時はsave()のたびにDjangoが埋める」と学びましたが、updateはsave()を通らないため、auto_nowが発動しないのです。updateで書き換えた行のupdated_atは古いままになります。知らないと踏む罠なので、SQLで見えたのは収穫でした。

save()はSELECT+全カラムUPDATE

では、1件取得してフィールドを書き換えてsave()する、いつもの書き方はどうなるのか。

>>> post = Post.objects.get(id=1)
>>> post.title = "改題しました"
>>> post.save()
SELECT ... FROM "blog_post" WHERE "blog_post"."id" = 1 LIMIT 21
UPDATE "blog_post" SET "author_id" = 1, "title" = '改題しました', "subtitle" = '', "slug" = 'django-orm-intro', "content" = 'DjangoのORM入門 の本文です。', "status" = 'published', "view_count" = 120, "published_at" = '2026-06-07 02:27:37.753464+00:00'::timestamptz, "created_at" = '2026-07-07 02:27:37.762992+00:00'::timestamptz, "updated_at" = '2026-07-07 02:28:21.252225+00:00'::timestamptz WHERE "blog_post"."id" = 1

クエリは2本(getのSELECTとUPDATE)で、UPDATEのSET句には全カラムが並んでいます。変えたのはtitleだけなのに、です。save()はどのフィールドを変更したかを追跡していないので、手元のインスタンスが持つ値を全部書き戻すしかない、という理屈でした。今度はupdated_atもSET句に乗っていて、auto_nowが効いています(INSERTの章で見た「とりあえずUPDATE」のsave()そのものなので、当然といえば当然です)。

書き戻す列は、update_fieldsで絞れます。

>>> post.save(update_fields=["title"])
UPDATE "blog_post" SET "title" = '再改題' WHERE "blog_post"."id" = 1

本当にtitleだけのUPDATEになりました。ただし面白いことに、この形だとupdated_atも乗りません。auto_nowはあくまで「saveで書き込まれるフィールド」に対して発動します。update_fieldsで絞るなら、update_fields=["title", "updated_at"]のように自分で含める必要があります。実際に試すと、含めたときだけSET句にupdated_atが現れました。

deleteは関連まで消しにいく

削除です。まず1件だけ消してみます。

>>> Comment.objects.get(id=1).delete()
DELETE FROM "blog_comment" WHERE "blog_comment"."id" IN (1)

DELETE文の初登場です。構文はDELETE FROM テーブル名 WHERE 条件で、行ごと消すのでSET句のようなものはありません4。WHEREが= 1ではなくIN (1)なのは、複数件をまとめて消す形に統一されているからのようです。

面白いのは、参照されている側を消したときです。コメントとタグが紐付いているPostをdelete()すると、DELETEが3本走りました。

>>> Post.objects.get(id=1).delete()
DELETE FROM "blog_post_tags" WHERE "blog_post_tags"."post_id" IN (1)
DELETE FROM "blog_comment" WHERE "blog_comment"."post_id" IN (1)
DELETE FROM "blog_post" WHERE "blog_post"."id" IN (1)

モデル定義編で、on_delete=CASCADEは「DDLにON DELETE CASCADEを書かず、DjangoがPython側でエミュレートする」と学びました。その実行現場がこれです。タグの紐付けとコメントを先に消し、最後に本体を消す。子から先に消すのは、親を先に消すと外部キー制約(参照されている行は消せない)に引っかかるからで、順序にも意味があります。QuerySet.delete()でまとめて消す場合は、この一連のDELETEの前にSELECTが1本入ります。何を消すことになるか(巻き込まれる関連オブジェクトを含めて)をDjangoが把握するためで、これもモデル定義編で見た「削除シグナルを発火するために自分で消しに行く」設計の続きでした。

bulk_updateとCASE式

最後に、INSERTの章のbulk_createと対になるbulk_updateです。複数の記事のview_countをそれぞれ違う値に更新したいとき、ループでsave()すると1件1本、5件で5本のUPDATEが走ります。bulk_updateなら1本にまとまるのですが、考えてみると不思議です。UPDATEのSET句は「この列をこの値にする」という一律の指定で、行ごとに違う値を書く仕組みがありません。どうやって1本にするのか。

>>> posts = list(Post.objects.filter(status="published"))
>>> for p in posts:
...     p.view_count += 1
>>> Post.objects.bulk_update(posts, ["view_count"])
UPDATE "blog_post" SET "view_count" = (CASE WHEN ("blog_post"."id" = 2) THEN 341 WHEN ("blog_post"."id" = 4) THEN 86 WHEN ("blog_post"."id" = 5) THEN 211 WHEN ("blog_post"."id" = 3) THEN 1 WHEN ("blog_post"."id" = 1) THEN 121 ELSE NULL END)::integer WHERE "blog_post"."id" IN (2, 4, 5, 3, 1)

答えはCASE式でした。CASEはCASE WHEN 条件 THEN 値 ... ELSE 値 ENDという形で値を返す、SQLの条件分岐です。SET句に「idが2なら341、4なら86…」という分岐する式を渡すことで、1本のUPDATEで行ごとに違う値を書き込んでいます。WHERE句のINで対象を絞っているので、ELSE NULLの枝が実際に使われることはありません。SQLの文の数だけ数えると、ループsave()の5本に対して、bulk_updateは1本。この差は、次回のN+1問題の話に直結します。

まとめ

普段書いているORMのコードを、対応するSQLと並べてひたすら確認してきました。今回登場した対応を一枚にまとめます。

ORM側 SQL側
create INSERT INTO ... VALUES ... RETURNING
bulk_create INSERT INTO ... SELECT * FROM UNNEST(...)
get SELECT ... WHERE ... LIMIT 21
first SELECT ... ORDER BY 主キー LIMIT 1
filter / exclude WHERE / WHERE NOT (...)
ルックアップ(__gte・__in・__containsなど) WHERE句の演算子(>=・IN・LIKEなど)
リレーションを辿るダンダー JOIN
order_by ORDER BY
スライス([20:30]) LIMIT 10 OFFSET 20
values / values_list SELECT句の列指定
update UPDATE ... SET ... WHERE
delete DELETE FROM ... WHERE (関連の分も別途)
bulk_update UPDATE ... SET CASE WHEN ...

結論は、SELECT文の基本形の章で立てた見取り図のとおりでした。QuerySetのメソッドチェーンは、SQLの句を組み立てる行為です。filterを分けて書いても最終的なWHERE句は1つに合成され、書き方の違いよりも生成されるSQLで理解するのが重要だと分かりました。

そのうえで、今回の収穫は2つあると思っています。

1つ目は、ORMが素朴な翻訳機ではないと分かったことです。getのLIMIT 21、bulk_createのUNNEST、get_or_createのSAVEPOINT、UPDATEを先に打ってみるsave()。どれもmodels.pyからは想像できない、安全側・効率側への工夫でした。SQLが読めるようになると、構文だけでなくこうした設計の意図まで見えてきます。

2つ目は、SQL側の性質はORMを使っていても消えない、ということです。JOINすると行が増えて結果が重複する。OFFSETは読み飛ばす行も読んでいる。updateはsave()を通らないのでauto_nowが効かない。ORMの層だけを見ていると不思議な現象やハマりどころが、SQLを1枚めくれば全部正常な動作でした。「ORMのメソッド→SQL」の対応が頭にあると、この種の問題は未然に防げるはずです。

次回はいよいよ最終回、パフォーマンスチューニング編です。テーブルを作り、変え、クエリを読み書きできるようになったので、最後は速さの話をします。ORMの便利さの代償として名高いN+1問題と、その対策のselect_related・prefetch_related、そして集計の裏側を、今回手に入れたconnection.queries(本数とtime)を武器に確かめていきます。

参考文献

  1. https://docs.djangoproject.com/en/6.0/faq/models/#how-can-i-see-the-raw-sql-queries-django-is-running

  2. https://www.postgresql.jp/docs/17/sql-insert.html 2 3

  3. https://code.djangoproject.com/ticket/35936

  4. https://www.postgresql.jp/docs/17/sql-delete.html

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?