← Back to list

How to Run Django Migrations Safely (Zero Downtime, No Surprises)

Django migrate best practices

Anas Issath · 2025-09-02 20:17 · 176 claps · 9.6 min read paywalled
#django #django-migrations #backend-development #django-database #django-commands
Open on Medium ↗
Wiki topics: 🌐 · Web Development 🥊 · Combat Sports

How to Run Django Migrations Safely (Zero Downtime, No Surprises)

Django migrate best practices

Photo by KOBU Agency on Unsplash

Photo by KOBU Agency on Unsplash

If you’re not a Medium member, you can read this article for free via this link: Friend Link

1. Why This Matters

If you’ve worked on a Django project long enough, you’ve probably had that heart-stopping moment: you hit deploy and run python manage.py migrate, and suddenly the site hangs. Database locked. Users are pissed. Boss glaring.

Migrations are supposed to be simple— “just sync the models with the database.” But anyone who’s shipped real software knows that’s a lie. The migrate command can either be your best friend or the reason your pager goes off at 3.07 a.m.

This is why you can’t treat migrations like a black box. You need to understand what’s really happening when you run them, what all those cryptic flags actually mean, and how to make sure you don’t brick production in the process.

That’s what this article is for: the real-world Django engineer’s playbook to Django migrations—no fluff, no theory, just the stuff that keeps your deploys alive.

2. What Actually Happens When You Run migrate

When you type:

python manage.py migrate

Django isn’t doing anything magical. It’s just moving your database schema closer to what your models say it should look like. But here’s the play-by-play:

  1. **manage.py is just a wrapper.** It sets up Django with the right settings, then hands off to the actual command system.
  2. Django loads all your migration files. Every app has a migrations/ folder full of numbered Python scripts that describe schema changes.
  3. It checks the database. Django keeps a django_migrations table that tracks which migrations have already been applied. Think of it as Django’s memory of what the DB “should” look like.
  4. It applies the missing ones in order. Each migration translates into SQL: creating tables, altering columns, adding indexes, or whatever you’ve defined.
  5. It records success (or blows up). If the migration runs cleanly, Django logs it in django_migrations. If it fails midway, you’re left in an awkward “half-migrated” state—and that’s when things get ugly.

That’s it. No black magic. Just Python files generating SQL and Django keeping score.

The problem? Not all migrations are created equal. Some are harmless (add a nullable field). Some can lock your entire table for minutes (add a non-nullable column with a default). And unless you understand how to control the process, you’re one migration away from taking prod down.

3. The Core Command: python manage.py migrate --noinput

This is the one you’ll see in almost every deployment script, Dockerfile, or CI/CD pipeline:

python manage.py migrate --noinput

So what’s happening here? Let’s split it:

  • **python manage.py** → the Django wrapper we talked about.
  • **migrate** → run unapplied migrations, make DB schema match your models.
  • **--noinput* → the sneaky part. This tells Django: “Don’t ever prompt me for input. Just do it.”*

Sounds harmless, but here’s the kicker: Django sometimes asks questions during migrate. For example, it might say:

“The auth.User model no longer exists. Do you want to remove its stale content types?”

Without --noinput, it waits for you to type yes or no. In a local terminal, no big deal. In a CI/CD pipeline? That’s a deadlock—your deploy freezes until someone types into a non-existent console.

With --noinput, Django doesn’t ask. It just assumes the default answer and plows forward. That makes it perfect for automation… and also slightly dangerous. If the default action is destructive (like dropping stale stuff), you won’t get a chance to argue.

Why it matters in real life:

  • On dev: you probably don’t need --noinput.
  • On staging/prod/CI: you almost always need it, because no one’s standing by to press keys.
  • But — and this is the big but — you should never rely on it blindly. Always know what’s about to run (we’ll talk about --plan soon).

So think of --noinput as the “don’t bother me, just go” mode. Great for robots. Risky for humans.

4. All the Flags You Need to Know

Most devs only ever touch --noinput, but Django’s migrate command actually has a bunch of switches that can save your skin—or dig your grave—depending on how you use them. Here are the ones that matter in the real world:

--plan

Shows you what migrations would run without actually running them. Think of it as a dry-run:

python manage.py migrate --plan

If you ever run migrate in production without previewing the plan first, you’re asking for pain.

--database <alias>

If you’ve got multiple databases defined in settings.DATABASES, this tells Django which one to apply migrations to.

python manage.py migrate --database=default

Pro tip: never point this at a read replica unless you enjoy cryptic DB errors.

--check

Exits with an error code if there are unapplied migrations. This is gold for CI pipelines:

python manage.py migrate --check

If someone forgot to commit a migration, your build will fail fast instead of silently drifting out of sync.

--fake

