django-migrations について
Django のマイグレーションを変更するエンジニア向けのプロジェクト固有スキルです。段階的な展開と履歴の維持、専用ヘルパーによるスキーマ変更のリスク低減を案内します。
主な機能
- 01マイグレーション削除の防止
- 02テーブルと列の段階的な廃止
- 03増加する列挙値向けの callable choices
- 04生成と検証の手順
- 05プロジェクト固有の安全ヘルパー
活用シーン
- 01スキーマ変更案の確認
- 02モデル、テーブル、列の廃止
- 03マイグレーション競合の解消
- 04列挙値の追加に伴うマイグレーションの抑制
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ posthog/ posthog --skill django-migrationsインストール後、Claude Code で/django-migrationsを入力して読み込まれたか確認します。
詳細
Django マイグレーション
マイグレーション履歴やマイグレーションの実行方法に変更を提案する前に、すでに試されたことを確認してください。ここには、履歴のスクワッシュと Person テーブルの切り替えに関する、クローズ済みの試みが記録されています。
マイグレーションを作成または編集する前に、まず以下のファイルを読んでください。
docs/published/handbook/engineering/developing-locally.md(## Django migrations、### Non-blocking migrations、### Resolving merge conflicts)docs/published/handbook/engineering/safe-django-migrations.mddocs/published/handbook/engineering/databases/schema-changes.mdproducts/*で作業する場合はproducts/README.md(## Adding or moving backend models and migrations)
タスクが ClickHouse のマイグレーションである場合は、代わりに clickhouse-migrations を使用してください。
マイグレーションファイルを決して削除しないこと
マイグレーションの追加は問題ありません。過去のマイグレーション——master にすでにある */migrations/NNNN_*.py、たとえアプリの 0001_initial.py であっても、スキーマ変更を「元に戻す」ためであっても——を削除することは許されません。 ファイルを削除しても何も元には戻りません。マイグレーションが実行されたすべてのデータベースにはテーブルとその制約が残り、新規データベースではそれらが再作成されることはなく、「Migration Risk Analysis」CI ジョブが、削除前のすべてのオープン PR でそのファイルを新規のファントムマイグレーションとして再びフラグ付けします。repo-checks CI ジョブの削除済みマイグレーション検査(hogli lint:migration-deletions コマンド)がこれをブロックします。本当に意図的でレビュー済みの削除——プロダクトやアプリの移動、リバート、スクワッシュ——は .github/scripts/migration-deletion-allowlist.txt で承認されます。ガードを無効化することによってではありません。
タスクがマイグレーションファイルの削除を求める場合は、停止して代わりにそれを報告してください。
モデルやテーブルを廃止するには、次のようにします。
- すべての使用箇所とモデルクラスを削除します。
makemigrationsを実行し、生成されたDeleteModelをmigrations.SeparateDatabaseAndState(state_operations=[...])(state のみ、DB 変更なし)でラップします。このファイルは保持してください。アプリはINSTALLED_APPSに残してください。 モデルがposthog_team、posthog_user、posthog_organization、posthog_projectへのForeignKeyを持っている場合は、この同じマイグレーション内のdatabase_operationsでDropForeignKeyを使ってその制約を削除してください。 これは必須であり、クリーンアップではありません。Django は見えなくなったテーブルへのカスケードを停止するため、子行は親の削除後も残ります。これらの制約はDEFERRABLE INITIALLY DEFERREDであるため、親の削除はカスケード全体を実行した後にCOMMITで失敗し、誰かがテーブルを削除するまでチームと組織の削除は壊れたままになります。 - デプロイし、少なくとも 1 回の完全なデプロイサイクルを待ちます。
- 後で新しいマイグレーションで
SafeDropTableを実行します——古いファイルを削除することによってではありません。手順 1 でホットな親テーブルへの外部キーを残した場合は常に、これを選択肢ではなく負うべき作業として扱ってください。DROP TABLEは、その外部キーが参照するすべてのテーブルに対して、1 つのリレーションずつACCESS EXCLUSIVEを取得するため、そのロック順序は実行中の複数テーブル読み取りのロック順序と交差し、両者がデッドロックします。lock_timeoutはこれを防ぎません。デッドロック検出器が先にサイクルを解決し、最も長く待機したセッションを終了させるからです。それは実行中の読み取り(500)か、マイグレーション(ブロックされたデプロイ)のどちらかです。SafeDropTableはdeadlock_timeoutから導出されたバジェットの下で、すべてのロックを事前に取得するため、代わりにマイグレーションが競争に負けます。RunSQLを手書きしないでください。ホットテーブルの危険を参照してください。python manage.py audit_orphan_hot_table_fksは、すでにこの状態にあるテーブルを一覧表示します。これを長期間稼働しているデータベースに対して実行してください。スクワッシュされた履歴には、スクワッシュ前に Django の state から離れたテーブルのCreateModelが存在しないため、新規データベースではそのテーブルが作成されることはなく、マイグレーションファイルにもその痕跡は残りません。
完全なガイド: safe-django-migrations.md(## Dropping Tables、### Removing a whole product or app)。ブランチが追加したが master にマージされたことのないマイグレーションの削除は許可されています(再生成)。
カラムの廃止
デフォルト: 削除しないでください。 フィールドを ORM から外し、カラムは残します。データは保持され、デプロイの調整も不要です。
フィールドを削除して makemigrations を実行するのは、これとは違います。Django は、書き込むすべての SELECT と INSERT で、すべての具象フィールドを名前指定します。そのため、生成される RemoveField は、コードがそのフィールドを要求しなくなるのと同じデプロイでカラムを削除し、古いリリースのままのすべての Pod がクエリに失敗します。# deprecated コメントは役に立ちません。フィールドは依然としてモデル上に残っているからです。
posthog.migration_helpers の 2 つのヘルパーが、これを正しく行います。
deprecate_field(models.IntegerField(null=True))は、その場でフィールドをラップします。マイグレーションは不要です。読み取りと書き込みは警告を出し、カラムはすべてのクエリから外れます。フィールドはあらかじめnull=Trueである必要があります。非表示になった時点で、カラムに書き込むものは何もないからです。呼び出し元が残っていないことを確認するには、raise_on_access=Trueを使用します。untrack_field("mymodel", "myfield")は、生成されるRemoveFieldを状態のみのマイグレーションに置き換えます。これにより、フィールドはモデルクラスから外れ、カラムは残ります。
外部キーのカラムは、同じマイグレーションで untrack_field と DropForeignKey を併用する必要があります。 Django がそのフィールドを認識できなくなると、親の削除がそのフィールドへカスケードしなくなり、遅延制約によって、それ以降ずっと COMMIT 時に削除が失敗します。deprecate_field は外部キーにはまったく使用できません。マイグレーションを書き込まないため、制約の削除を配置する場所がないからです。
カラムを削除するのは、これらのいずれかを完全なデプロイサイクル分デプロイした後に限り、状態の削除に続くマイグレーションで RunSQL ... DROP COLUMN IF EXISTS を使って削除します。deprecate_field から移行する場合は、ラップされた行を削除し、生成された RemoveField をまず untrack_field に置き換えます。両方のマイグレーションは 1 つの PR に含めてかまいません。makemigrations が書き出す素の RemoveField を決してリリースしないでください。これは状態の削除とカラムの削除を 1 つの操作で行い、アナライザーはそれを未ステージの削除と区別できないからです。
まず ORM 以外の読み取り側(nodejs/、rust/、Temporal ワーカー、Metabase)を確認してください。Django からフィールドを隠しても、それらについては何も変わりません。
完全なガイド: safe-django-migrations.md(## Dropping Columns)。
専用のマイグレーションテストの廃止
データマイグレーションのテストが守るのは、ロールアウトであり、製品の恒久的な動作ではありません。サポート対象のすべての環境がマイグレーションを適用し、ロールバック期間が終了し、サポート対象のアップグレードで古いデータ状態に依存するものがなくなったら、専用のテストを削除します。
期限切れのテストは、スキップとしてマークするのではなく削除してください。マイグレーションファイルは残します。新規スキーマの CI は、マイグレーションの完全なチェーンが適用されることを引き続き確認します。
このルールを、マイグレーションツール、マイグレーションの安全性チェック、再利用可能なバックフィルシステム、またはまだ実行できるバックフィルには適用しないでください。
成長する enum には呼び出し可能な choices が必要
時間の経過とともに成長する choices= リストは、追加のたびに、その enum を使用するすべてのモデルで AlterField を生成します。これらのマイグレーションは SQL を出力しません — choices は Django の Field.non_db_attrs に含まれているため、スキーマエディターはそれらをスキップします — しかし、それらは依然としてマイグレーション状態、CI のシャードごとのマイグレーションリプレイ、そして max_migration.txt に残り、マージキューで待機している他のすべてのマイグレーションと衝突します。
代わりに呼び出し可能なオブジェクトを渡してください。Django はそれを遅延評価するため、マイグレーションは関数参照を一度記録するだけで、その後二度と変更されません:
def external_data_source_type_choices() -> list[tuple[str, str]]:
return ExternalDataSourceType.choices
source_type = models.CharField(max_length=128, choices=external_data_source_type_choices)
実行時の動作は変わりません。DRF は依然として ChoiceField を構築するため、OpenAPI の enum と生成されるフロントエンドの型はそのままです。full_clean() は依然として未知の値を拒否します。admin は依然としてドロップダウンをレンダリングします。get_FOO_display() は依然としてラベルを解決します。実際のスキーマ変更(max_length、null、新しいフィールド)は依然としてマイグレーションを生成します。
enum が組織がもの(ソース、インテグレーション、製品、プロバイダー、モデル ID)を追加するにつれて成長する レジストリ である場合は、呼び出し可能なオブジェクトを使ってください。ステータスや優先度のようにドメインに固有の 閉じた語彙 には、通常のリストを維持してください。その場合、新しいメンバーは記録する価値のある真のドメイン変更です。
呼び出し可能なオブジェクトが自動で行わないことが 2 つあります:
- マイグレーションはそれをインポートパスによって永久に参照するため、名前変更や移動には独自のマイグレーションが必要です。ラップする enum の隣に置いてください。
- モジュールレベルの名前付き関数でなければなりません。共有ファクトリーから返されるラムダやクロージャにはインポート可能なパスがないため、Django はそれをシリアライズできません — enum ごとに 1 つの小さな関数が意図された形であり、因数分解して排除すべき重複ではありません。
- それが隠すのはマイグレーション状態からの choice の成長であり、カラムからではありません。フィールドの
max_lengthより長い新しいメンバーは依然として実際のスキーマ変更であるため、レジストリが成長するときは余裕を確認してください。
ワークフロー
- 分類 変更を追加的(新しい nullable カラム、新しいテーブル)またはリスクあり(drop/rename、
NOT NULL、インデックス、制約、大規模なデータ更新、モデルの移動)として分類します。変更が hot table に触れる場合も、それがどれほど追加的に見えてもリスクありです。以下の クロス言語のNOT NULLハザード も参照してください。 - 生成:
DEBUG=1 ./manage.py makemigrations [app_label]。 マージコンフリクトの場合:python manage.py rebase_migration <app> && git add <app>/migrations(posthogまたはee)。 - 安全規則を適用
safe-django-migrations.mdから — このドキュメントでは多段階ロールアウト、SeparateDatabaseAndState、並行操作、冪等性、およびすべてのリスクのあるパターンを詳細に扱っています。 - 検証:
./manage.py sqlmigrate <app> <migration_number>を実行し、テストを実行し、線形なマイグレーション順序を確認します。
マイグレーションヘルパーを使用する
posthog.migration_helpers には、リスクはあるもののよくあるケース向けのドロップイン操作が用意されています。まずこれらを検討してください。これらは Django の状態を追跡し、タイムアウトを無効化し、bin/migrate のリトライに対して冪等です。
- インデックスの追加/削除 →
SafeAddIndexConcurrently/SafeRemoveIndexConcurrently(model_name+models.Index)。Django のAddIndexConcurrentlyは決して使用しないでください。CI がブロックします。 - CHECK 制約の追加 →
AddConstraintNotValidの後に、後続のマイグレーションでValidateConstraint(またはatomic = Falseを指定した同一マイグレーション内)。 - ホットテーブルへの ForeignKey の追加 → モデル上で FK を
db_constraint=Falseで宣言し(これによりCreateModel/AddFieldが親ロックを発行しません)、その後AddForeignKeyNotValidで DB 制約を戻し、後続のマイグレーションでValidateForeignKeyを続けて実行します。ホットテーブルへの外部キーを参照してください。 - 生の SQL としてのみ表現されたインデックス(Django の
Indexなし) →SeparateDatabaseAndStateでラップしたCreateIndexConcurrently/DropIndexConcurrently。 - Django がフィールドに対して作成するインデックスの削除(
ForeignKeyまたはその他のdb_index=Trueインデックス、あるいは varchar または text フィールドの_likeコンパニオン) →db_indexがオフになるときに生成されるAlterFieldの代わりにDropFieldIndexesConcurrently(model_name=..., name=...)を使用します。これは Django の名前を導出するため、手入力されたハッシュ接尾辞付きの名前に対するリスクアナライザーのブロックは適用されません。外部キーでは、親の削除がまだ読み取るキーを、そのカラムを先頭に持つ btree インデックスなしで残すことを拒否します。 - カラムの廃止 → モデル上で
deprecate_field、または生成されるRemoveFieldの代わりにuntrack_field。カラムの廃止を参照してください。 - 廃止されたテーブルの削除 →
SafeDropTable("posthog_oldfeature")。これは参照されている親を事前にdeadlock_timeout未満のバジェットでロックするため、ロックサイクルは生きた読み取りが殺されるのではなく、マイグレーションがリトライされて終わる傾向があり、リトライに対して冪等です。ロックリストが階層のメンバーをカバーしないため、パーティション化されたテーブルや継承されたテーブルは拒否します。 - 廃止中のカラムに対する check および unique ルールの削除 →
DropColumnConstraints(table, columns=[...])を、そのカラムへの書き込みを停止するリリースの前に実行します。これはカタログ内のそれらカラムに対するすべての check、unique、exclusion 制約、およびすべての unique インデックスを検出します。これには、どのマイグレーションも名前を挙げていない、以前のスワップが残したものも含まれ、それらを1つのバウンドされたロックの下で削除します。Django が生成した名前をDROP CONSTRAINT IF EXISTSに決して入力しないでください。間違った名前は成功し、何も削除しません。リスクアナライザーは、Django のハッシュ接尾辞を持つ名前の前方削除をブロックします。 - 廃止によって残された外部キーの削除 →
DropForeignKey(table, column=...)またはDropForeignKey(table, to_table=...)。これはpg_constraintから名前を読み取るため、何もハードコードされておらず、リトライは no-op です。SafeDropTableと同様に、すべての親をロックしてから、子を1つのバウンドされたフェーズでロックします。トランザクションはCOMMITまでそれらのロックを保持するため、テーブル上のすべてのキーを1つの操作に与え(column=["a_id", "b_id"])、そのマイグレーション内で単独に保ち、せいぜい状態のみの操作と並べるようにしてください。リスクアナライザーは、1つのアトミックマイグレーション内での2つの削除、または他のデータベース操作の隣での削除をブロックします。複数のビジーな親へのキーの場合は、代わりにatomic = Falseとキーごとに1つの操作を使用し、各削除が単独でコミットされるようにし、atomic_false_acknowledged_migrations.txtでマイグレーションを承認してください。アナライザーは、キーが2つのホットテーブルに到達する1つの操作を、atomic = Falseの下でもブロックします。
すべての同時インデックス操作には atomic = False が必要です。
ヘルパーのないリスクはあるがよくあるパターンに遭遇したときのメタ原則:ドキュメントから安全な DDL を手書きしないでください。posthog/migration_helpers にドロップインヘルパーを提供し、CI ポリシーをそこに向けてください。1つのインポートのヘルパーは、注意書きだらけの RunSQL の壁に常に勝ります。
リスクアナライザーに対するスクリプト作成
posthog/management/migration_analysis/models.py には 2 つのリスクデータクラスがあります。OperationRisk は 1 つの操作をスコアリングし、MigrationRisk はマイグレーションファイル全体をスコアリングして max_score、level、category を公開します。どちらも .score に応答するため、MigrationRisk では score または max_score のどちらでも読み取ることができ、同じ数値を返します。
ホットテーブルの危険性
posthog_team、posthog_user、posthog_organization、posthog_project は、ほぼすべてのリクエストで読み取られます。これらに対する ALTER TABLE は、他の場所では「安全」な単なる nullable な AddField であっても ACCESS EXCLUSIVE ロックを必要とし、そのロック要求が処理中のクエリの後ろで待機している間、そのテーブルへの後続のすべてのクエリがその後ろにキューイングされます。メタデータのみの ADD COLUMN であっても、ALTER がロック競合に勝つまで、サイト全体のトラフィックを波状に(bin/migrate のリトライごとに 1 回)停止させることがあります。これは本番環境で 5xx インシデントを引き起こしたことがあります。
これらのモデルのいずれかに触れるマイグレーションを書く前に:
Teamの場合: ドメイン固有のフィールドは代わりに Team 拡張モデルに配置してください —posthog/models/team/README.md。これはCREATE TABLEであり、posthog_teamにロックはかかりません。CREATE INDEX CONCURRENTLY(SafeAddIndexConcurrently経由)は問題ありません —SHARE UPDATE EXCLUSIVEは読み取りも書き込みもブロックしません。- そのフィールドが本当にホットテーブルに属する場合(コアアイデンティティ、クロスプロダクト設定、SDK 設定)、
HotTableAlterPolicyアナライザーは、posthog/management/migration_analysis/hot_table_acknowledged_migrations.txtに<app_label>.<migration_name>が追加されるまで、CI でマイグレーションをブロックします。この承認は、低トラフィックの時間帯に向けてインフラとデプロイを調整することも意味します。
ホットテーブルへの外部キー
ホットテーブルを 参照する ForeignKey は、反対側からの同じ危険性であり、あらゆる アプリから影響を及ぼします — 例えば、単なるプロダクトアプリの CreateModel や to="posthog.team" を持つ AddField(または settings.AUTH_USER_MODEL、すなわち posthog_user)です。FK 制約を作成すると、参照される親に対して SHARE ROW EXCLUSIVE ロックが取得され、これは親に対するすべての INSERT/UPDATE/DELETE が保持する ROW EXCLUSIVE と競合します。書き込みトラフィック下ではロックがキューイングし、lock_timeout が bin/migrate のリトライごとにそれをキャンセルします。HotTableAlterPolicy はこのケースを検出するようになりました。2 つの回避策があります:
ForeignKeyにdb_constraint=False— FK 制約を発行せず、親に対して 一切 ロックを取得しません(アプリレベルでの強制のみ)。これが唯一の真にロックフリーなパスです。- 実際の DB 制約、2 段階 — FK を
db_constraint=Falseで宣言し、その後AddForeignKeyNotValidで DB 制約として追加し、後のマイグレーションでValidateForeignKeyを実行します。正直に言うと、ADD CONSTRAINT ... NOT VALIDはメタデータ追加のために親に対して 短時間のSHARE ROW EXCLUSIVEロックを依然として取得します — 行スキャンをスキップするため、ロック期間を短縮しますが、排除はしません。その後VALIDATEは親に対してロックフリーで実行されます。
プロダクトデータベースの境界
products/db_routing.yaml にリストされているアプリは、独自のデータベースでのみマイグレーションされ、他のどこでもそうではありません。マイグレーションは、それ自体が適用されるデータベースに適用されるマイグレーションにのみ依存できます。したがって、他のアプリからこれらのアプリのいずれかへの依存関係を追加しないでください。また、その逆も同様です。外部キーやインデックスはデータベースをまたぐことができないため、このエッジには何の利点もなく、CI のスキーマ復元はその不在に依存しています。つまり、ルーティングされたアプリの django_migrations 行を忘れることで、各ジョブが独自のルーティングの下でそれらを適用できるようにし、忘れられた行に依存するものがあると Django がマイグレーションを拒否します。posthog/test/repo_invariants/test_migration_dependencies_share_a_database.py がこのエッジをブロックします。
言語をまたぐ NOT NULL の危険性
メインの Postgres データベースにある posthog_user、posthog_team、その他の主要なテーブルは、Django および nodejs/(insertRow を介した plugin-server のテスト)、rust/ のサービス、Temporal ワーカーによって書き込まれます。これらの非 Django の書き込み側は、必要な列だけを列挙した生の INSERT を発行するため、Postgres レベルの DEFAULT を持たない新しい NOT NULL 列は、null value in column "<col>" violates not-null constraint でそれらを壊してしまいます。
Django の default= だけでは、Postgres レベルのデフォルトは作成されません。設計上、Django はそれを Model.__init__ で適用される Python のみの属性として扱います。
- 呼び出し可能なデフォルト(
default=list、default=dict、default=uuid.uuid4)は、そもそも SQL に出力されることがありません。 - スカラーのデフォルト(
default=False、default=0、default="")はADD COLUMN ... DEFAULT X NOT NULLとして出力され、その直後に後続のALTER COLUMN ... DROP DEFAULTによって削除されます。./manage.py sqlmigrateで確認してください。
マージする前に、そのテーブルへの外部からの書き込み側を grep で探してください。
rg -n "INSERT INTO <table>|insertRow\(.*'<table>'" nodejs rust products services
一致するものがあれば、モデルのフィールドに default= と db_default= の両方を追加してください。db_default= は実際の Postgres の DEFAULT を設定し、default= は ORM での作成時に使用する Python 側の値を保持します。
class User(models.Model):
hide_mcp_hints = models.BooleanField(default=False, db_default=False, null=False)
makemigrations は単純な AddField(..., db_default=False, default=False, ...) を出力し、sqlmigrate には ADD COLUMN ... DEFAULT false NOT NULL だけが表示されます。後続の DROP DEFAULT はありません。
db_default= は、nodejs / rust のテストスイートにとっても重要です。posthog/management/commands/setup_test_environment.py は disable_migrations() を呼び出し、マイグレーションを完全にスキップしてモデル定義から直接テストスキーマを構築します。単純な default= はこの経路では認識されません。Django が生成する CREATE TABLE に埋め込むのは db_default= です。これがないと、./manage.py migrate が単体では正しく見えても、.github/workflows/ci-nodejs.yml の postgres-parity ジョブと Jest ジョブが生の INSERT で失敗します。
既存の列のデフォルトを変更する場合(ADD COLUMN なし)は、代わりに単純な RunSQL を使用してください。
migrations.RunSQL(
sql="ALTER TABLE <table> ALTER COLUMN <col> SET DEFAULT '[]'::jsonb;",
reverse_sql="ALTER TABLE <table> ALTER COLUMN <col> DROP DEFAULT;",
)
常に ./manage.py sqlmigrate <app> <number> で、余計な DROP DEFAULT が紛れ込んでいないことを確認し、./manage.py makemigrations --dry-run が状態のドリフトを報告しないことを確認してください。