Removing a Django Field with SeparateDatabaseAndState

The legacy_code field on Order has been unused for months, so you deleted it from the model, ran makemigrations, and deployed. The migration dropped the column in forty milliseconds. For the next five minutes, every request served by an old pod failed with column orders_order.legacy_code does not exist, because Django builds its SELECT statements from the model’s full field list — the old code did not use the field, but it still selected it on every query. Removing a field safely takes two releases: first stop the code from referencing the column, then drop the column. Django’s SeparateDatabaseAndState operation lets you express exactly that split, so model state and database schema change at different times. This guide walks through it as part of Django Migrations Without Downtime.

Removing a Field in Two Releases Timeline across two releases. In release 1, the field is removed from the model and Django state, but the column stays in the database; old pods selecting it still work. In release 2, after no pod references the field, a migration drops the column. Removing a Field in Two Releases release 1 migrate release 2 migrate Model field present removed from state DB column present (unused after release 1) dropped Old pods select it until rollout ends release 1 rollout release 2 rollout exists state only removed column dropped still referenced
The column outlives the field by one full release, so every running version always finds every column it selects.

Symptom / Error Signatures

A premature field removal produces errors from the old version of the code during the rollout:

django.db.utils.ProgrammingError: column orders_order.legacy_code does not exist
LINE 1: SELECT "orders_order"."id", ..., "orders_order"."legacy_code" FROM "orders_order" ...

On MySQL the error is ERROR 1054 (42S22): Unknown column 'orders_order.legacy_code' in 'field list'. The same failure can come from other consumers — a reporting service with its own copy of the model, a Celery worker on an older image, an admin export — that the deploy did not replace at the same time.

Root Cause Analysis

Django’s ORM selects every concrete field of a model unless told otherwise (only()/defer()), and it names each column explicitly. So a column that no line of application code reads is still referenced by every query on the model. When a single migration removes the field from Django’s state and drops the column, the database changes the moment the migration commits, while pods built from the previous release keep generating queries that name the column until they are replaced.

SeparateDatabaseAndState resolves this by letting one migration contain two lists of operations: state_operations, which change Django’s in-memory model state (what makemigrations compares against), and database_operations, which run SQL. By putting RemoveField in state_operations with no database operations in release one, you tell Django “the field is gone” without touching the table. In release two, a migration with RunSQL (or a RemoveField in database_operations with an empty state list) drops the column.

Release Model Migration Database Old pods
N (today) has legacy_code column exists select it
N+1 field deleted SeparateDatabaseAndState(state_operations=[RemoveField]) column still exists selects it — works
N+2 RunSQL("ALTER TABLE … DROP COLUMN …") column dropped N+1 pods do not select it — works
One Migration vs SeparateDatabaseAndState Two panels. Default: one migration with RemoveField that changes state and drops the column at the same time, breaking old pods. Split: release N+1 migration removes the field from state only; release N+2 migration drops the column with RunSQL. One Migration vs SeparateDatabaseAndState makemigrations default RemoveField('order', 'legacy_code') state and column change together old pods: column does not exist errors for the whole rollout Split across releases N+1: SeparateDatabaseAndState, state_operations=[RemoveField] column untouched, old pods fine N+2: RunSQL DROP COLUMN IF EXISTS no failure window
The only change is where the DROP happens — moving it one release later removes the whole failure window.

Immediate Mitigation

If old pods are failing now because the column was dropped early:

1. Re-add the column as nullable. This is instant on PostgreSQL and MySQL 8.0, and immediately satisfies the old pods’ SELECT lists. The data is gone, but the errors stop.

-- PostgreSQL · migration role · metadata-only ADD COLUMN
-- WARNING: restores the column shape only; values dropped earlier are not recovered.
SET lock_timeout = '2s';
ALTER TABLE orders_order ADD COLUMN IF NOT EXISTS legacy_code varchar(20) NULL;
-- ROLLBACK PATH: ALTER TABLE orders_order DROP COLUMN IF EXISTS legacy_code;  (after rollout completes)

2. Finish the rollout, then remove the column again properly. Once only new pods remain, drop the column in a later release with the procedure below. If the dropped data mattered, recover it from backups as described in recovering data after an irreversible migration.

