【Django】後方互換性を保つデータベース変更をしよう（SeparateDatabaseAndState）

# 前置き

サービスの継続的な運営のために後方互換性を保つ開発は重要です。

DjangoのようなWebバックエンドフレームワークを利用する際、一度作成したテーブルにカラムを追加したり削除するのはよくあるでしょう。

その際、ORMの定義と実際のテーブル定義にギャップが生じる状態がありえます。

この記事では、Djangoにおいて後方互換性を保って安全にリリースすることで継続的なサービス開発を行う方法を紹介します。

なお、この記事を書くにあたって検証したコードのバージョンは以下の通りです。

- Python: 3.13
- Django 5.1.2
- データベース: SQLite3

## 問題が起きるケース

DB変更を伴うDjangoアプリケーションをリリースするときに以下の2点を**完全に同時に**行うことは難しいです。

- ソースコードの変更（差し替え）
- DBマイグレーション実施（DDL適用）

※たとえリリースが自動化されていたとしても。

以下のテーブルがあるとします。

```python:models.py
# Before: 以下の例の「Blue」のコード
from django.db import models

class User(models.Model):
    name = models.CharField(max_length=100)
    bio = models.TextField()
```

サービスの仕様変更に伴い `bio` カラムを削除する流れを考えてみましょう。

```diff_python
# After: 以下の例の「Green」のコード
class User(models.Model):
    name = models.CharField(max_length=100)
-   bio = models.TextField()
```

例えば一般的なリリース方式であるBlue/Greenデプロイの場合を考えてみます。

### (1/4) Before


リリース前のアプリケーションが稼働中（Blue）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154711/0fba87cd-f037-e2c5-eaed-2a2d9a66a6db.png)


### (2/4) Install

リリースパイプラインによって新しいアプリケーションが起動する（Green）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154711/d69be6ae-1bed-5d88-e95d-acba9008949f.png)

ここまでは稼働中のアプリケーションには一切の変更がなく、問題なく動作します。
※**ORM（Djangoモデル）の定義とテーブル定義が一致している**

```python
>>> # 発行クエリを確認
>>> from app.models import User
>>> print(User.objects.all().query)
SELECT "app_user"."id", "app_user"."name", "app_user"."bio" FROM "app_user"
```

### (3/4) Apply Migration

マイグレーション適用のステップを実行する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154711/3933a6f2-da7b-cf17-9dcf-40ddca174d2f.png)

`bio`カラムを削除した後は**ORMが発行するクエリとテーブル定義が一致しない**ため、クエリ実行がエラーとなります。

```python
>>> User.objects.all()
Traceback (most recent call last)
...
OperationalError: no such column: app_user.bio
```

### (4/4) BlueからGreenへトラフィックを切り替える

トラフィックの切り替え以降、ORMとテーブル定義が一致するためエラーは起きなくなります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154711/946c1aa9-175b-44b1-d8df-6c908b763f5e.png)

---

上記で紹介した例の2から3の間の時間帯でDjangoのクエリ実行が失敗する場合があります。
※例の場合では順序を「2\. マイグレーション適用」、「3. トラフィック切り替え」としましたが、仮にこれを逆の順序にしてもカラム追加のケースで同様の事象が起きます。

自動化されたリリース処理で数秒～1分以内程度なら小さな影響と見なせるかもしれませんが、リリースの度にこのようなエラーを前提とするのはよろしくありません。

これを解決できるのがDjangoマイグレーションの機能の一つである `SeparateDatabaseAndState` です。

# SeparateDatabaseAndStateとは

以下の記事の説明文が分かりやすいと思ったので引用します。

