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

Django save(update_fields=...) で既存データを壊しかけた話

コードレビューDjangoPythonデータベース

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


この記事でわかること

  • save(update_fields=...) を間違えるとデータが壊れる理由
  • 「エラーも出ずにデータが消える」タイプのバグの怖さ
  • 正しい直し方と、今後同じミスをしないための教訓

修正前のコード

写真メタデータを 更新する サービス関数の中に、こんなコードがあった。

python
# 修正前
allowed = {"width", "height", "taken_at"}
 
for field in allowed:
    if field in meta:
        setattr(photo, field, meta[field])
 
# ここが問題: meta にないフィールドも update_fields に入ってしまう
photo.save(update_fields=list(allowed))

この関数は、photo が新規に作られている経路や、別の処理で taken_at が None のまま渡されてくる経路があった。撮影日時の EXIF が無い写真は珍しくありません。DB に保存済みの写真 (id=42) へ、新規インスタンス経由で meta = {"width": 1920} だけ渡された場合がこうなります。

置き場所は3つ。meta と photo はメモリ(保存しない)。残る正は photos_photo テーブル。meta(メモリ)辞書・保存しないwidth: 1920height: キーなしtaken_at: キーなしEXIF から作った一時値photo(メモリ)新規インスタンスwidth: 1920height: Nonetaken_at: Nonesetattr したのは width だけphotos_photo(DB)id=42 の既存行width: 800 → 1920height: 600 → NULLtaken_at: 2024-08-15 → NULLcaption 海は残るsetattr は width だけ許可リスト3列を書くSQL は成功する。ログは出ない。残る正は右の photos_photo だけ。

開発者の意図は width だけの更新だった。コードレビューで指摘されるまで気づけない類のバグ。


なぜ起きるのか — update_fields の落とし穴

ポイントは、update_fields に指定したフィールドは、save 時点でメモリ上にある値がそのまま DB に書き込まれる こと。メモリ上の値は、そのインスタンスをどう作ったかで中身が違います。

置き場所は2つ。左が photos_photo テーブル、右がプロセスのメモリ。photos_photo(DB)photo(メモリ)DB から読むget でロード2024-08-15 があるテーブルの現在値同じ日付が入るロードしたコピー新規に作る空のインスタンス読まない既存行があっても未ロードNoneフィールドの初期値None を代入setattr で明示読まないDB の日付は見ないNone今入れた値save は出所を見ない。右のメモリを左の photos_photo へ書く。

注意したいのは、「setattr していないフィールドだから DB の現在値が温存される」というわけではない ということ。

つまり「DB から読んできた photo を更新する」ケースなら、setattr しなかったフィールドはロード時の値がそのまま書き戻されるだけで実害はあまりありません。問題が出るのは、新規にモデルを作って一度も setattr していないフィールド や、別経路で None に再代入されているフィールド が update_fields に含まれているとき。今回のバグはまさにそれでした。


なぜ危険か

  • 撮影日時は写真アプリの根幹データ。これが NULL になると、時系列ソートや年月別のフィルタが壊れます
  • 問題が静かに起きる点が厄介。エラーは出ず、保存は成功します。データが消えているだけ
  • 一度 NULL 上書きされたデータは復元できません(バックアップがない限り)

大切な思い出写真の「いつ撮ったか」という情報が静かに消えていく、というのはとても困る。


対策

update_fields は「実際に setattr したフィールド」だけで動的に組み立てるべき、というのが学んだルール。

python
# 修正後
allowed = {"width", "height", "taken_at"}
updated_fields = []
 
for field in allowed:
    if field in meta:
        setattr(photo, field, meta[field])
        updated_fields.append(field)  # setattr したものだけ記録
 
if updated_fields:
    photo.save(update_fields=updated_fields)  # 実際に更新したフィールドのみ保存

直し後のデータの流れはこうなります。

置き場所は同じ3つ。save が書くのは setattr した列だけ。正は photos_photo。meta(メモリ)辞書・保存しないwidth: 1920height: キーなしtaken_at: キーなしEXIF から作った一時値photo(メモリ)新規インスタンスwidth: 1920height: None のままtaken_at: None のままsetattr したのは width だけphotos_photo(DB)id=42 の既存行width: 800 → 1920height: 600 のままtaken_at: 2024-08-15 のままcaption 海も残るsetattr は width だけwidth だけ書くメモリが None でも、update_fields に無ければ photos_photo は触らない。

学びポイント

  • save(update_fields=...) は「指定したフィールドの メモリ状態を強制的に DB に流し込む」操作。許可リストと、実際に値をセットしたリストは別管理にします
  • 「エラーが出ないから大丈夫」は危険な思い込み。Django の save() はデータを黙って壊すことがあります
  • 動的に update_fields を組み立てるパターン(updated_fields = [] → append → save)を定型として覚えておきます

おわりに

このバグも 「動いてはいるが、壊れている」 タイプだった。save() が成功してログにもエラーが残らないのに、データが少しずつ削れていきます。これは経験しないと気づけない類の問題で、コードレビューで指摘されなかったらしばらく気づかなかったと思う。

XSS のような「外から攻撃される系」のバグと違って、こっちは 自分のコードが自分のデータを壊している タイプ。攻撃者は要りません、自分一人で被害を出せます。同じくらい怖い。


この記事は 2026 年 4 月時点の自分の理解で書いています。Django の挙動や推奨パターンは時間とともに変わる可能性があるので、参考にする際は最新の公式ドキュメント(特に Model.save() の update_fields の項目)も合わせて確認してください。