Django’s migrate command is a surprisingly complex beast when you’re aiming for zero downtime in production.

Here’s how you can run Django migrations without taking your application offline, even with potentially long-running or dependency-heavy schema changes.

The core problem is that a standard Django migration, by default, performs two operations atomically: it adds or modifies the database schema, and then it allows your application code to use that new schema. If your application code is deployed after the schema change but before the migration is fully committed, or if the schema change itself is a breaking change for older code versions, you’ll get errors.

The key to zero-downtime migrations is to decouple the schema change from the application code deployment and to ensure that both old and new code versions can coexist with the database schema during the transition.

The Two-Phase Migration Strategy

This strategy involves splitting your migration into two distinct steps, executed with careful coordination of your deployment process.

Phase 1: Add the New Schema Element (or prepare for removal)

  1. The Migration: Your first migration will typically add a new column, a new table, or a new constraint. Crucially, it must not alter existing columns or remove anything that your current running application code relies on.

    For example, to add a new nullable boolean field is_active to a User model:

    # migrations/00XX_add_is_active_to_user.py
    from django.db import migrations, models
    
    class Migration(migrations.Migration):
        operations = [
            migrations.AddField(
                model_name='user',
                name='is_active',
                field=models.BooleanField(default=True),
            ),
        ]
    
  2. The Deployment: After applying this migration to your database, you deploy a new version of your application code. This new code knows about the is_active field and can read/write to it, but it still gracefully handles the case where it might be None (if you made it nullable) or uses the default value. Critically, the old version of your application code does not know about is_active and will simply ignore it. This is the "blue/green" or "rolling deployment" advantage: multiple versions of your app code can run concurrently.

  3. The Wait: You must wait for all your application instances to be updated to the new code version. This ensures that no instance is trying to read or write to a field that doesn’t exist yet. You can monitor this by checking your deployment tooling (e.g., Kubernetes rollout status, Capistrano/Fabric deployment logs).

Phase 2: Remove the Old Schema Element (or finalize the change)

  1. The Migration: Now that all application instances are running the new code that can handle the is_active field, you create a second migration. This migration will either use the new field (e.g., setting a default for existing rows if it wasn’t nullable, or populating it), or, if you were replacing a field, it would remove the old, now-obsolete field.

    Let’s say you want to remove an old is_enabled boolean field, and you’ve already added is_active in the previous phase. You’d have a migration that looks like this:

    # migrations/00YY_remove_is_enabled_from_user.py
    from django.db import migrations
    
    class Migration(migrations.Migration):
        operations = [
            migrations.RemoveField(
                model_name='user',
                name='is_enabled',
            ),
        ]
    

    Self-correction: If you are modifying a column (e.g., changing its type or nullability), you often need a three-phase approach: 1) Add the new column, 2) Deploy code that writes to both old and new, 3) Deploy code that reads only from the new, 4) Remove the old column. For simplicity, we’ll stick to add/remove here.

  2. The Deployment: After applying this second migration to your database, you deploy a new version of your application code. This version no longer references the is_enabled field at all. The old code, which still referenced is_enabled, should have already been phased out in the previous deployment.

  3. The Cleanup: Once all instances are running the latest code that doesn’t rely on the removed is_enabled field, you can consider the migration complete.

Common Pitfalls and How to Avoid Them

  • AlterField and RenameField are your enemies: These operations often require a brief period where the database schema is in an inconsistent state relative to your code. You cannot do these atomically with a code deploy if you want zero downtime. You must break them into add/remove or temporary columns. For RenameField, the strategy is: 1) Add new field, 2) Deploy code that writes to both old and new, 3) Deploy code that reads from new and writes to new, 4) Remove old field.
  • Data Dependencies: If your migration involves populating a new field based on existing data, or if a new constraint depends on existing data, you might need a multi-stage process. For example, adding a NOT NULL constraint requires all existing rows to satisfy it. You’d first add the column as nullable, then deploy code to populate it for all existing rows, then run a migration to set the NOT NULL constraint.
  • Dependencies between Migrations: Django’s migration dependency system is crucial. Ensure your two-phase migrations correctly depend on each other.
  • Database Locks: Long-running ALTER TABLE statements can lock tables, blocking reads and writes. Some databases (like PostgreSQL) have online ALTER TABLE operations for certain changes, but it’s not universal. For complex changes, consider tools like pt-online-schema-change or gh-ost which perform schema changes in a sidecar table and then swap them, minimizing downtime. However, Django’s built-in AddField and RemoveField are often optimized enough for common cases.
  • Testing: Thoroughly test your migrations in a staging environment that mirrors production as closely as possible, including load testing. Simulate the phased deployments.

The "One Thing" Most People Miss

The most common misunderstanding is that a Django migration is just a database operation. It’s not. It’s a coordination point between your database schema and your application code. The migration command itself only touches the database. The deployment process is what bridges the gap. You must ensure your deployment process is designed to handle multiple versions of your application code running simultaneously, and that you wait for a full rollout of the code that understands the new schema before applying the schema change that would break the old code, and vice-versa for removals.

The next hurdle you’ll likely face is managing complex data transformations that need to happen after a schema change but before the new code fully utilizes it.

Want structured learning?

Take the full Django course →