Django Migration — Zero Downtime
Project Configuration
Read the active host's project instruction file (for example,
or
) for the values below. If they are not set, use the defaults shown.
| Key | Default | Notes |
|---|
| Django version | unknown | Affects availability — only Django 5.0+ supports it |
| Deploy strategy | rolling deploy | Rolling deploy is the most restrictive; blue/green or maintenance-window deploys allow more operations |
| Runtime guard | none | If a guard is configured (e.g. django-pg-zero-downtime-migrations
), operations it blocks are Errors; uncovered operations are Warnings |
| Migration command | python manage.py sqlmigrate
| e.g. or docker compose run web python manage.py sqlmigrate
|
| Docs / wiki URL | none | If set, append links when flagging issues in review output |
To configure this skill for your project, add a section like this to the active
host's project instruction file:
## django-safe-migration
- Django version: 5.1
- Deploy strategy: rolling deploy
- Runtime guard: none
- Migration command: docker compose run web python manage.py sqlmigrate
- Docs URL: https://github.com/your-org/repo/wiki/migrations
When to Use
- "Review this migration"
- "Is this migration safe?"
- "Write a migration for..."
- "Rewrite this migration to be safe"
- "How do I add a NOT NULL column / drop a column / add an index / rename a column / add a FK..."
Why Zero-Downtime Migrations Matter
In a rolling deploy, the new database schema is applied first, then application instances are restarted one by one. At any moment during the deploy, old code and new code run simultaneously against the same database.
Every migration must be safe to run while the previous version of the app is still serving traffic. A migration that takes an
lock on a large table blocks all reads and writes — downtime even for a few seconds on a busy table.
The problem is not just the lock itself but the
wait queue: a fast
that takes 50ms will queue behind any long-running transaction, and all subsequent queries queue behind the migration. On a busy table this cascades into connection pool exhaustion.
How PostgreSQL Locking Works
| Lock | Acquired by | Blocks |
|---|
| Most , , (FK) | All reads and writes |
| (on child table + referenced table simultaneously) | Writes only (on both tables) |
| | Writes only |
| CREATE INDEX CONCURRENTLY
, | Nothing meaningful — safe under traffic |
For the full conflict matrix (table-level locks × business logic operations × row-level locks) and the FIFO wait-queue explanation, load
references/postgres-locks.md
. Load it when:
- explaining why a specific operation is unsafe
- a developer asks what a lock type blocks or conflicts with
- reasoning about whether two concurrent operations interact
lock_timeout
Any operation that requires
should be preceded by
. This causes the migration to fail fast (with a clear error) instead of waiting indefinitely for the lock — preventing connection pool exhaustion from queue cascading.
For a normal transactional migration:
python
migrations.RunSQL("SET LOCAL lock_timeout = '2s'"),
migrations.AlterField(...), # or any ACCESS EXCLUSIVE operation
scopes the timeout to the current transaction, so it does not affect other sessions or persist after the migration completes.
Default:
— adjust up if the table is known to have long-running transactions that legitimately need more time, or down for stricter environments.
For
migrations, combine
and the DDL in the same
operation; see Structural Rules below.
Key Patterns
NOT VALID + VALIDATE (for FK and CHECK constraints)
A two-step PostgreSQL technique to add a constraint without a long lock:
ADD CONSTRAINT … NOT VALID
— creates the constraint and enforces it on new writes immediately, but skips scanning existing rows. Takes a brief lock with no table scan — on both tables for FK constraints, for CHECK constraints.
- — scans existing rows to confirm they satisfy the constraint. Takes (plus on the referenced table for FK constraints), which does not block reads or writes.
The dangerous part is the full-table scan, not the constraint creation itself. Splitting it keeps the write-blocking lock window to milliseconds, with the long scan moved to a non-blocking step.
Runtime Guards
Some projects configure a custom database backend or linter that raises errors for unsafe operations at migration time (e.g.
,
django-pg-zero-downtime-migrations
,
).
When reviewing a migration:
- Operations the project's runtime guard blocks → classify as Error (migration will not run)
- Operations the guard does not cover → classify as Warning (migration runs but may cause downtime)
If no runtime guard is configured, treat all unsafe operations as Errors that require a safe rewrite before deploying to production.
Check the Project Configuration block above (or
) for what this project's guard covers.
Mode 1: Review
Goal
Identify every operation that is unsafe or risky for a rolling deploy on PostgreSQL.
Steps
- Read the migration file in full.
- Run
<migration_command> <app_label> <migration_name>
to get the actual SQL Django will execute. Always do this — the generated SQL is the ground truth. Use the value from the active host's project instruction file; default is python manage.py sqlmigrate
. If the key is not set and a or exists in the project root, ask the user: "How do you run sqlmigrate in this project?" and suggest they save the answer to that instruction file. The ORM operation class alone is not sufficient: for example, on a FK field that adds also drops and re-adds the FK constraint, emitting (taking on both the child and referenced table) followed by ADD CONSTRAINT FOREIGN KEY
without (taking with a full scan on both tables) — neither is visible from the migration file alone.
- Load
references/operation-guide.md
. Load references/postgres-locks.md
when explaining why a flagged operation is unsafe.
- For each SQL statement produced, check it against the detection checklist below.
- If any operation requires (flagged as error or warning), ask the user before outputting the report:
"This migration contains an
operation. A
should be added to fail fast instead of queuing and cascading. The default is
— confirm or provide a custom value."
If the migration already contains
, note the existing value and ask the user to confirm it is appropriate.
Use the confirmed value in the fix instructions.
- Output a structured report:
Migration Review: <filename>
Errors — will cause downtime or be blocked by runtime guard
- [ERROR] on : <what it does and why it's unsafe>
Fix: <one-sentence description of the safe alternative>
What this rewrite changes: clarify whether is still required in the safe version, and if so, explain what actually improves — lock duration (full table scan → milliseconds), failure mode (silent queue cascade → fast timeout error), or both. Never let a reader assume the rewrite eliminates the lock entirely.
<wiki/docs link if configured>
Warnings — may cause extended locks or deployment issues
- [WARNING] : <issue>
Fix: <safe alternative>
Structural Issues
- [ERROR|WARNING] <issue> (e.g., missing atomic=False, RunPython without reverse)
Safe
- [OK] <operations that pass all checks>
- If there are errors or warnings, offer to rewrite (Mode 3).
- If everything passes, confirm the migration is safe to deploy.
Mode 2: Write
Goal
Generate correct, zero-downtime migration(s) for a described change.
Steps
- Load
references/operation-guide.md
and .
- Clarify the operation if ambiguous:
- What model and field?
- New column or changing an existing one?
- For FK: which table is referenced? New column or existing?
- For type changes: from what type to what type?
- Django version (affects availability)?
- If the operation will require , ask the user:
"This migration will use
. A
will be included to fail fast if the lock cannot be acquired. The default is
— confirm or provide a custom value."
- Determine how many migration files are needed — many patterns require two files in separate PRs.
- Generate the migration file(s) using patterns from . Add
SET LOCAL lock_timeout = '<confirmed_value>'
before each statement. In normal migrations, this can be a preceding ; in migrations, combine the timeout and DDL in the same operation so is still active when the DDL runs.
- When two files are needed, always output deployment instructions:
## Deployment Order
- Migration 1: ships in the same PR as the model/code change
- Migration 2: ships in a follow-up PR after the deploy is confirmed stable
Mode 3: Rewrite
Goal
Transform an existing unsafe migration into one or more safe migrations.
Steps
- Read the migration file.
- Identify all unsafe operations using the detection checklist.
- Load
references/operation-guide.md
and .
- If any operation requires , ask the user:
"This migration contains an
operation. A
will be added to the rewrite to fail fast if the lock cannot be acquired. The default is
— confirm or provide a custom value."
- For each unsafe operation, apply the correct safe pattern.
- Add
SET LOCAL lock_timeout = '<confirmed_value>'
before each statement in the rewritten migration. In normal migrations, this can be a preceding ; in migrations, combine the timeout and DDL in the same operation so is still active when the DDL runs.
- If the rewrite requires splitting into two files, generate both and include deployment instructions.
- Preserve: migration number prefix, , any safe operations unchanged.
- After rewriting, verify structural rules (see below).
- After the migration code, always output a "What this rewrite changes" block that explains:
- Whether is still required (often yes — be explicit about this).
- What actually improves: lock duration (full table scan → milliseconds for metadata-only ops), failure mode (silent queue → fast timeout error with ), or both.
- Any data integrity window introduced (e.g., means existing rows are unvalidated until Migration 2 runs) and whether it matters given the table's prior state.
Detection Checklist
Errors — unsafe regardless of runtime guard
| Django operation | What to detect | Why it's unsafe |
|---|
| and no (Django 5.0+) | Old code inserts omit the column — no DB-level default to fall back on |
| not inside | Old code queries the dropped column by name — crashes immediately |
| not inside | Old code queries the dropped table — crashes immediately |
| not using | takes lock — blocks writes during build |
| not using | takes — blocks reads and writes |
| (FK) | not using pattern | Full table scan under on both the child and referenced table — blocks writes (not reads) for the scan duration |
| (CHECK) | not using pattern | Full table scan under |
| removing on existing column without CHECK path | Full table scan under |
| (FK field) | output contains followed by ADD CONSTRAINT FOREIGN KEY
| Django drops and re-adds the FK: takes on both the child and referenced table; re-adding without takes with a full scan on both tables. Use with to add and include . |
| or | not on class | CONCURRENTLY cannot run inside a transaction — will error |
| function imports model directly () | Uses current model class, not historical snapshot — breaks old migrations |
Additional errors if covered by the project's runtime guard
If the project has a runtime guard configured (see Project Configuration), also flag these as Errors (they will raise at migration time):
| Django operation | What to detect |
|---|
| any rename |
| any rename |
| column type changes |
| |
If no runtime guard is configured, flag these as Errors too — they require a safe rewrite.
Warnings
| Django operation | What to detect | Issue |
|---|
| (UNIQUE) | not using index-first pattern | Inline index under lock — blocks writes during build |
| adding to an existing column () | Takes — fast catalog update, but queues behind any long-running transaction; all subsequent queries queue behind the migration and can exhaust the connection pool |
| missing | Not reversible |
| missing | Not reversible |
Structural Rules (always check)
- on the class whenever any operation uses or . For specifically: (1) is self-conflicting — see the conflict matrix in
references/postgres-locks.md
; (2) keeps the wrapping transaction open for the full scan duration, holding all prior statement locks and increasing deadlock risk; (3) lets VALIDATE run in its own transaction and release locks immediately on completion.
- split for / : always two separate files.
- : always provide
reverse_code=migrations.RunPython.noop
at minimum.
- : always provide . If truly irreversible, use and note why.
- Model access in : always
apps.get_model("app", "ModelName")
, never direct imports.
- : required before every operation. Default value is ; always confirm with the user before writing or rewriting. Use (not ) so the timeout is scoped to the current transaction.
- exception: only resets at transaction end. In an migration, each operation runs outside a wrapping transaction, so a standalone
migrations.RunSQL("SET LOCAL lock_timeout = '2s'")
resets before the next operation executes and has no effect. When the migration has , always combine the timeout and the DDL in a single call:
python
migrations.RunSQL("""
SET LOCAL lock_timeout = '2s';
ALTER TABLE app_model ADD COLUMN ...;
""")