Marks a migration as applied without actually running it. Dangerous, but sometimes necessary when you’ve manually made schema changes. Use it only if you 100% know your DB matches what the migration expects. Otherwise you’re basically lying to Django.

--fake-initial

Useful when you’re adding migrations to a project that already has tables in the database. Django will check if those tables already exist and, if so, mark the initial migration as applied. Handy for legacy projects, but don’t expect Django to validate every column/constraint. It only checks for the table.

--prune

Cleans up migration records that no longer exist on disk. Typical use case: after you squash and delete old migration files, run:

python manage.py migrate --prune

Keeps your django_migrations table from becoming a graveyard of ghosts.

--run-syncdb

Creates tables for apps that don’t have migrations. In practice? Almost no modern app should need this. It’s more of a legacy escape hatch.

app_label [migration_name]

The old-school way to target migrations.

  • Apply only one app:
python manage.py migrate auth
  • Roll back to a specific point:
python manage.py migrate blog 0012
  • Nuke an app’s schema completely:
python manage.py migrate blog zero

Careful with that last one. “Zero” is Django-speak for “drop it all.”

These flags are the difference between treating migrations like a blunt hammer and using them like a scalpel. In dev, you can get away with the hammer. In prod, you need the scalpel.

5. Zero-Downtime Migration Strategies

Here’s the dirty secret: Django doesn’t care about downtime. You run migrate, it fires SQL, and if that SQL locks the table for 30 seconds, tough luck—your users just got 30 seconds of 500 errors.

If you’re running a toy app, who cares? If you’re running something with real traffic, those 30 seconds can feel like forever. Let’s talk about how to not tank production.

The Golden Rule: Break It Into Steps

The fastest way to kill a site is to add a non-nullable field with a default in one migration. Django tries to rewrite the whole table. Boom: locked.

Better way:

  1. Add the new column as nullable (cheap).
  2. Backfill the data in small batches with a custom script or RunPython migration.
  3. Flip the column to non-nullable and add the default.

It’s more work, but it avoids the giant lock.

PostgreSQL Power Moves

If you’re on Postgres (and you should be for anything serious), you get a couple of weapons:

  • Concurrent indexes Normal CREATE INDEX locks the table. Use CONCURRENTLY to build it without downtime:
from django.contrib.postgres.operations import CreateIndexConcurrently
  • Just remember to mark the migration as atomic = False, because concurrent indexes can’t run inside a transaction.
  • Constraints “not valid” first Adding a check/foreign key constraint? Use NOT VALID so it doesn’t scan the whole table right away. Then add a follow-up migration to VALIDATE CONSTRAINT later. Users keep working while Postgres does the heavy lifting.

Non-Atomic Migrations

By default, Django wraps each migration in a transaction. Sounds safe, but some operations (like the concurrent index above) can’t run inside one.

That’s when you set:

class Migration(migrations.Migration):
    atomic = False

Now Django won’t try to wrap it. You still get to control smaller atomic blocks manually if needed.

Data vs Schema: Keep Them Separate

Schema migrations should just create/alter tables. Data migrations should just move or transform rows. Mixing them is how you end up with 10-minute deploys.

Run schema changes first, and let them settle. Then push data changes in a controlled way—ideally outside of the critical deploy path.

These tricks don’t make you bulletproof, but they turn a scary, traffic-stopping migration into just another line in your deploy log. And that’s the difference between “Django dev” and “Django engineer.”

6. Production-Proof Workflows

Here’s the reality: migrations don’t fail because Django is bad. They fail because teams treat them like an afterthought. You need a workflow that keeps devs honest, CI strict, and production safe.

Here’s the one I’ve battle-tested:

Step 1: Don’t Ship Ghost Migrations

Before you even push code, make sure you’re not missing any migration files. Run:

python manage.py makemigrations --check

This will scream at you if your models have changes that aren’t captured in migrations. Add it to your pre-commit hooks or CI so broken migrations never make it to main.

Step 2: Sanity Check Before Deploy

Preview what’s about to happen:

python manage.py migrate --plan -v 2

This shows you every migration in order, across every app. If you see something scary—like a giant “ALTER TABLE” on your biggest table—you catch it here, not mid-deploy.

Step 3: CI/CD Guardrails

In your pipeline, add a job that runs:

python manage.py migrate --check

If there are unapplied migrations, the build fails. No more “oops, I forgot to run makemigrations before merging.”

Step 4: Deploy with Automation

When it’s time to ship, let the robots do it:

python manage.py migrate --noinput

No prompts, no hanging builds, just migrations applied. But remember — only safe because you previewed the plan first.

Step 5: Handle Squash Cleanly

Eventually, you’ll squash migrations to keep things tidy. After squashing and deleting old files, don’t forget:

python manage.py migrate --prune

This keeps django_migrations from filling up with junk records pointing to files that no longer exist.

Step 6: Keep Data and Schema Separate