[[Django]モデルを別のアプリに移動する方法](https://zenn.dev/ikemo/articles/django-move-model-between-apps)

> SeparateDatabaseAndState とは、Django のマイグレーションの操作で、その名の通り、データベース操作と状態操作を分離する機能です。
> 通常はこの2つは一致するのですが、 SeparateDatabaseAndState では、あえてこの二つを分離します。
> - 移動元のアプリケーションからモデルを削除したことにする(データベースは何も変更しない)
> - 移動先のアプリケーションに同じモデルを作成したことにする(データベースは何も変更しない)

これを使うことで、Djangoが「`bio`カラムは削除されたもの」と見なしORMが発行するクエリから`bio`カラムを除外させられるようになります。

# カラム削除の実践例

リリースステップを2回に分ける方針です。

- Step1: 「`bio`カラムは削除されたもの」とDjangoに伝える（SeparateDatabaseAndStateを使う）
- Step2: 実際に`bio`カラムを削除する

## Step1

SeparateDatabaseAndStateを使います。
また、NOT NULL制約がある場合は先に消去しておきます。

```diff_python
# models.py
class User(models.Model):
    name = models.CharField(max_length=100)
-   bio = models.TextField()

# migrations/0002_remove_user_bio_step1.py
from django.db import migrations, models

class Migration(migrations.Migration):

    dependencies = [
        ('app', '0001_initial'),
    ]

    operations = [
        # 1-1. Remove NOT NULL constraint
        migrations.AlterField(
            model_name='user',
            name='bio',
            field=models.TextField(null=True),
        ),
        # 1-2. Use SeparateDatabaseAndState migration
        migrations.SeparateDatabaseAndState(
            state_operations=[
                migrations.RemoveField(
                    model_name='user',
                    name='bio',
                ),
            ],
        )
    ]
```

`SeparateDatabaseAndState`によって実際のカラム削除は**されません**（NULLABLEにはなる）。

これにより、ORMのモデル定義として`bio`が存在する状態、しない状態ともにエラー無くクエリを実行できます。

```python
from app.models import User

# --- 1. Before: bio が残っているコード（Blue）
print(User.objects.all().query)
# SELECT "app_user"."id", "app_user"."name", "app_user"."bio" FROM "app_user"

print(User.objects.all().query) # テーブルには bio カラムがまだ存在するのでエラーなく実行できる
# <QuerySet [...]>

# --- 2. After: bio が無いコード（Green）
print(User.objects.all().query)
# SELECT "app_user"."id", "app_user"."name" FROM "app_user"

print(User.objects.all().query)  # もちろんエラー無く実行できる
# <QuerySet [...]>
```

## Step2

マイグレーションコードによって実際に`bio`カラムを削除します。

```python:0003_remove_user_bio_step2.py
from django.db import migrations

class Migration(migrations.Migration):

    dependencies = [
        ('app', '0002_remove_user_bio_step1'),
    ]

    operations = [
        migrations.RunSQL(
            "ALTER TABLE app_user DROP COLUMN bio",
            reverse_sql="ALTER TABLE app_user ADD COLUMN bio TEXT NULL",
        ),
    ]
```

なおDjangoは既に「usersテーブルに`bio`カラムは存在しない」と認識しているため、`migrations.RemoveField` 操作はそのまま使えません。

```python
class Migration(migrations.Migration):

    dependencies = [
        ('app', '0002_remove_user_bio_step1'),
    ]

    operations = [
        migrations.RemoveField(
            model_name='user',
            name='bio',
        ),
    ]
```

```shell
python manage.py migrate app
...
KeyError: 'bio'
```

# まとめ

サービスの継続開発において後方互換性を維持するのDB変更の方法を紹介しました。
必要となるシーンは多そうですが意外と知られていないように思います（私も昨日まで知らなかった）。

カラム削除の例を紹介しましたが、同様のアプローチでカラム追加やリネームにも対応できます（多分）。
Django開発者の助けになれば幸いです。

参考リンク:

- [マイグレーション・オペレーション | Django ドキュメント | Django](https://docs.djangoproject.com/ja/5.1/ref/migration-operations/#separatedatabaseandstate)
- [[Django]モデルを別のアプリに移動する方法](https://zenn.dev/ikemo/articles/django-move-model-between-apps)
  - テーブル名変更の例を紹介しています
- [Zero Downtime Django Deployments with Multistep Database Changes | Johnny Metz](https://johnnymetz.com/posts/multistep-database-changes/)