Django Migrations Without Downtime
Django’s migration framework is one of the most complete in any web framework: it tracks model state, autodetects changes, orders migrations across apps by dependency graph, and runs each migration in a transaction on PostgreSQL. It was also designed around a deploy model in which the database and the code change together, and that assumption is exactly what a zero-downtime rollout breaks. During a rolling deploy, old and new versions of the application run side by side against the same database. A migration that removes a field breaks the old version the moment it commits; one that adds a non-null field can break the old version’s inserts; and makemigrations will happily generate an AddIndex that locks a hot table for the whole build. This part of ORM & Framework Migration Workflows covers how to use Django’s machinery — sqlmigrate, db_default, SeparateDatabaseAndState, atomic = False, RunPython — to keep every step backward compatible and every lock short. It serves Django developers who own their migrations and the platform engineers who run them.
The core discipline is borrowed from Expand and Contract Methodology: every migration must be compatible with the code currently running and the code about to run. Django gives you precise control over what SQL each migration emits; the work is in reading that SQL and splitting changes across releases.
Concept & Mechanism
A Django migration is a Python file containing a list of operations — AddField, RemoveField, AlterField, AddIndex, RunSQL, RunPython — plus dependencies on other migrations. When you run migrate, Django builds the project state by replaying operations in memory, then asks the schema editor for each database backend to translate each operation into SQL. The SQL is what reaches the database, and python manage.py sqlmigrate <app> <migration> prints it without running anything. Reading that output is the single most important habit for zero-downtime Django, because several operations emit more or different SQL than their names suggest.
On PostgreSQL, Django wraps each migration in a transaction by default (atomic = True on the Migration class), so a migration either applies completely or not at all, and all locks it takes are held until the end of the file. On MySQL, DDL cannot be transactional, so each statement commits independently and a failed migration can be left half-applied. Two operations deserve particular attention:
AddFieldwith a default. Django adds the column with the default so existing rows are populated, then drops the database default in the same migration, because Django manages defaults in Python rather than in the database. On PostgreSQL 11+ adding a column with a constant default is metadata-only, so the lock is brief — but once the database default is dropped, old application code that inserts rows without mentioning the new column will violateNOT NULL. Django 5.0’sdb_defaultkeeps the default in the database and removes that hazard.AddIndex/index=True/db_index=True. These emit a plainCREATE INDEX, which blocks writes on PostgreSQL for the duration of the build.AddIndexConcurrentlyfromdjango.contrib.postgres.operationsemitsCREATE INDEX CONCURRENTLYand requires a non-atomic migration.
sqlmigrate on every migration — the operation name tells you the intent, only the SQL tells you the lock.Prerequisites & Decision Criteria
Before relying on the procedure below, confirm the deployment model and tooling are in place.
| Requirement | Why it matters | How to check |
|---|---|---|
| Migrations run as a separate deploy step | concurrent migrate from many pods races and extends locks |
deploy config runs migrate once, before rollout |
Django 5.0+ for db_default |
removes the NOT NULL hazard for new fields with defaults | python -m django --version |
| PostgreSQL 11+ | constant defaults added without table rewrite | SHOW server_version |
django.contrib.postgres in INSTALLED_APPS if using its operations |
AddIndexConcurrently, ValidateConstraint |
settings review |
| A lock timeout for the migration connection | a waiting DDL freezes the table otherwise | OPTIONS or a RunSQL("SET lock_timeout …") |
Use this checklist when reviewing any Django migration for production:
Step-by-Step Procedure
1. Generate, then read the SQL. Run makemigrations, then sqlmigrate for each new migration. Treat the SQL as the thing under review; the Python file is just how you edit it. Verify every statement against the lock table in DDL Lock Management & Timeouts before proceeding.
# Shell · developer workstation · reads migration files and the DB connection settings, runs nothing
python manage.py makemigrations orders
python manage.py sqlmigrate orders 0042
python manage.py migrate --plan # shows which migrations would run, in order
2. Bound lock waits for the migration connection. Put a lock_timeout in the connection options used by migrate, ideally via a dedicated settings module or database alias for migrations so application connections are unaffected.
# Python · settings/migrate.py · used only by the migrate job (DJANGO_SETTINGS_MODULE=settings.migrate)
# WARNING: do not apply these options to application connections.
from .base import * # noqa
DATABASES["default"]["OPTIONS"] = {
**DATABASES["default"].get("OPTIONS", {}),
"options": "-c lock_timeout=2000 -c statement_timeout=900000",
}
3. Split additive and destructive changes across releases. A model change that renames or removes a field becomes two or more releases: first add the new structure and deploy code that writes both; later, once no running code reads the old field, remove it. Use SeparateDatabaseAndState to remove a field from Django’s model state in one release while dropping the column in a later one, as described in removing a Django field with SeparateDatabaseAndState.
4. Use online forms for indexes and constraints. Replace AddIndex with AddIndexConcurrently in a non-atomic migration, and add constraints in two steps — AddConstraintNotValid then ValidateConstraint from django.contrib.postgres.operations (Django 4.0+) — so validation runs without blocking writes.
# Python · orders/migrations/0043_order_region_idx.py · PostgreSQL · runs outside a transaction
# WARNING: atomic = False means a failure is not rolled back; keep only this operation here.
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False
dependencies = [("orders", "0042_order_region")]
operations = [
AddIndexConcurrently("order", models.Index(fields=["region"], name="order_region_idx")),
]
# ROLLBACK PATH: migrating back to 0042 runs RemoveIndexConcurrently.
5. Move data changes into dedicated, batched migrations. RunPython operations that touch many rows should process them in batches and, on PostgreSQL, run in a non-atomic migration so each batch commits. Keep them separate from DDL, as covered in writing reversible RunPython data migrations.
6. Run migrate once, before the rollout. The deploy pipeline runs python manage.py migrate --noinput as a single job with the migration settings, waits for success, and only then rolls out the new application version.
Verification & Observability
Before deploying, confirm what Django believes is applied and what will run:
# Shell · CI or deploy job · read-only inspection of the migration table and plan
python manage.py showmigrations orders
python manage.py migrate --plan
python manage.py makemigrations --check --dry-run # fails if models and migrations disagree
Two review habits make these checks far more effective. First, review migrations as SQL: post the sqlmigrate output for every new migration into the pull request, so reviewers who know PostgreSQL’s lock table but not Django’s schema editor can judge the change. Second, keep a short list of the tables where any blocking lock is unacceptable — the orders, payments and sessions tables of most applications — and require that migrations touching them carry an explicit note of the lock taken and its expected duration. Neither habit needs tooling, and together they catch the blocking AddIndex, the scanning AlterField and the premature RemoveField before they reach a database.
Multi-database projects need one more check. Django records migrations per database alias, and database routers decide which migrations run where through allow_migrate. Run showmigrations --database <alias> for every alias in CI, because a migration silently skipped on a secondary database is a drift problem that surfaces only when code first queries the missing column.
makemigrations --check is a valuable CI gate: it exits non-zero when model changes have no migration, which catches the common mistake of shipping a model edit without its migration. During the run, watch lock waits on the primary with pg_stat_activity and the lock-tree query from finding the blocking session with pg_blocking_pids. After the run, confirm django_migrations contains the new rows and that no invalid indexes remain.
-- PostgreSQL · read-only · after the migrate job
SELECT app, name, applied FROM django_migrations
WHERE app = 'orders' ORDER BY applied DESC LIMIT 5;
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
makemigrations --check and a scan of sqlmigrate output — catch most unsafe Django migrations before they reach a database.Rollback Path
Django can reverse most operations: python manage.py migrate orders 0041 unapplies every migration after 0041 by running each operation’s reverse. That mechanism is reliable for schema but dangerous for data: reversing an AddField drops the column and everything written to it, and a RunPython without a reverse function makes the migration irreversible.
# Shell · deploy job · migration settings · reverses migrations after 0041
# WARNING: reversing AddField drops the column and its data; confirm nothing valuable was written.
python manage.py migrate orders 0041 --plan # inspect what would be reversed
python manage.py migrate orders 0041
Rollback via reverse migration is safe when the migrations being reversed are additive and hold no data yet, or when their reverse operations are also additive. For anything else — a dropped column, a transformed dataset — roll forward with a corrective migration. Application rollbacks are simpler: because every migration is backward compatible by construction, rolling the code back to the previous release does not require reversing the schema at all. That property is the main payoff of the discipline, and it is what Rollback Automation builds on.
Common Errors & Fixes
django.db.utils.IntegrityError: null value in column "region" violates not-null constraint from old pods after a deploy. Root cause: AddField with default= dropped the database default, and old code inserts rows without the new column. Fix: use db_default on Django 5.0+, or add the field nullable, deploy, backfill, then tighten; see adding a non-null field in Django without locking.
django.db.utils.ProgrammingError: column orders_order.legacy_code does not exist from old pods. Root cause: RemoveField ran while old code still selects the column (Django selects all model fields by default). Fix: remove the field from the model and state first, deploy, then drop the column in a later release with SeparateDatabaseAndState.
CREATE INDEX CONCURRENTLY cannot run inside a transaction block. Root cause: AddIndexConcurrently in a migration with the default atomic = True. Fix: set atomic = False on that migration and keep the operation alone in it, as described in creating Postgres indexes concurrently in Django.
InconsistentMigrationHistory: Migration x is applied before its dependency y. Root cause: migrations were faked, squashed or reordered inconsistently across environments. Fix: inspect django_migrations, and correct history with targeted --fake operations, per fixing Django InconsistentMigrationHistory errors.
Child Page Index
The guides under this topic each resolve one recurring Django problem. Adding a non-null field in Django without locking covers db_default, the nullable-then-tighten pattern and the old-pod insert failure. Removing a Django field with SeparateDatabaseAndState shows how to decouple model state from DDL so removals span two releases. Creating Postgres indexes concurrently in Django handles the non-atomic migration mechanics and invalid-index recovery. Writing reversible RunPython data migrations covers batching, historical models and reverse functions. And fixing Django InconsistentMigrationHistory errors is the runbook for a migration graph that no longer matches the database.
Framework-neutral background lives in Zero-Downtime Schema Evolution Patterns, and teams comparing Django with SQLAlchemy should read the parallel Alembic & SQLAlchemy Migrations topic.
Frequently Asked Questions
Is Django’s AddField with a default safe on large PostgreSQL tables?
The DDL itself is: on PostgreSQL 11 and later, adding a column with a constant default does not rewrite the table. The hazard is that Django then drops the database default, so old application code inserting rows fails with a NOT NULL violation. Use db_default on Django 5.0+, or add the field as nullable first.
Should migrate run when each pod starts?
No. Run it once as a dedicated deploy step before the new version rolls out. Concurrent migrate runs from many pods contend for locks, and a failure would crash-loop every pod.
How do I see the SQL a Django migration will run?
Use python manage.py sqlmigrate <app> <migration_name>. It prints the SQL for your configured database backend without executing it, including the BEGIN/COMMIT wrapper for atomic migrations.
Can I mix RunPython and schema operations in one migration?
You can, but on PostgreSQL it keeps any locks from the schema operations held for the whole data migration, and on MySQL it mixes committed DDL with transactional data changes. Put data migrations in their own migration files.