Djangoのマイグレーションシステムは、モデルの変更をデータベーススキーマに反映させる強力な仕組みです。しかし、アプリケーションの運用が続く中で、単純なカラム追加だけでなく、既存のデータを新しいテーブル構造に移行するような、より複雑なリファクタリングが必要になることがあります。
このような「スキーマ変更(テーブル構造の変更)」と「データ移行(レコードの移動や変換)」を、makemigrationsが自動生成した一つのマイグレーションファイル内で同時に実行しようとすると、デプロイ時やロールバック時に深刻な問題を引き起こす可能性があります。
この記事では、Djangoマイグレーションを安全かつ堅牢に運用するため、スキーマ変更とデータ移行の操作を明確に分離する手法について解説します。
課題:単一マイグレーションによるリファクタリング
例えば、ECサイトの「商品(Product)」モデルがあり、当初は在庫数をモデル内に直接持っていたとします。
# 変更前の models.py (一部)
from django.db import models
class Product(models.Model):
name = models.CharField("商品名", max_length=200)
sku = models.CharField("SKU", max_length=50, unique=True)
# リファクタリング対象のカラム
stock_level = models.PositiveIntegerField("在庫数", default=0)
stock_updated_on = models.DateTimeField("在庫更新日時", auto_now=True)
# ...
運用が続くにつれ、在庫の変動履歴も管理する必要が出てきたため、stock_level と stock_updated_on を削除し、新しく StockRecord モデル(在庫履歴)を作成して正規化するリファクタリングを行うことにしました。
models.py を以下のように一気に変更したとします。
# 一気に変更した models.py (一部)
from django.db import models
from django.utils import timezone
class Product(models.Model):
name = models.CharField("商品名", max_length=200)
sku = models.CharField("SKU", max_length=50, unique=True)
# stock_level と stock_updated_on は削除された
# ...
class StockRecord(models.Model):
"""
新設した在庫履歴モデル
"""
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="stock_records")
quantity = models.PositiveIntegerField("在庫数")
recorded_at = models.DateTimeField("記録日時", default=timezone.now)
class Meta:
ordering = ['-recorded_at']
この状態で manage.py makemigrations を実行すると、Djangoは「Product から2つのカラムを削除」し、「StockRecord モデルを作成」するマイグレーションファイルを生成します。
ここで、既存の stock_level のデータを StockRecord に移行するため、生成されたマイグレーションファイルに RunPython 操作を追記したとします。
# 安全でない単一マイグレーションファイルの例 (例: 0003_auto_....py)
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
def migrate_stock_data(apps, schema_editor):
# 既存の在庫データを新しいテーブルに移行する
Product = apps.get_model("inventory", "Product")
StockRecord = apps.get_model("inventory", "StockRecord")
records_to_create = []
for product in Product.objects.all():
# この時点で product.stock_level は存在するのか?
if product.stock_level > 0:
records_to_create.append(
StockRecord(
product=product,
quantity=product.stock_level,
recorded_at=product.stock_updated_on
)
)
StockRecord.objects.bulk_create(records_to_create)
class Migration(migrations.Migration):
dependencies = [
('inventory', '0002_previous'),
]
operations = [
# 1. 新しいテーブルを作成
migrations.CreateModel(
name='StockRecord',
fields=[
# ... (fields定義) ...
],
),
# 2. データを移行
migrations.RunPython(migrate_stock_data, reverse_code=migrations.RunPython.noop),
# 3. 古いカラムを削除
migrations.RemoveField(
model_name='product',
name='stock_level',
),
migrations.RemoveField(
model_name='product',
name='stock_updated_on',
),
]
このアプローチには、以下のような重大なリスクが伴います。
ロールバックの危険性
もし migrate_stock_data のロジックにバグがあり、RunPython の途中で失敗した場合、マイグレーション全体がロールバックされます。しかし、RemoveField(カラム削除)は、データ損失を伴う不可逆的な操作です。データベースによっては、トランザクション内でスキーマ変更が正しくロールバックされず、データが失われた状態になる危険性があります。
デプロイ時の競合
このマイグレーションが本番環境で実行されている最中、アプリケーションの古いコード(まだ product.stock_level を参照しているコード)は稼働し続けているかもしれません。RemoveField オペレーションが実行された瞬間に、古いコードからのアクセスは NoSuchColumn エラーを引き起こし、サービスが停止します。
堅牢なアプローチ:マイグレーションの分割
安全なリファクタリングは、操作を「スキーマ追加」「データ移行」「スキーマ削除」の3つのステップ(=3つの独立したマイグレーションファイル)に分割することで実現できます。
ステップ1:スキーマ(追加)マイグレーション
まず、新しいモデルを追加する(CreateModel)マイグレーションだけを実行します。この時点では、古いカラム(Product.stock_level)は削除しません。
models.pyでStockRecordモデルを追加します。(Productのカラムはそのまま残します)manage.py makemigrationsを実行します。0003_create_stockrecord.pyのような、CreateModel(StockRecord)のみを含むマイグレーションファイルが生成されます。
このマイグレーションをデプロイしても、既存のコードは影響を受けません。
ステップ2:データ移行マイグレーション
次に、データを移行するためだけの空のマイグレーションファイルを作成し、RunPython を記述します。
manage.py makemigrations inventory --empty --name migrate_stock_dataを実行します。0004_migrate_stock_data.pyのような空のファイルが生成されます。- このファイルに、
RunPythonオペレーション(migrate_stock_data関数)を記述します。
# 0004_migrate_stock_data.py
from django.db import migrations
def migrate_stock_data(apps, schema_editor):
Product = apps.get_model("inventory", "Product")
StockRecord = apps.get_model("inventory", "StockRecord")
records_to_create = []
# この時点では Product.stock_level は確実に存在している
for product in Product.objects.all():
records_to_create.append(
StockRecord(
product=product,
quantity=product.stock_level,
recorded_at=product.stock_updated_on
)
)
StockRecord.objects.bulk_create(records_to_create)
class Migration(migrations.Migration):
dependencies = [
('inventory', '0003_create_stockrecord'), # ステップ1に依存
]
operations = [
migrations.RunPython(migrate_stock_data, reverse_code=migrations.RunPython.noop),
]
このマイグレーションは、Product のカラム(stock_level)から StockRecord へデータをコピーするだけです。スキーマの削除は行いません。もしこの移行に失敗しても、0003 までロールバックすればよく、データ損失のリスクはありません。
注意: このステップと同時に、アプリケーションコードを更新し、在庫の読み書きを
StockRecordモデルで行うように切り替える必要があります。
ステップ3:スキーマ(削除)マイグレーション
ステップ2のデータ移行が完了し、アプリケーションコードが新しい StockRecord モデルを完全に利用するようになったことを確認した後で、最後に古いカラムを削除します。
models.pyを編集し、Productモデルからstock_levelとstock_updated_onを削除します。manage.py makemigrationsを実行します。0005_remove_product_stock_fields.pyのような、RemoveFieldオペレーションのみを含むマイグレーションファイルが生成されます。
このマイグレーションは、もはや誰からも参照されなくなったカラムを安全に削除するだけの、単純な操作となります。
まとめ
Djangoのマイグレーションは、データベースのスキーマ変更とデータ操作を分離することで、その安全性が飛躍的に高まります。
- スキーマの追加(
CreateModel,AddField) - データの移行(
RunPython) - スキーマの削除(
RemoveField)
これら3つのステップを個別のマイグレーションファイルとして実行するアプローチは、一見すると手間がかかるように見えます。しかし、この分離こそが、本番環境でのデプロイの安全性、ロールバックの確実性、そしてシステム全体の堅牢性を担保するための鍵となります。
