Claude CodeでDjangoのsqlmigrateを読み、本番のロックを事前に洗う
モデル変更の出し忘れはStop hookのmakemigrations --checkで止め、本番に流す前にsqlmigrateのSQLからロックと再書き込みをClaude Codeに洗わせる手順です。
Claude CodeでDjangoのsqlmigrateを読み、本番のロックを事前に洗う
Djangoのマイグレーションで本番が止まるとき、原因はPythonのコードよりも、そこから生成されるALTER TABLEの種類にあることが多くなります。python manage.py sqlmigrateは、適用前にそのSQLを表示するコマンドです。
この記事では、Claude Codeを使って2段の関門を作ります。1段目は、Stop hookでmakemigrations --checkを走らせ、マイグレーションの出し忘れを止めること。2段目は、sqlmigrateの出力をClaude Codeに読ませ、長時間ロックや全行の書き換えを起こす文を適用前に拾うことです。PostgreSQLを前提に書き、MySQLとSQLiteの違いは後半にまとめます。
本番で止まるのはmigrateではなくALTER TABLEの待ち行列
python manage.py migrate自体は、本番で特別に遅いコマンドではありません。遅くなるのは、その中身のALTER TABLEやCREATE INDEXが、テーブルに対して強いロックを取る場合です。
PostgreSQLのALTER TABLEは、特に断りがなければACCESS EXCLUSIVEロックを取ります。このロックはあらゆるロックモードと衝突します。保持している間、他のトランザクションはそのテーブルを読むこともできません。
さらに、Djangoは対応するデータベースでは、1つのマイグレーションの全操作を1つのトランザクションで実行します。PostgreSQLではロックは原則としてトランザクションの終わりまで保持されます。つまり、1つ目の操作が取ったロックは、同じマイグレーションの最後の操作が終わるまで解放されません。
ロック評価で見る3点
- どのテーブルにどの強さのロックを取るか
- テーブル全体の書き直しや全行の走査が入るか
- その操作が同じトランザクションの中で何件重なるか
この3点は、Pythonのマイグレーションファイルより、sqlmigrateが出すSQLのほうが読み取りやすい情報です。ファイルのAlterFieldが型変更なのか制約の追加なのかは、SQLにするとはっきり分かれます。
Stop hookでmakemigrations --checkをかける
ロックを評価する前提として、モデルを変えたのにマイグレーションを作っていない状態を先に潰します。makemigrations --checkは、マイグレーションのないモデル変更を検出すると非ゼロの終了コードで終わります。このオプションは--dry-runを含むので、ファイルは書き出されません。
hookの置き場所には、2つの選択肢があります。
| 置き場所 | 動くタイミング | 向き不向き |
|---|---|---|
PostToolUse(Edit) | 動くタイミングmodels.pyを1回編集するたび | 向き不向き複数のモデルを続けて直す途中でも毎回失敗しうる |
| Stop | 動くタイミングClaudeが作業を終えようとしたとき | 向き不向き変更が出そろった時点で1回だけ判定できる |
Stopのほうが、「モデルを変えたらマイグレーションを作ってから終える」という約束に合います。Stop hookは終了コード2で、Claudeが止まるのを防いで会話を続けさせます。stderrの内容は、Claudeへの指示として渡ります。
次のスクリプトは、.claude/hooks/check-migrations.shに置く例です。
#!/bin/bash
# Stop hook: モデル変更にマイグレーションが付いているか確かめる
INPUT=$(cat)
# stop hook の再実行中は二重に止めない
if echo "$INPUT" | grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then
exit 0
fi
cd "$CLAUDE_PROJECT_DIR" || exit 0
if ! OUT=$(python manage.py makemigrations --check --no-input 2>&1); then
{
echo "モデルの変更にマイグレーションが付いていません。"
echo "python manage.py makemigrations を実行し、生成された"
echo "ファイルの中身を読んでから終了してください。"
echo "$OUT"
} >&2
exit 2
fi
exit 0--no-inputは、リネーム確認のような対話の質問を抑える指定です。抑えた質問が自動で解決できないときは、終了コード3で終わります。どちらも非ゼロなので、このスクリプトでは同じ「出し忘れ」の扱いになります。
stop_hook_activeは、Stop hookによってClaudeがすでに作業を続けている最中かを示す入力フィールドです。確認しないまま毎回2を返すと、解消しない条件で止め続けるおそれがあります。
設定は.claude/settings.jsonに書きます。Stopイベントにはmatcherを付けません。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-migrations.sh",
"timeout": 60
}
]
}
]
}
}hookはあくまで補助の関門です。Claude Codeを使わない人の変更も受けるCIには、同じmakemigrations --checkを別に置いておく構成が確実です。PostToolUseとStopの使い分けは、hooksでテストを自動実行する記事でも同じ軸で比べています。
sqlmigrateは本番でなくコピーDBに向ける
sqlmigrateはマイグレーションが生成するSQLを標準出力に出します。ここで見落としやすい条件が1つあります。このコマンドは、制約名を解決するためにデータベースへの接続を必要とします。
Djangoのリファレンスは、SQLを適用先と同じ構成の「データベースのコピー」に対して生成するよう求めています。本番に直接つなぐ必要はありません。
settings.pyに、本番のスキーマをリストアしたコピーを指す別名を用意しておきます。
DATABASES = {
"default": {...}, # 開発用
"copy": { # 本番スキーマのコピー(読み取り専用の接続情報)
"ENGINE": "django.db.backends.postgresql",
"NAME": "app_copy",
"HOST": "db-copy.internal",
"USER": "readonly_user",
"PASSWORD": os.environ["COPY_DB_PASSWORD"],
},
}sqlmigrateには--databaseオプションがあり、SQLを生成する対象の別名を選べます。--backwardsを付けると、戻す方向のSQLも見られます。
python manage.py showmigrations polls
python manage.py sqlmigrate --database copy polls 0004
python manage.py sqlmigrate --database copy --backwards polls 0004Claude Codeに任せる範囲は、権限で切っておきます。sqlmigrateは出力するだけなので許可し、データベースを書き換えるmigrateは毎回確認に回す構成です。
{
"permissions": {
"allow": [
"Bash(python manage.py makemigrations *)",
"Bash(python manage.py showmigrations *)",
"Bash(python manage.py sqlmigrate *)"
],
"ask": ["Bash(python manage.py migrate *)"]
}
}コピーDBの接続情報には読み取り専用のユーザーを使います。本番のパスワードをClaude Codeの手元に置かない考え方は、Alembicのマイグレーションを書く記事の権限分離と同じです。
出力のSQLを読む早見表
sqlmigrateの出力は、例えば次のような形になります。内容は説明用の例で、実際の出力はDjangoとデータベースのバージョンで変わります。
BEGIN;
-- Add field status to question
ALTER TABLE "polls_question" ADD COLUMN "status" varchar(20) DEFAULT 'open' NOT NULL;
-- Create index polls_question_status_idx on field(s) status of model question
CREATE INDEX "polls_question_status_idx" ON "polls_question" ("status");
COMMIT;BEGINとCOMMITに挟まれているのは、さきほど述べた「全操作が1つのトランザクション」の表れです。この3行を、PostgreSQLの挙動に照らして読みます。
| SQLの形 | 起きること | 回避の方向 |
|---|---|---|
ADD COLUMN(定数のDEFAULT) | 起きることテーブルの書き直しなし。メタデータの更新で済み、巨大なテーブルでも速い | 回避の方向そのままでよい |
ADD COLUMN(clock_timestamp()のような揮発性DEFAULT、生成列、IDENTITY列) | 起きることテーブルと全インデックスの書き直し | 回避の方向nullableで追加し、値は別のデータ移行で埋める |
ALTER COLUMN ... TYPE | 起きること通常はテーブルとインデックスの書き直し。バイナリ互換の型変更は例外 | 回避の方向新しい列を足してコピーし、切り替える |
ADD CONSTRAINT(CHECK)/SET NOT NULL | 起きること既存の全行を走査。コミットまで他の更新が待たされる | 回避の方向NOT VALIDで追加し、別にVALIDATEする |
CREATE INDEX(通常) | 起きること書き込みをブロック。読み取りは可能 | 回避の方向CREATE INDEX CONCURRENTLY |
ADD FOREIGN KEY | 起きること両方のテーブルにSHARE ROW EXCLUSIVEロック | 回避の方向NOT VALIDで追加し、別にVALIDATEする |
先ほどの例のADD COLUMNは、定数のDEFAULTなので問題になりません。ここで引っかかるのは、直後のインデックス作成です。通常のCREATE INDEXは、完了までpolls_questionへの書き込みを待たせます。
Djangoには、回避策がそのまま演算として用意されています。django.contrib.postgres.operationsのAddIndexConcurrentlyはCONCURRENTLY付きでインデックスを作ります。この演算はトランザクションの中では実行できないので、マイグレーションをatomic = Falseにします。
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False
operations = [
AddIndexConcurrently(
"question",
models.Index(fields=["status"], name="polls_question_status_idx"),
),
]制約も同じ考え方です。AddConstraintNotValidで既存行の走査を飛ばして追加し、ValidateConstraintで後から検証します。この2つは、別々のマイグレーションに分けて実行するよう求められています。同じ原子的なマイグレーションに入れると、通常のAddConstraintと同じ効果になってしまうためです。
Claude Codeにロック評価を任せる指示
早見表を毎回人が引くのは手間なので、CLAUDE.mdに手順として書き、Claude Codeに表の分類までやらせます。
## マイグレーションを出すとき
- モデルを変えたら makemigrations のあと、必ず
`python manage.py sqlmigrate --database copy <app> <番号>` を実行する
- 出力の ALTER TABLE / CREATE INDEX を1文ずつ、次の3つに分類して報告する
1. 書き直し・全行走査が入る文(型変更、揮発性DEFAULT、CHECK / NOT NULL の追加)
2. 書き込みを止める文(通常の CREATE INDEX)
3. 問題のない文(定数DEFAULTの ADD COLUMN など)
- 1と2がある場合は、対象テーブルの行数を私に聞く(自分で推測しない)
- 回避策は AddIndexConcurrently / AddConstraintNotValid + ValidateConstraint を使い、
別のマイグレーションに分ける「行数を聞く」と書く理由は、ロックの長さがテーブルの大きさで決まるためです。コピーDBが小さな開発データだと、型変更も一瞬で終わるように見えます。行数は、本番で確かめた数字を人がClaude Codeに渡します。
sqlmigrateの出力を読んで文を分類し、回避用のマイグレーションに書き直す作業は、Claude Codeに任せられます。テーブル規模と業務上止められる時間は、人が持っている情報です。この分担を指示に書いておきます。
使い方は、次のように頼むだけです。
polls の 0004 を sqlmigrate で出して、ロックの観点で分類して。
polls_question は約8,000万行あるよ。Claude Codeのhooksの全体像はhooksの設計ガイドに、Djangoアプリ自体の組み立てはMVT構成で開発する手順にまとめています。
MySQLとSQLiteでは読む点が変わる
早見表はPostgreSQLの挙動です。データベースが違えば、同じSQLでも評価が変わります。
MySQLにはスキーマ変更をトランザクションで囲む仕組みがありません。マイグレーションの途中で失敗すると、適用済みの変更を手で戻してからやり直すことになります。「失敗したら自動で戻る」前提でSQLを評価できないため、1つのマイグレーションに入れる操作を減らしておく必要があります。
MySQL 8.0ではDDLの性能が大きく改善されましたが、ロックがまったく起きない保証はありません。ロックが必要になる場面では、所要時間が行数に比例します。
SQLiteは、新しいスキーマでテーブルを作り、データをコピーし、元のテーブルを落として名前を付け替えるという手順でALTERを模倣します。本番環境でSQLiteをmigrateすることは、リスクを理解した上でなければ勧められていません。
よくあるつまずき
- コピーDBの規模が本番と違う。SQLの種類は同じでも、所要時間はまったく違います。コピーは構成の確認用、時間の見積もりは本番の行数で行います。
RunPythonのデータ移行がSQLとして読めない。RunPythonはPythonの関数を呼ぶ操作で、中身はsqlmigrateの出力から読み取れません。データ移行のコード自体を、Claude Codeにレビューさせます。sqlmigrateが接続エラーで止まる。このコマンドはDB接続が必須です。--databaseで指定した別名の接続情報が、Claude Codeを起動した環境から届く状態かを確かめます。- Stop hookが何度も止まる。
stop_hook_activeの判定が抜けていないかを見ます。マイグレーション生成後も--checkが通らないなら、--no-inputで抑えた質問が残っています。
まとめ
Stop hookのmakemigrations --checkは、マイグレーションの出し忘れという機械的なミスを止めます。ロックの評価はその先の仕事で、sqlmigrateの出力を3つに分類する指示と、人が渡す行数の組み合わせで進めます。
CREATE INDEXと制約の追加は、DjangoのAddIndexConcurrentlyやAddConstraintNotValidに置き換えられることが多い箇所です。ALTER TABLEが見つかったとき、最初に行数を確認する習慣をつけておくと、適用日の予定を立てやすくなります。Flywayを使う構成なら、Flywayのinfoとvalidateで確認する手順が同じ位置づけです。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。