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

> Safely create and run Django migrations with PostHog's production guide. Learn a multi-phase strategy to separate code and database ops, prevent downtime, and deploy zero-downtime changes.

- Repository: [PostHog/posthog](https://github.com/PostHog/posthog)
- Tags: best-practices
- Published: 2026-04-25

---

**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`](https://github.com/PostHog/posthog/blob/main/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:

```python
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:

```python
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:

```sql
-- 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:

```python
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`](https://github.com/PostHog/posthog/blob/main/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.