We’ve open-sourced the migration tooling we run at Breww. Introducing django-deferred-migrations ![]()
The problem. In a rolling deploy, migrations run first while the old release still serves every request, and then old and new processes overlap until the rollout finishes. Anything that breaks the old code’s view of the schema errors for the length of that overlap - RemoveField, DeleteModel, a new NOT NULL column with no database default, tightening a column to NOT NULL, a rename, or an index build that holds a write lock.
The standard answer is to split the change across two deploys and remember to ship the second one. In practice the second one gets forgotten, and it can’t go out until every environment has taken the first.
What this does instead. It splits each migration run into two phases around the rollout, and defers only the destructive SQL:
| Step | Database | Old code | New code |
|---|---|---|---|
migrate_pre_deploy |
legacy_ref relaxed so inserts may omit it; DROP COLUMN queued |
Works | Not running yet |
| Rollout | Works: the column is still there | Works: its model has no such field | |
migrate_post_deploy |
Waits for terminating pods, then DROP COLUMN legacy_ref |
Gone | Works |
DeferredRemoveField updates Django’s migration state immediately, so nothing in the migration graph ever depends on unrun work. That’s the main difference from the whole-migration approach taken by django-syzygy and django-safemigrate, and it’s what stops a deploy blocking when two PRs touching the same app land between releases. It also means it works perfectly with django-linear-migrations (which we love, if you don’t use django-linear-migrations, take a look).
What’s in the box:
-
DeferredRemoveField,DeferredDeleteModel,DeferredRenameModel,SetNotNull, trigger-synced column reshaping with batched backfills, and concurrent field/index/constraint builds. -
A
makemigrationsthat writes the deploy-safe operation for you, andfix_deploy_safetyto rewrite existing ones. -
check_deploy_safetyfor CI, with per-app baselines so an existing project can adopt it without touching history. -
A short
lock_timeoutwith jittered retry on every DDL statement, so a migration never queues behind a long transaction. -
Differential tests: the suite generates the same change with Django’s
makemigrationsand with ours, applies both to separate databases, and compares columns, types, nullability, defaults, collations, comments, indexes, constraints, sequences, triggers, functions, views and row contents - at every step, including after a rollback and after full reversal.
Requirements: Python 3.12+, Django 5.2 / 6.0 / 6.1, PostgreSQL 14+, psycopg 3. No other backend - migrate_pre_deploy refuses to run on one.
It needs a post-rollout hook. If your platform only gives you a pre-deploy release phase, you can’t get the full benefit. Helm pre-upgrade/post-upgrade Jobs are what we use; there’s a generic CI recipe in the readme.
The readme has a comparison table covering django-syzygy, django-safemigrate, pgroll and django-pg-zero-downtime-migrations, including what each of them does better and which ideas this borrows from them. Thank you to the authors of those for their ideas and insights - without those, we probably would never have built django-deferred-migrations (or at least it wouldn’t have been as good as we think it is).
Feedback is very welcome, especially from anyone doing this at larger scale than us. We hope this can use useful to others.