Django4.1からbulkのupsert的な処理ができるようになっていた

前回書いた記事のupdate_or_create()でupsertのような挙動を実現させるは1行ずつ処理をするものだった。

しかし、レコードの登録件数が多い時など、DBに対する負荷を考慮した場合、いわゆるbulk処理で一括挿入・更新することが求められるケースがある。

そうしたケースにもbulk_createにオプションが追加され、Django4.1から対応できるようになっていたようだった。

https://docs.djangoproject.com/ja/5.0/releases/4.1/#models には以下のように書かれていた。

QuerySet.bulk_create() now supports updating fields when a row insertion fails uniqueness constraints. This is supported on MariaDB, MySQL, PostgreSQL, and SQLite 3.24+.

bulk_createとは

自分の中では、レコードを一括挿入するSQLを作成してくれるQuerySet APIと理解している。

リファレンスには以下のように書かれている。

This method inserts the provided list of objects into the database in an efficient manner (generally only 1 query, no matter how many objects there are), and returns created objects as a list, in the same order as provided:

https://docs.djangoproject.com/ja/4.2/ref/models/querysets/#bulk-create より引用。

引数を自分なりにまとめたものが以下。

引数 内容 デフォルト値
objs 対象のModel群 なし
batch_size 1回のクエリで作成されるオブジェクトの数。※SQLiteはデフォルト値999なのでそれ以外のDBが対象。 None
ignore_conflicts Trueにした場合、重複して失敗した行の挿入を無視する。 False
update_conflicts Trueにした場合、重複して失敗した場合失敗した行を更新する。 False
update_fields update_conflictsをTrueにした場合、更新する対象のフィールド。 None
unique_fields PostgreSQLSQLiteではupdate_conflicts指定時に競合する可能性のあるリストを指定する。 None

update_conflicts, update_fields, unique_fieldsがDjango4.1から追加された引数だった。

また、ignore_conflictsupdate_conflictsを両方TrueにするとValueErrorが発生する。

実際に動かしてみた

部署名と課名のフィールドを持つModelを定義する。

class Department(models.Model):
    """所属部署"""
    name = models.CharField("名前", max_length=100, unique=False)
    section_name = models.CharField("課名", max_length=100, unique=True)

django-extensionsのshell_plusを利用して以下のコードを実行した。

1回目のデータ投入

ここでは、すべてINSERTされる想定。

>>> departments = [
...         Department(id="1", name="営業部", section_name="営業課"),
...         Department(id="2", name="仕入部", section_name="仕入課"),
...         Department(id="3", name="物流部", section_name="物流課"),
...     ]
>>> 
>>> Department.objects.bulk_create(
...         objs=departments,
...         update_conflicts=True,
...         update_fields=["name", "section_name"],
...         unique_fields=["id"],
...     )
(0.000) BEGIN; args=None; alias=default
(0.006) INSERT INTO "company_department" ("id", "name", "section_name") VALUES (1, '営業部', '営業課'), (2, '仕入部', '仕入課'), (3, '物流部', '物流課') ON CONFLICT("id") DO UPDATE SET "name" = EXCLUDED."name", "section_name" = EXCLUDED."section_name"; args=(1, '営業部', '営業課', 2, '仕入部', '仕入課', 3, '物流部', '物流課'); alias=default
(0.008) COMMIT; args=None; alias=default
[<Department: 営業部>, <Department: 仕入部>, <Department: 物流部>]

SQLINSERT INTO~ ON CONFLICT DO UPDATE SETと出力されており、これでUPSERT的処理を実現していることが理解できた。

また、レコードがINSERTされていることも確認できた。

sqlite> select * from company_department;
1|営業部|営業課
2|仕入部|仕入課
3|物流部|物流課

2回目のデータ投入。

ここでは、INSERT・UPDATE(および何もされない)想定で記述した。

>>> departments = [
...         Department(id="1", name="営業部", section_name="営業1課"),  # 営業1課にUPDATE
...         Department(id="2", name="仕入部", section_name="仕入1課"),  # 仕入1課にUPDATE
...         Department(id="3", name="物流部", section_name="物流課"),   # ここはそのまま
...         Department(id="4", name="営業部", section_name="営業2課"),  # 新規INSERT
...         Department(id="5", name="商品企画部", section_name="商品企画課"),  # 新規INSERT
...     ]
>>> 
>>> Department.objects.bulk_create(
...         objs=departments,
...         update_conflicts=True,
...         update_fields=["name", "section_name"],
...         unique_fields=["id"],
...     )
(0.000) BEGIN; args=None; alias=default
(0.007) INSERT INTO "company_department" ("id", "name", "section_name") VALUES (1, '営業部', '営業1課'), (2, '仕入部', '仕入1課'), (3, '物流部', '物流課'), (4, '営業部', '営業2課'), (5, '商品企画部', '商品企画課') ON CONFLICT("id") DO UPDATE SET "name" = EXCLUDED."name", "section_name" = EXCLUDED."section_name"; args=(1, '営業部', '営業1課', 2, '仕入部', '仕入1課', 3, '物流部', '物流課', 4, '営業部', '営業2課', 5, '商品企画部', '商品企画課'); alias=default
(0.009) COMMIT; args=None; alias=default
[<Department: 営業部>, <Department: 仕入部>, <Department: 物流部>, <Department: 営業部>, <Department: 商品企画部>]

レコードがUPDATE, INSERTされていることも確認できた。

sqlite> select * from company_department;
1|営業部|営業12|仕入部|仕入13|物流部|物流課
4|営業部|営業25|商品企画部|商品企画課

雑感

bulk_createに引数を追加して本機能を実装するのではなく、bulk_upsertbulk_update_or_createがあっても良いのでは?と言う感想を持ったので、なぜbulk_createに作成されたのか、経緯をちょっと調べてみようと思った。