Permanent Fix / Long-Term Pattern

1. Release N+1: remove the field from the model and from Django’s state only. Delete the field from models.py, run makemigrations, and then edit the generated migration to wrap RemoveField in SeparateDatabaseAndState. If the column is NOT NULL without a database default, make it nullable in the same migration’s database operations, because code in N+1 no longer writes it and inserts would otherwise fail.

# Python · orders/migrations/0050_remove_order_legacy_code_state.py · release N+1
# WARNING: the column remains in the database; do not drop it until release N+2.
from django.db import migrations

class Migration(migrations.Migration):
    dependencies = [("orders", "0049_previous")]
    operations = [
        migrations.SeparateDatabaseAndState(
            state_operations=[migrations.RemoveField("order", "legacy_code")],
            database_operations=[
                migrations.RunSQL(
                    sql='ALTER TABLE "orders_order" ALTER COLUMN "legacy_code" DROP NOT NULL',
                    reverse_sql=migrations.RunSQL.noop,
                ),
            ],
        ),
    ]
# ROLLBACK PATH: reversing restores the field in Django state; the column was never removed.

2. Verify no code references the column. Search the codebase, other services and raw SQL for legacy_code, and confirm on production that queries no longer mention it. pg_stat_statements is a practical check:

-- PostgreSQL · read-only · requires the pg_stat_statements extension
SELECT calls, left(query, 120) AS query
FROM pg_stat_statements
WHERE query ILIKE '%legacy_code%'
ORDER BY calls DESC;

3. Release N+2: drop the column. Use RunSQL so Django’s state (already without the field) is unaffected, and bound the lock.

# Python · orders/migrations/0051_drop_order_legacy_code_column.py · release N+2
# WARNING: irreversible for data; archive the column first if it may be needed.
from django.db import migrations

class Migration(migrations.Migration):
    dependencies = [("orders", "0050_remove_order_legacy_code_state")]
    operations = [
        migrations.RunSQL("SET LOCAL lock_timeout = '2s'", reverse_sql=migrations.RunSQL.noop),
        migrations.RunSQL(
            'ALTER TABLE "orders_order" DROP COLUMN IF EXISTS "legacy_code"',
            reverse_sql='ALTER TABLE "orders_order" ADD COLUMN IF NOT EXISTS "legacy_code" varchar(20) NULL',
        ),
    ]

The same two-release shape applies to deleting a model (DeleteModel in state first, DROP TABLE later) and to renames, which become add-new, dual-write, switch, remove-old — see renaming a column with expand and contract. The general contract-phase rules are in safely removing a NOT NULL column with expand-contract.

Field Removal Checklist Across Releases Five steps. Release N+1 removes the field from the model and state and drops NOT NULL; deploy and let the rollout complete; verify no queries reference the column; release N+2 drops the column with lock_timeout; confirm no errors. Field Removal Checklist Across Releases STEP 1 N+1: state only SeparateDatabaseA ndState STEP 2 Rollout completes no pod selects column STEP 3 Verify unused pg_stat_statements = 0 STEP 4 N+2: DROP COLUMN RunSQL, lock_timeout STEP 5 Confirm no errors, drift check clean
The verification step between releases is what makes the drop safe — do it on production data, not by reading code.

Verification Checklist

Frequently Asked Questions

Why do old pods fail if they never use the field? Because Django selects every concrete field of a model by default and names each column explicitly. A query for Order.objects.get(pk=1) includes legacy_code in its SELECT list, so it fails once the column is gone.

Can I use defer() instead of two releases? Deferring the field in every query path in release N+1 would work, but it is easy to miss one — the admin, serializers, values() calls. Removing the field from the model is simpler and complete, and SeparateDatabaseAndState keeps the column around until nothing selects it.

Does SeparateDatabaseAndState work on MySQL? Yes. It is database-agnostic; it only controls which operations change Django’s state and which run SQL. The column drop in release N+2 uses the MySQL syntax your backend requires.

What if another service reads the same table? Treat it as another “old pod”: the column cannot be dropped until that service has also stopped selecting it. Query statistics on the database are the reliable way to confirm, since you may not control that service’s code.