How to Create and Run Django Migrations Safely: A PostHog Production Guide

Create and run Django migrations safely by deploying a multi-phase strategy that separates code changes from database operations, using migrations.SeparateDatabaseAndState for destructive changes and atomic=False with CONCURRENTLY for index creation to prevent downtime in zero-downtime environments.

PostHog operates a zero-downtime, rolling-deploy architecture where application servers and background workers may run different code versions simultaneously during deployment. This environment makes it critical to create and run Django migrations safely, as destructive operations can instantly break older code instances and make rollbacks impossible. The following patterns, derived from the PostHog/posthog repository's engineering handbook, provide battle-tested approaches for handling schema changes without service interruption.

The Core Challenge: Zero-Downtime Rolling Deploys

In a rolling deployment, old and new application versions coexist. A migration that drops, renames, or removes database objects while old code still references them causes immediate failures. According to the safe migration guide in docs/published/handbook/engineering/safe-django-migrations.md, the solution requires treating migrations as deployment phases that span multiple code pushes rather than atomic operations.

The Three-Phase Migration Strategy

PostHog recommends a two-phase or three-phase approach for any risky schema change:

Phase 1 – Remove All Code References

Delete the model, import statements, serializers, viewsets, API endpoints, background-job queries, and any other code references to the database object. Deploy this change and wait for all servers and workers to restart. This ensures no active process attempts to access the object you plan to modify.

Phase 2 – Deploy State-Only Changes with SeparateDatabaseAndState

Generate a migration that updates Django's state without touching the database. Wrap the operation in migrations.SeparateDatabaseAndState, leaving database_operations empty or containing only non-blocking changes:

from django.db import migrations

class Migration(migrations.Migration):
    dependencies = [("myapp", "0001_initial")]

    operations = [
        migrations.SeparateDatabaseAndState(
            state_operations=[
                migrations.DeleteModel(name="OldFeature"),
            ],
            database_operations=[
                # Database untouched; add optional FK constraint cleanup here

            ],
        ),
    ]

This allows new code to run while old code remains active, as the physical database remains unchanged.

Phase 3 – Execute Destructive Operations After Safety Window

Wait for a full deployment cycle (or several minutes) to ensure every server, Celery worker, and cron job has restarted with the new code. Then create a separate migration that physically drops the table, column, or object using RunSQL with IF EXISTS guards.

Safe Patterns for Specific Operations

Dropping Tables and Columns

Never drop tables or columns in the same deployment that removes code references. Follow the three-phase flow: remove references in Phase 1, update state in Phase 2, then use RunSQL to execute DROP TABLE IF EXISTS or ALTER TABLE ... DROP COLUMN IF EXISTS in Phase 3. This pattern prevents query failures from old code attempting to access removed objects.

Renaming Tables and Columns (The "Don't Rename" Rule)

Do not rename tables. Instead, create a new model pointing to the existing table via db_table meta option, migrate data if necessary, then drop the old model later using the phased approach. For columns, keep the old column name in the model using db_column="old_name", add a new column, copy data, then drop the old column in a subsequent migration after the safety window.

Adding NOT NULL Columns

Adding NOT NULL constraints to existing tables risks blocking migrations because existing rows lack values. The safe approach requires three distinct migrations:

  1. Add the column as nullable (null=True)
  2. Back-fill data using a data migration with RunPython
  3. Alter the column to NOT NULL in a subsequent deployment after all code handles the field

Adding Indexes Concurrently

Standard CREATE INDEX commands lock tables. Use CONCURRENTLY with atomic=False and wrap the operation in RunSQL to avoid transaction locks:

from django.db import migrations, connection

def create_concurrent_index(apps, schema_editor):
    with connection.cursor() as cursor:
        cursor.execute(
            "CREATE INDEX CONCURRENTLY IF NOT EXISTS mymodel_field_idx ON myapp_mymodel (field);"
        )

class Migration(migrations.Migration):
    dependencies = [("myapp", "0005_previous")]

    operations = [
        migrations.RunPython(
            create_concurrent_index,
            reverse_code=migrations.RunPython.noop,
            atomic=False,  # Required for CONCURRENTLY

        ),
    ]

