Django ORMの裏側ではSQLが動いている
Django ORMを使うと、SQLを直接書かずにPythonでデータベースを操作できます。ただし、データベースがPythonのコードをそのまま実行しているわけではありません。DjangoがQuerySetをSQLへ変換し、そのSQLをデータベースへ送っています。
この記事では、勤怠管理システムを題材に、同じ処理のDjango ORMとSQLを並べて比較します。
掲載するSQLは、SQLiteを使用した場合の形を読みやすく整えた例です。Djangoやデータベースのバージョンによって、引用符、テーブルの別名、プレースホルダーなどは変わる場合があります。
この記事の環境
$ python --versionPython 3.13.0$ python -m django --version6.1$ sqlite3 -version3.51.0使用するモデル
1人の従業員が複数の勤怠記録を持つ、1対多の関係のモデルがあるとします。
from django.db import models
class Employee(models.Model): name = models.CharField('氏名', max_length=100) number = models.CharField('社員番号', max_length=20, unique=True) age = models.IntegerField('年齢')
class Meta: db_table = 'employee' verbose_name = '従業員'
class Attendance(models.Model): employee = models.ForeignKey( Employee, on_delete=models.CASCADE, verbose_name='従業員', ) note = models.TextField('備考', blank=True) work_minutes = models.IntegerField('勤務時間(分)', default=0) clock_in = models.DateTimeField('出勤日時') clock_out = models.DateTimeField('退勤日時', null=True, blank=True)
class Meta: db_table = 'attendance' verbose_name = '勤怠記録'QuerySetからSQLを確認する
SELECT文を組み立てるQuerySetは、query属性を文字列にするとSQLを確認できます。
queryset = Employee.objects.filter(age__gte=20)print(queryset.query)filter()でQuerySetを作った段階では、通常SQLはまだ実行されません。ループで結果を使う、list()へ変換するなど、データが必要になったときに実行されます。この性質を遅延評価と呼びます。
なお、queryの文字列表現はデバッグ用です。値を安全に渡す仕組みまで再現した、実行SQLそのものではありません。
1. すべてのデータを取得する
まずは条件を付けず、従業員をすべて取得します。
Employee.objects.all()SELECT "employee"."id", "employee"."name", "employee"."number", "employee"."age"FROM "employee";all()は、テーブルの全行を取得するSELECT文に対応します。モデルに定義した各フィールドと、Djangoが追加した主キーのidがSELECT対象になります。
2. 完全一致で検索する
氏名が「佐藤」と一致する従業員を取得します。
Employee.objects.filter(name='佐藤')SELECT "employee"."id", "employee"."name", "employee"."number", "employee"."age"FROM "employee"WHERE "employee"."name" = '佐藤';条件の指定に使うのがフィールドルックアップです。フィールドルックアップは、「どのフィールドを、どの方法で検索するか」を Django ORM の仕組みです。
基本形はフィールド名__ルックアップ名=値です。フィールド名とルックアップ名の間は、2つのアンダースコア__で区切ります。
Employee.objects.filter(name__exact='佐藤')# └─┬┘ └─┬─┘# フィールド ルックアップこの例のnameは検索対象のフィールド、exactは完全一致という検索方法を表します。ルックアップを省略してname='佐藤'と書いた場合も、Djangoはexactを使用します。そのため、次の2つは同じ条件です。
Employee.objects.filter(name='佐藤')Employee.objects.filter(name__exact='佐藤')完全一致検索では、氏名が「佐藤」のデータは取得できますが、「佐藤 太郎」や「佐藤田」のようにほかの文字を含むデータは取得されません。
3. LIKEで部分一致検索する
氏名に「佐」を含む従業員を取得します。Django ORMでは__containsを使います。
Employee.objects.filter(name__contains='佐')SELECT "employee"."id", "employee"."name", "employee"."number", "employee"."age"FROM "employee"WHERE "employee"."name" LIKE '%佐%' ESCAPE '\';SQLの%は0文字以上の任意の文字列を表します。そのため、'%佐%'は文字列の途中を含め、どこかに「佐」があれば一致します。
よく使うLIKE検索に対応するルックアップは次のとおりです。
| Django ORM | 検索方法 | SQLのパターン例 |
|---|---|---|
name__contains='佐' |
部分一致 | LIKE '%佐%' |
name__startswith='佐' |
前方一致 | LIKE '佐%' |
name__endswith='藤' |
後方一致 | LIKE '%藤' |
SQLiteでは、ASCII文字に対するLIKEの大文字・小文字の扱いなどが、ほかのデータベースと異なる場合があります。大文字・小文字を区別しない検索を意図するときは__icontains、__istartswith、__iendswithも利用できますが、最終的な挙動は使用するデータベースで確認してください。
4. 年齢を条件に絞り込む
20歳以上の従業員だけを取得します。
Employee.objects.filter(age__gte=20)SELECT "employee"."id", "employee"."name", "employee"."number", "employee"."age"FROM "employee"WHERE "employee"."age" >= 20;filter()へ渡した条件がSQLのWHEREへ変換されました。age__gte=20の__gteは「以上」を表すフィールドルックアップです。
| ルックアップ | 意味 | SQLでの表現例 |
|---|---|---|
__gt |
より大きい | > |
__gte |
以上 | >= |
__lt |
より小さい | < |
__lte |
以下 | <= |
__in |
一覧のいずれかに一致 | IN (...) |
5. 複数の条件をANDでつなぐ
20歳以上、かつ社員番号がEから始まる従業員を取得します。
Employee.objects.filter( age__gte=20, number__startswith='E',)SELECT ...FROM "employee"WHERE ( "employee"."age" >= 20 AND "employee"."number" LIKE 'E%' ESCAPE '\');filter()へ複数のキーワード引数を渡すと、条件はANDで結ばれます。__startswithは前方一致で、SQLiteではLIKE 'E%'に相当します。
6. 並び替えて先頭の5件を取得する
勤務時間が長い順に並べ、上位5件を取得します。
Attendance.objects.order_by('-work_minutes')[:5]SELECT ...FROM "attendance"ORDER BY "attendance"."work_minutes" DESCLIMIT 5;order_by('-work_minutes')の先頭にあるマイナスは降順を表し、SQLではDESCになります。QuerySetのスライス[:5]はLIMIT 5へ変換されます。
昇順ならフィールド名の前にマイナスを付けません。
Attendance.objects.order_by('clock_in')SELECT ...FROM "attendance"ORDER BY "attendance"."clock_in" ASC;7. QオブジェクトでOR条件
年齢が20歳未満、または60歳以上の従業員を取得します。
filter()のキーワード引数だけではANDになるため、OR条件にはQオブジェクトを使います。
from django.db.models import Q
Employee.objects.filter( Q(age__lt=20) | Q(age__gte=60))SELECT ...FROM "employee"WHERE ( "employee"."age" < 20 OR "employee"."age" >= 60);|はOR、&はAND、~はNOTに対応します。複数の条件を組み合わせるときは、Q(...)を括弧で囲むと意図が明確になります。
NOT の場合下記のようになります。
Employee.objects.filter( ~(Q(age__lt=20) | Q(age__gte=60)))8. values()で必要な列だけ取得する
従業員一覧に氏名と社員番号しか表示しないなら、values()で取得する列を限定できます。
Employee.objects.values('name', 'number')SELECT "employee"."name" AS "name", "employee"."number" AS "number"FROM "employee";結果はモデルのインスタンスではなく、次のような辞書になります。
{ 'name': '佐藤', 'number': 'E001',}使わない列を取得しないため、データベースからアプリケーションへ転送するデータ量を減らせます。ただし、モデルのメソッドを使いたい場合はvalues()ではなく、通常のQuerySetを使います。
9. ForeignKeyをたどってJOINする
次は、従業員名が「佐藤」の勤怠記録を取得します。employee__nameのように二重アンダースコアで関連先のフィールドを指定すると、SQLではJOINが使われます。
Attendance.objects.filter(employee__name='佐藤')SELECT "attendance".*FROM "attendance"INNER JOIN "employee" ON ( "attendance"."employee_id" = "employee"."id" )WHERE "employee"."name" = '佐藤';attendance.employee_idとemployee.idを結び、関連する従業員のname列で絞り込んでいます。
10. select_related()で従業員も同時に取得する
勤怠一覧で、各勤怠記録に従業員名も表示するとします。通常のQuerySetをループしながらattendance.employee.nameへアクセスすると、勤怠記録を取得するSQLとは別に、従業員を取得するSQLが繰り返し発行されます。これがN+1問題です。
外部キーの関連先を同じSQLで取得するにはselect_related()を使います。
Attendance.objects.select_related('employee').order_by('clock_in')SELECT "attendance".*, "employee"."id", "employee"."name", "employee"."number", "employee"."age"FROM "attendance"INNER JOIN "employee" ON ( "attendance"."employee_id" = "employee"."id" )ORDER BY "attendance"."clock_in" ASC;先ほどの絞り込みでもJOINが使われましたが、目的が異なります。
filter(employee__name=...)のJOINは関連先を検索条件に使うためですが、select_related('employee')のJOINは関連先の列もSELECTし、取得後の追加問い合わせを避けるために使用します。
11. annotate()で従業員ごとの勤怠件数を数える
annotate()は、QuerySetで取得する各データに集計値や計算結果を追加するメソッドです。モデルに定義されていない一時的な項目を、検索結果へ付け加えられます。
例えば、従業員ごとの勤怠件数をattendance_countという名前で追加してみます。
from django.db.models import Count
Employee.objects.annotate( attendance_count=Count('attendance'))このattendance_countはデータベースの列として保存されるわけではありません。このQuerySetから取得した各Employeeオブジェクトにだけ追加されます。
for employee in Employee.objects.annotate( attendance_count=Count('attendance')): print(employee.name, employee.attendance_count)QuerySet全体を1つの値へ集計するaggregate()とは異なり、annotate()は従業員ごとに結果を付けるのがポイントです。
今回はvalues()も続けて使用し、従業員名と勤怠件数だけを辞書として取得します。Attendanceにrelated_nameを設定していないため、集計で使う逆方向の検索名はモデル名の小文字であるattendanceです。
from django.db.models import Count
Employee.objects.annotate( attendance_count=Count('attendance')).values('name', 'attendance_count')SELECT "employee"."name" AS "name", COUNT("attendance"."id") AS "attendance_count"FROM "employee"LEFT OUTER JOIN "attendance" ON ( "employee"."id" = "attendance"."employee_id" )GROUP BY "employee"."id", "employee"."name", "employee"."number", "employee"."age";LEFT OUTER JOINなので、勤怠記録が0件の従業員も結果に残り、attendance_countは0になります。
Pythonからは、辞書に追加されたattendance_countを参照できます。
for employee in Employee.objects.annotate( attendance_count=Count('attendance')).values('name', 'attendance_count'): print(employee['name'], employee['attendance_count'])12. Sum()で勤務時間の合計を求める
さらに、従業員ごとの合計勤務時間を求めます。
from django.db.models import Sum
Employee.objects.annotate( total_work_minutes=Sum('attendance__work_minutes')).values('name', 'total_work_minutes')SELECT "employee"."name" AS "name", SUM("attendance"."work_minutes") AS "total_work_minutes"FROM "employee"LEFT OUTER JOIN "attendance" ON ( "employee"."id" = "attendance"."employee_id" )GROUP BY "employee"."id", "employee"."name", "employee"."number", "employee"."age";Count()は件数を数え、Sum()は値を合計します。勤怠記録がない従業員の合計はNoneになります。0として扱いたい場合は、Sum()のdefaultを指定します。
Employee.objects.annotate( total_work_minutes=Sum('attendance__work_minutes', default=0))13. Exists()で未退勤の従業員を探す
最後はサブクエリです。出勤済みで、まだ退勤時刻が登録されていない勤怠記録を1件でも持つ従業員を取得します。
from django.db.models import Exists, OuterRef
working_attendances = Attendance.objects.filter( employee=OuterRef('pk'), clock_out__isnull=True,)
Employee.objects.filter(Exists(working_attendances))SELECT "employee".*FROM "employee"WHERE EXISTS ( SELECT 1 FROM "attendance" U0 WHERE ( U0."employee_id" = "employee"."id" AND U0."clock_out" IS NULL ) LIMIT 1);OuterRef('pk')は、外側で検索している従業員の主キーを参照します。Exists()は条件に合う行の内容ではなく、存在するかどうかだけを調べます。データベースは1件見つけた時点で探索を打ち切れる場合があります。
UPDATEやDELETEのSQLを確認する
queryset.queryで確認しやすいのはSELECT文です。create()、update()、delete()は呼び出した時点で実行され、戻り値もQuerySetではないため、同じ方法ではSQLを表示できません。
確認したい処理のSQLだけを取得するには、CaptureQueriesContextを使います。
from django.db import connectionfrom django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as captured: Attendance.objects.filter( clock_out__isnull=True, ).update(note='退勤打刻を確認してください')
for q in captured.captured_queries: print(q['sql'])CaptureQueriesContextは、withブロック内で実行されたSQLをcaptured_queriesへ保存します。
実行されるSQLは、次のような形になります。
UPDATE "attendance"SET "note" = '退勤打刻を確認してください'WHERE "attendance"."clock_out" IS NULL;explain()でSQLの実行計画を確認する
生成されたSQLを確認するだけでなく、データベースがそのSQLをどのように処理する予定なのか調べたいときは、QuerySetのexplain()を使います。
queryset = Employee.objects.filter(age__gte=20)print(queryset.explain())SQLiteでは、次のような実行計画が表示されます。
2 0 216 SCAN employeeSCAN employeeは、employeeテーブルを走査して条件に合う行を探す計画であることを示します。ageにはインデックスを設定していないため、この例ではテーブルを順に調べます。
一方、numberにはunique=Trueを指定しています。SQLiteでは一意性を保証するインデックスが作られるため、社員番号で検索すると異なる実行計画になります。
queryset = Employee.objects.filter(number='E001')print(queryset.explain())3 0 39 SEARCH employee USING INDEX sqlite_autoindex_employee_1 (number=?)SEARCH ... USING INDEXは、テーブル全体を順に調べるのではなく、インデックスを使って対象を探す計画であることを示します。
print(queryset.query)が「どのようなSQLへ変換されたか」を確認するものなのに対し、queryset.explain()は「そのSQLをデータベースがどのように実行する予定か」を確認するものです。表示形式や内容、利用できるオプションはデータベースによって異なります。
ORMとSQLの対応を振り返る
| Django ORM | 主に対応するSQL |
|---|---|
all() |
SELECT ... FROM ... |
filter()、exclude() |
WHERE |
order_by() |
ORDER BY |
| QuerySetのスライス | LIMIT、OFFSET |
values() |
SELECTする列の限定 |
| 関連フィールドの参照 | JOIN |
select_related() |
関連先を含むJOINとSELECT |
annotate()、Count()、Sum() |
COUNT、SUM、GROUP BY |
Exists() |
EXISTSサブクエリ |
ORMを使えば、複雑な問い合わせもPythonのコードとして組み立てられます。ただし、短いORMコードが常に効率的なSQLになるとは限りません。
関連データをループで参照するとき、集計条件を重ねるとき、画面の応答が遅くなったときは、生成されたSQLとクエリ数を確認してみてください。ORMとSQLを対応させて読めるようになると、N+1問題や不要なJOINにも気づきやすくなります。