Schema changes ride along with deploys. Data migrations? They often deserve their own rollout. For big backfills, I schedule separate jobs to avoid clogging the deploy window.

With this workflow, migrations stop being a gamble. They become just another line item in your deploy logs—boring, predictable, safe. And boring deploys are the best kind.

7. Debugging and Inspecting Migrations

Even with the best workflow, migrations sometimes misbehave. Maybe someone faked one, maybe prod is out of sync, maybe you’re staring at a locked table wondering what the hell Django is doing. That’s when you need visibility.

showmigrations — The Truth Table

Run:

python manage.py showmigrations --plan

This lists every migration across your apps, marking which ones are applied ([X]) and which aren’t ([ ]).

Add --plan and you see the exact order Django plans to run them. If your database state and migration files are drifting apart, this is where you’ll catch it.

sqlmigrate — See the SQL Before It Hits Prod

Want to know what SQL Django is about to fire off? Try:

python manage.py sqlmigrate app_name 0012

This prints the raw SQL for that migration. Sometimes you’ll be shocked at how destructive a “simple” model change really is. It’s the best way to spot things like full table rewrites before they happen.

Common Mess-Ups

  • Half-applied migrations: DB crashed mid-migrate? You’re in limbo. Use showmigrations to see what’s recorded, then decide if you need --fake to reconcile state.
  • Branch conflicts: Two devs add fields in parallel, now you’ve got a merge conflict in migration dependencies. Resolve it like code — pick the right dependency chain and regenerate.
  • Legacy DB drift: When working with a hand-built schema, you’ll hit mismatches. sqlmigrate is your friend for seeing what Django thinks it should do.

Debugging migrations is less about magic commands and more about knowing where to look. showmigrations tells you what Django thinks happened. sqlmigrate tells you what Django wants to do. Between the two, you can usually claw your way out of the mess.

8. Do’s and Don’ts (Cheat Sheet)

If you only remember one section from this guide, make it this one. These are the hard rules that keep migrations boring instead of catastrophic.

✅ Do’s

  • Preview before pulling the trigger: always run migrate --plan to see what’s about to happen.
  • Lock it down in CI: use migrate --check to fail builds if migrations are missing.
  • Split risky changes: nullable first, backfill, then enforce non-null. Saves you from locking massive tables.
  • Use concurrent ops on Postgres: CreateIndexConcurrently, NOT VALID constraints, etc. They’re there for a reason.
  • Keep schema and data separate: schema in migrations, data in scripts/jobs. Don’t mix them unless you like long deploys.

❌ Don’ts

  • Don’t abuse --fake: lying to Django about state is a quick way to make future migrations impossible. Use it only when you know exactly what’s in your DB.
  • Don’t run migrations on replicas: always target your primary DB with --database. Replicas can’t handle schema writes.
  • Don’t skip planning with --noinput: just because it runs unattended doesn’t mean you shouldn’t know what it’ll do first.
  • Don’t mix massive data migrations into deploys: backfilling millions of rows inline will stall your pipeline.
  • Don’t assume Django makes safe SQL: check with sqlmigrate — sometimes it’s more destructive than you expect.

Tape this list on your wall, share it with your team, and you’ll avoid 90% of the “why is prod on fire?” migration stories.

9. Final Thoughts

Migrations aren’t scary. What’s scary is treating them like background noise. Too many teams just smash migrate in their deploy scripts and hope for the best. That works fine… until it doesn’t. And when it doesn’t, it usually happens at the worst possible moment.

The truth is, Django gives you all the tools you need: --plan to preview, --check to enforce discipline, sqlmigrate to see the SQL, and strategies like concurrent indexes and staged non-null fields to keep production alive. Most failures come down to ignoring those tools, not the framework itself.

If you take anything away from this guide, let it be this: migrations should be boring. They should be predictable. They should never surprise you during a deploy. The moment migrations feel like a gamble, you’ve already lost.

So next time you run python manage.py migrate --noinput, don’t just trust it blindly. Know exactly what’s about to happen, and make it part of a workflow that won’t leave you sweating at 3.07 a.m.

That’s how you go from “Django dev” to “Django engineer.”

Thanks for reading! ❤

If this helped you, consider clapping (50 👏 s), following, or sharing it. A writer without readers is just talking to themselves — so your time means everything.

Let’s keep building better, together.


메타데이터
post_id
f4016de79983
slug
how-to-run-django-migrations-safely-zero-downtime-no-surprises-f4016de79983
url
https://medium.com/@anas-issath/how-to-run-django-migrations-safely-zero-downtime-no-surprises-f4016de79983
canonical_url
https://medium.com/@anas-issath/how-to-run-django-migrations-safely-zero-downtime-no-surprises-f4016de79983
author_url
https://medium.com/@anas-issath
status
ok
fetched_at
2026-08-11 23:03:24