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

保存が途中で失敗すると半端なデータが残る — transaction.atomic() の「全部か無か」はどう効くのか

コードレビューDjangoDB

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


この記事でわかること

  • Django で Model.objects.create() を for ループで回すと何が起きるか
  • transaction.atomic() が内部で何をして「全部か無か」を保証するのか(SAVEPOINT の仕組み)
  • transaction.atomic() と bulk_create を組み合わせて整合性を守る方法
  • 所有者チェックの「ループ内 get_object_or_404」が壊れやすい理由

概念

Django の ORM は「1 クエリ = 1 トランザクション」がデフォルトで、Model.objects.create() を for ループで回すと 1件ごとに独立したトランザクションが張られて commit されます。

例えると、レジで3点まとめて会計している途中で、2点目でカードが弾かれたのに1点目だけ会計が済んで手元に残る——みたいな状態です。本来「3点まとめて1つの会計」のはずが、半端な結果だけ残ってしまいます。

これを防ぐには with transaction.atomic(): ブロックで全体を囲み、全部成功するか、全部ロールバックするか のどちらかに寄せる必要があります。

置き場所。アルバムと紐づけは PostgreSQL。photo_ids はリクエスト本文のメモリで保存しない。1件ごと commit2件目の所有者チェックで 404photos_albumアルバム1行が残るphotos_albumphoto写真1だけ残る。写真2は未反映atomic で囲む途中の例外で塊ごと巻き戻るphotos_album行なしphotos_albumphoto行なし閉じても残る正は左の2表。右は開始時点に戻る。

修正前のコード

アルバム作成時、「アルバム本体を作る」→「渡された写真 ID 群から中間テーブルを作る」を for ループで処理していた。所有者チェックがループ内にあり、他人の ID が紛れ込んだ時点で 404 を返して処理が止まります。

python
# 修正前
album = Album.objects.create(user=request.user, ...)  # ← ここで commit
for order, photo_id in enumerate(photo_ids):
    photo = get_object_or_404(Photo, pk=photo_id, owner=request.user)
    # ↑ 他人の photo_id で 404 発生 → ここまでに作った中間レコードは残る
    AlbumPhoto.objects.create(album=album, photo=photo, display_order=order)

なぜ危険か

  • データが取り戻せない: 空のアルバムや一部写真だけのアルバムが残り、ユーザーは「作った気」だが UI には崩れたデータが出ます
  • リトライで二重作成: エラー画面でユーザーが再実行すると、壊れたアルバムの隣にもう1つアルバムが作られて増殖します
  • マイグレーションの爆弾: 将来「アルバムには必ず1枚以上の写真が付く」という制約を足そうとしたとき、既存の壊れたデータで通りません

対策

全体を transaction.atomic() で囲み、Photo の取得は一括で、中間レコードは bulk_create() で 1 クエリに。

python
from django.db import transaction
from rest_framework.exceptions import ValidationError
 
with transaction.atomic():                       # ← ここから1つの塊。中で失敗したら全部巻き戻る
    album = Album.objects.create(user=request.user, ...)  # 本体を作る
 
    # 所有者チェックを「一括」で。ループ内 get_object_or_404 をやめ、
    # 自分が持っている写真だけを1クエリで取得する
    photos = Photo.objects.filter(pk__in=photo_ids, owner=request.user)
    if photos.count() != len(photo_ids):
        # 他人の ID が混ざってた → 例外で中断。atomic 内なので album 作成ごと巻き戻る
        raise ValidationError("所有していない写真が含まれています")
 
    photo_map = {p.pk: p for p in photos}
    AlbumPhoto.objects.bulk_create([             # 中間レコードを1クエリでまとめて作成
        AlbumPhoto(album=album, photo=photo_map[pid], display_order=i)
        for i, pid in enumerate(photo_ids)
    ])

仕組み(atomic は内部で何をしているか)

with transaction.atomic(): の中で Django がやってくれているのは、ざっくりこの4ステップ。

  1. ブロック開始時に SAVEPOINT(巻き戻し用のしおり)を発行します
  2. ブロック内の SQL を発行します(Album INSERT → AlbumPhoto INSERT...)
  3. 最後まで正常終了したら RELEASE(コミット相当)で確定させます
  4. 途中で例外が出たら ROLLBACK TO SAVEPOINT で、ブロック開始時点まで巻き戻します
SAVEPOINT は PostgreSQL セッション内のしおり。テーブルではない。残る正は2表。SAVEPOINT(セッション内のしおり)テーブルではない。ユーザーデータとしては保存しないphotos_album本体の INSERTPostgreSQL・閉じても残るphotos_albumphoto紐づけの INSERTPostgreSQL・閉じても残る最後まで行く → RELEASE両表に行が残るコミット相当例外 → ROLLBACK TO SAVEPOINT両表は開始時点のまま半端なアルバムは残らないatomic は交差で、置き場ではない。残る正は photos_album と photos_albumphoto。

なお atomic() は 入れ子(ネスト)にもできます。外側の atomic がまだコミット前なら、内側で例外が出て巻き戻っても外側は生きています。複雑な処理の一部だけをロールバックしたいときに効きます。

学びポイント

  • 「複数レコードを作る/削除する」処理は atomic で囲います。これは機械的ルールでいいです
  • 所有者チェックをループ内で get_object_or_404 するのは「書きやすいが壊れやすい」典型パターンです。filter(owner=...) で 一度に検証 する方がバグも速度も良いです
  • bulk_create / bulk_update は「速い」だけでなく「整合性が揃いやすい」。個人データを扱うサービスほど意識したい


おわりに

「ハッピーパスだけ書く」と起きる典型バグで、本人が気づきにくいタイプの不整合。atomic() は 書いた人が偉い タイプの安全装置で、コードレビューでも「複数 create / update があるのに atomic で囲まれてない」は機械的に指摘できます。

レビューチェックリストに「複数レコード書き込みは atomic で囲ってるか」を入れておくと、AI 生成コードの取りこぼしも拾いやすいです。

なお「毎回 atomic で囲むべきか/囲まなくていい場面はあるか」という使いどころの判断は、別記事 2ステップ保存で空ページが残った — transaction.atomic() を「いつ使い、いつ使わないか」 で整理しています。


この記事は2026年4月時点の自分の理解で書いています。Django の transaction は安定した領域ですが、最新の公式ドキュメントも合わせて参照してください。