Django 4.2から5.2へ(前編)リリースノートに載っていない落とし穴

皆さま、こんにちは。グローバルウェイのKです。
既存の業務システムの保守運用に携わっており、今回、稼働中のシステムでDjangoを4.2系から5.2系へバージョンアップしました。その知見を、前編・後編の2回に分けてお伝えします。
バージョンアップと聞くと、番号を書き換えて動かせば終わり、というイメージを持たれることもあります。ですが、実際にはそう単純ではありませんでした。最終的には、後編で扱う「ソースコードに存在しない列名のエラー」への対応まで必要になりました。
前編で扱うのは、そのエラーに出会う前の「準備」の段階です。地味な工程ですが、後の原因調査を大きく左右する重要な部分でした。

この記事は以下の方を対象としています。

★5 Django開発経験が3年以上。
★4 Django開発経験が1年以上
★3 Webサイト開発経験あり。これからDjangoを学習します。
★2 Python 初級者。簡単なプログラムコードが書けます。
★1 プログラミング未経験。

まとめて最新化しない、という判断

バージョンアップでは、関連パッケージもまとめて最新にする進め方がよく採られます。一度に済ませたほうが、手間は減ります。ただ、これには落とし穴があります。
あとから不具合が出たとき、Django 5.2が原因なのか、別のパッケージが原因なのか、切り分けが難しくなるのです。
そこで今回は、着手前に方針を固めました。リリースノートに掲載された変更点をすべて洗い出し、自システムでの使用有無と、使用している場合の対応をまとめました。そのうえで、変更点を一覧化し、計画を立ててから作業に入りました。
この計画は有効でしたが、この後で実際に苦労した2つのトラブルは、いずれもリリースノートには記載のない挙動変化でした。計画的に備えても読み切れない部分があります。だからこそ、次に述べる切り分けの土台が効いてきます。

変更前の実行環境を基準点として確定する

最初に行ったのは、設定ファイルに書かれたバージョンではなく、実際にコンテナ内で動作している環境の確認です。確認結果は次の通りでした。

# 変更前の基準点(実コンテナ内で確認)
Python      3.12.2
Django      4.2.26
PostgreSQL  16.2

ここで重要だったのは、バージョン番号を確認すること自体ではありません。アップグレード後に問題が起きたとき、「何を変えたことで動作が変わったのか」を比較できる基準点を作ることでした。
この基準点は、後編の調査で実際に効いてきます。同じソースコード、同じPython、同じPostgreSQL、同じデータベース、同じ検索条件のまま、Djangoのバージョンだけを変えて比較する。この切り分けができたのは、変更前の環境を確定していたからです。もし全部を同時に更新していたら、問題が起きても、どの変更が原因なのか判断できなくなっていたはずです。

依存関係はrequirements.inを起点に、範囲を絞って更新する

このシステムでは、直接利用するパッケージを requirements.in に記載し、pip-tools の pip-compilerequirements.txt を生成しています。requirements.in が直接依存の定義、requirements.txt がそこから生成されるロックファイル、という位置付けです。
ここで大事なのは、requirements.txt を直接編集しないことです。requirements.txt はpip-compileが依存関係を解決して生成したファイルです。あるパッケージのバージョンだけを手で書き換えると、紐づく依存パッケージとの整合が取れなくなります。紐づくバージョンまで含めて適切に更新するには、requirements.in を変更元にして、pip-compileで生成し直す必要があります。
そのうえで、更新対象はできるだけ絞りました。requirements.txt を再生成すると、Djangoと無関係なパッケージにも差分が出ます。すべてを最新化すると、不具合発生時の原因候補が増えてしまいます。そこで、必要なパッケージだけを更新し、無関係な差分を増やさない方針にしました。これは、後で原因を切り分けるための準備でもあります。

最初のORMエラーは型の明示で解消した

Django 5.2へ変更した後、最初に問題になったのが Concat を使用した処理です。あるデータの名称や住所に、文字列とIDを連結する処理でした。連結対象のフィールドは CharField、IDは BigAutoField です。文字列型と数値型を一つの式で組み合わせており、Django 4.2では動作していましたが、5.2では型を明示する必要が生じました。

# 修正前:Django 4.2 では動作していたが、5.2 でエラーになった
Concat(
    F("location_name"),
    Value("deleted"),
    F("id"),                     # ← BigAutoField をそのまま連結
)
# 修正後:型を明示して解消した
Concat(
    F("location_name"),
    Value("deleted"),
    Cast(F("id"), CharField()),   # ← 文字列へ変換
    output_field=CharField(),      # ← 連結結果全体の型も明示
)

対応として、Cast(F("id"), CharField()) でIDを文字列へ変換し、さらに output_field=CharField() を指定して連結結果全体の出力型も明示しました。この修正で処理は正常に動作しました。同じPythonコードでも、Djangoのバージョンが変わるとORMの式の型推論の挙動が変わることがあります

この型の問題は、リリースノートには明記されていませんでした。動かして初めて表面化した挙動変化です。計画に含めていなかった変化だからこそ、先に現在地を整理していたことが切り分けに役立ちました。

準備は、速さより「あとで説明できること」のためにある

前編で伝えたいのは、修正の速さよりも、比較できる環境と、説明できる差分を先に作ることが重要である、という点です。現在地と変更範囲を整理しておいたことで、後から発生した問題を、Djangoの変更による影響として切り分けやすくなりました。急いで直したくなる場面ほど、この準備が効いてきます。

後編では、ソースコードに存在しない列名のエラーを、生成SQLとバージョン差分から追った過程をお伝えします。

※本記事のサムネイル画像は、生成AIを使用して作成しています。