Adding Constraints Safely

Adding constraints immediately validates existing rows, which can fail on dirty data or lock tables. Add constraints as NOT VALID first, then validate in a separate migration:

-- First migration
ALTER TABLE myapp_mymodel ADD CONSTRAINT check_positive CHECK (value > 0) NOT VALID;

-- Second migration (after deployment)
ALTER TABLE myapp_mymodel VALIDATE CONSTRAINT check_positive;

Running Large Data Migrations

Long-running UPDATE or DELETE operations lock tables. Batch updates using RunPython that processes a few thousand rows at a time, sleeping briefly between batches:

from django.db import migrations

BATCH_SIZE = 10_000

def batch_update(apps, schema_editor):
    MyModel = apps.get_model("myapp", "MyModel")
    qs = MyModel.objects.filter(needs_update=True).order_by("id")
    
    while True:
        batch = list(qs[:BATCH_SIZE])
        if not batch:
            break
        for obj in batch:
            obj.some_field = "new_value"
            obj.save(update_fields=["some_field"])
        # Optional: import time; time.sleep(0.5)

class Migration(migrations.Migration):
    dependencies = [("myapp", "0009_previous")]

    operations = [migrations.RunPython(batch_update, migrations.RunPython.noop)]

Critical Best Practices

As documented in the PostHog safe migration guide, follow these rules to prevent accidental downtime:

  • One risky operation per migration – Combine only safe operations in single files
  • Use atomic=False only for CONCURRENTLY operations – Never disable transactions for standard schema changes
  • Guard statements with IF EXISTS/IF NOT EXISTS – Prevents failures when rerunning migrations or in inconsistent states
  • Maintain rollback plans – Ensure every migration has a reverse operation or documented manual recovery steps

Summary

  • Create and run Django migrations safely by assuming old and new code versions run simultaneously during deployment
  • Use migrations.SeparateDatabaseAndState to separate Django state changes from physical database operations across multiple deployments
  • Never rename tables or columns; use db_column references and phased creation/deletion instead
  • Add NOT NULL columns in three steps: nullable addition, data back-fill, then constraint application
  • Create indexes concurrently using atomic=False and RunSQL with the CONCURRENTLY keyword
  • Process data migrations in batches with RunPython to prevent table locks
  • Reference the complete guide at docs/published/handbook/engineering/safe-django-migrations.md for operation-specific templates

Frequently Asked Questions

What is migrations.SeparateDatabaseAndState and when should I use it?

migrations.SeparateDatabaseAndState is a Django migration operation that allows you to specify different actions for the database schema and Django's model state. Use it when dropping tables, columns, or making other destructive changes that would break old code still running during deployment. It lets you remove the model from Django's state (so new code doesn't use it) while keeping the physical database object intact until all old servers have shut down.

Why can't I rename tables or columns in a rolling deployment?

Renaming breaks active queries from old code still expecting the original names. In PostHog's rolling-deploy architecture, old application servers continue running until the deployment completes, meaning any renamed table or column reference causes immediate query failures. The safe alternative involves creating new structures with db_table or db_column references, migrating data if necessary, and removing old references only after a full safety window.

How do I safely add a NOT NULL column to an existing table?

First, add the column as nullable (null=True) in a model change and migration. Deploy this change and wait for all servers to restart. Second, create a data migration using RunPython to populate default values for existing rows. Finally, after confirming all code handles the new field, create a third migration altering the field to NOT NULL. This three-phase approach prevents migration failures from existing null values and ensures code compatibility throughout the deployment.

What is the risk of using atomic=False in Django migrations?

atomic=False disables the transaction wrapper around a migration, which is dangerous for most schema changes because it prevents rollback if the migration fails. However, it is required when using PostgreSQL's CONCURRENTLY keyword to create indexes, as concurrent index creation cannot run inside a transaction. Only use atomic=False for CONCURRENTLY index operations and specific PostgreSQL commands that explicitly require it, never for standard table alterations or destructive operations.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →