Run Django Migrations Exactly Once in a Multi-Server Deploy
- Author
- Vishal Maurya
- Published on
- Reading time
- 5 min read
Overview
You scale from one server to three, and the startup script still runs python manage.py migrate before launching Gunicorn. On the next deploy, all three start at once. Sometimes it works. Sometimes you see relation "app_thing" already exists, a deadlock, or one server serving traffic while another is still halfway through a migration.
Migrations are a single global operation on a shared database. They should run once per release, not once per instance.
1. Why Running It Per Instance Goes Wrong
Django records applied migrations in the django_migrations table, but two processes can both decide a migration is unapplied and try to apply it. Some databases serialize DDL better than others, and PostgreSQL runs most schema changes transactionally, but concurrent runs can still block each other, fail, or leave you unsure about the state of the release.
A second issue is timing. If new code starts serving traffic before its migration has finished, requests hit a schema the code does not expect. If old code is still running while the schema changes, the old code may break.
2. Run Migrate as a Separate Release Step
Apply migrations in one place, before or as part of the rollout, and only start new app instances if it succeeds. The mechanism depends on your platform:
- A release phase or pre-deploy command, if your platform provides one.
- A one-off job or container that runs
migrateand exits, such as a Kubernetes Job or a Helm pre-upgrade hook. - A single CI/CD step that runs the command against the production database before the rollout.
- A Compose service that runs once and that other services wait for.
services:
migrate:
image: myproject:latest
command: python manage.py migrate --noinput
env_file: .env
restart: 'no'
web:
image: myproject:latest
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000
depends_on:
migrate:
condition: service_completed_successfully
service_completed_successfully is supported in current Compose versions; confirm in your version's documentation. If the job fails, the web service does not start and the old version keeps serving.
A Kubernetes equivalent is a Job created before the Deployment rollout, with the rollout gated on its completion.
3. Add a Safety Lock if You Cannot Guarantee One Runner
If you cannot fully control who runs migrate, take a lock so a second runner waits instead of colliding. With PostgreSQL an advisory lock around the command works.
Advisory locks are session-scoped, so holding one from a shell command across a separate manage.py process is awkward. A more reliable approach is a small wrapper in Python that takes the lock, calls the migrate command and releases it.
# app/management/commands/migrate_once.py
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.db import connection
LOCK_ID = 727274
class Command(BaseCommand):
help = "Run migrate while holding a PostgreSQL advisory lock."
def handle(self, *args, **options):
with connection.cursor() as cursor:
cursor.execute("SELECT pg_advisory_lock(%s)", [LOCK_ID])
try:
call_command("migrate", interactive=False)
finally:
cursor.execute("SELECT pg_advisory_unlock(%s)", [LOCK_ID])
This is PostgreSQL specific, and the lock lives on the same connection as the cursor. It is a fallback; a single designated runner is still the cleaner design.
4. Keep Migrations Backward Compatible
During a rolling deploy, old and new code run against the same schema at the same time. So a migration must work with both versions.
- Adding a nullable column or a column with a default is usually safe. Add first, deploy code that uses it.
- Do not drop or rename a column in the same release that stops using it. Release 1 stops reading and writing it; release 2 drops it.
- Avoid renaming. Add the new column, backfill, migrate code, and then remove the old one.
- Be careful with
NOT NULLadditions and index creation on large tables, which can lock writes. Check your database's behavior for the operation and consider concurrent index creation on PostgreSQL viaAddIndexConcurrentlyfromdjango.contrib.postgres.operations. - Put large data backfills in a separate migration or a job, not inside the schema migration's transaction.
5. Verify Before Deploying
Catch problems in CI:
python manage.py makemigrations --check --dry-run
python manage.py migrate --plan
makemigrations --check fails if model changes lack a migration. migrate --plan prints what would run. Run migrations on a copy of production-like data to see timing and locks.
After the release, python manage.py showmigrations confirms state, and a failed release should be able to roll the code back without rolling the schema back, which is exactly what backward compatible migrations allow.
Conclusion
Run migrate exactly once per release in a dedicated step that gates the rollout, keep migrations compatible with both old and new code, and check plans in CI. A lock is a useful backstop, not a replacement for a single runner.
If your deploys have flaky migrations or you want a safer release pipeline for a multi-server Django app, I can help. Contact me.