skip to content

How can a Django release pipeline use migrate --plan and migrate --check to preview and gate pending migrations before new code takes traffic?

level: seniorimportance: nice to knowfreq 26%

answer

  1. preview without applying
  2. exit status as a gate
  3. the backwards plan
  4. IRREVERSIBLE in the output

basics

~20 s

migrate --plan prints the migrations and operations that would run without applying them; migrate --check applies nothing and exits non-zero while unapplied migrations exist. Review with the first, and gate traffic or container start on the second.

solid answer

~40 s

`migrate --plan` lists each pending migration and a one-line description of its operations without touching the schema, so a release can be reviewed before it runs; run against an earlier target, such as `migrate shop 0041 --plan`, it shows the rollback plan and marks a `RunPython` without `reverse_code` or a `RunSQL` without `reverse_sql` as `IRREVERSIBLE`. `migrate --check` (Django 3.1+) applies nothing and exits non-zero when unapplied migrations exist, so it works as a gate after the release step or in a web container's start script, making the container exit instead of serving code against an old schema. On the code side, `makemigrations --check` in CI exits non-zero when models changed without a migration and, since Django 4.2, writes no files.

code

bash · 10 lines
bash
# CI: fail if a model change has no migration (writes nothing)
python manage.py makemigrations --check

# release: show what will run, apply it once, then assert nothing is pending
python manage.py migrate --plan
python manage.py migrate --noinput
python manage.py migrate --check   # non-zero exit blocks the traffic switch

# rollback rehearsal: IRREVERSIBLE marks steps that cannot be undone
python manage.py migrate shop 0041 --plan

go deeper

for a junior

Know that migrate can preview its work with --plan and that showmigrations lists which migrations are applied.

for a middle

Explain what --plan and --check each do, which one exits non-zero, and why makemigrations --check belongs in CI.

for a senior

Place the gates in a pipeline, rehearse rollbacks with a backwards --plan, and explain what a passing check does not prove.

for a principal

Decide which schema gates a release must pass and who owns an IRREVERSIBLE step before it reaches production.

## Two questions a release has to answer Before containers running new code take traffic, a Django release has to answer two questions about the schema: **what will `migrate` do**, and **has it been done**. Django answers both with options on `migrate` itself, and a third option on `makemigrations` covers the code side. | Command | Applies anything? | Exit status | Where it fits | |---|---|---|---| | `makemigrations --check` | no, it implies `--dry-run` | non-zero when models changed without a migration | CI, before an image is built | | `migrate --plan` | no | zero | release review; rehearsing a rollback | | `migrate --check` | no | non-zero when unapplied migrations exist | a gate before traffic, or at container start | | `showmigrations` | no | not tied to pending migrations | a human-readable `[X]` / `[ ]` listing | | `migrate` | yes | non-zero on failure | the release step itself | ## makemigrations --check: the code side In CI, `python manage.py makemigrations --check` fails the pipeline when a model change has no migration file. Since Django 4.2 it no longer writes the missing files, because it implies `--dry-run`, so the check has no side effects. Without it, a release can run `migrate`, apply nothing new, and ship models that do not match the schema. ## migrate --plan: what the release will do `migrate --plan` prints each migration that would run and a one-line description of every operation in it, without applying anything. It earns its place in two situations: - **Release review.** A reviewer sees that the release will, for example, remove a field or run a data migration before it happens. - **Rollback rehearsal.** `migrate <app_label> <earlier_migration> --plan` shows the backwards plan. A `RunPython` without `reverse_code` or a `RunSQL` without `reverse_sql` is printed as **`IRREVERSIBLE`**, which tells you before an incident that migrating back will stop at that step. For a `RunPython`, the description includes the start of the function's docstring, so a documented data migration reads clearly in the plan. ## migrate --check: has it been done? `migrate --check`, added in Django 3.1, exits with a non-zero status when unapplied migrations exist and applies nothing. Typical placements: 1. **After the release step, before switching traffic**: a cheap assertion that the one-off `migrate` really completed against the database the new containers will use. 2. **In the web container's start script, before the application server starts**: the container exits instead of serving code against an old schema, so the platform never marks it healthy. 3. **Together with `--plan`**: the command prints the pending plan and then exits non-zero, which makes the log of a failed gate self-explanatory. ## Limits of the gate - `migrate` runs the system checks for the target database before it acts, unless `--skip-checks` is given, so the gate needs valid settings and a reachable database, which a gate should require anyway. - It is a **point-in-time read**: it proves the schema was current when it ran, not that it stayed that way. - It checks **one database alias**; a project with several databases runs it per alias with `--database`. - It does not make a migration safe for the old code still running during a rolling release; that is the job of how the migration is written, not of the gate. - It does not replace running `migrate` once: it only reads, so it pairs with a single release step rather than competing with it. ## Wiring it into a pipeline A typical sequence, each step a management command with a meaningful exit status: 1. **CI**: `makemigrations --check`, so a model change without a migration never reaches an image. 2. **Release review**: `migrate --plan` printed into the release log, from the new image against the target database, so the log records exactly which operations were about to run. 3. **Release step**: the single `migrate --noinput`. 4. **Gate**: `migrate --check` before traffic moves to the new containers. Because every gate is an exit status, the pipeline needs no parsing of output to decide whether to continue. `showmigrations` (or `showmigrations --plan`, which lists migrations in the order they would be applied) remains the tool for a human reading the state during an incident, but its exit status does not report pending migrations, so it is not a gate. A useful habit for teams that write data migrations is to run the rollback rehearsal in review as well: `migrate <app_label> <previous_migration> --plan` against a copy of the database shows whether an emergency rollback is possible at all before the release ships, instead of discovering an `IRREVERSIBLE` step during an incident.

  • What does IRREVERSIBLE mean in the output of Django's migrate --plan?
    It appears on a backwards plan, when you target an earlier migration, for a `RunPython` whose `reverse_code` is missing or a `RunSQL` whose `reverse_sql` is missing. Migrating back would stop at that operation with an irreversibility error, so either add a reverse (a no-op reverse is often acceptable) or plan a roll-forward fix instead.
  • Why put Django's migrate --check in the web container's start script rather than running migrate there?
    `--check` only reads, so every container can run it safely: a container started against an old schema exits instead of serving. Applying migrations stays in one release step, where a failure is one visible failed job rather than many containers crashing on boot.

saying these in an interview costs you the question

  • migrate --check applies pending migrations and then reports what it did.
  • migrate --plan output shows IRREVERSIBLE on forward migrations that drop columns.
  • makemigrations --check still writes the missing migration file on current Django.
  • A green migrate --check proves the migration is safe for the old code still running.