Remove a Django Field Without Downtime

Drop a Django model field and its database column safely during rolling deploys: stop using the field in code first, remove it from Django’s state with SeparateDatabaseAndState, drop the column in a later release, and handle NOT NULL columns and dependent indexes.

Problem statement

You delete a field from a model, run makemigrations, and deploy. The migration drops the column while old application instances are still running. Django selects every model column by name, so old instances fail on every query of that model with column "legacy_code" does not exist, not just where the field was used. Removing a field safely means removing it from code before removing it from the database.

Why dropping a column breaks deploysMigration dropsthe columnOld code selectsall model fieldsEvery query failscolumn missingUntil rollout endserrors everywhereDjango names every column in SELECTs, so old code breaks on any query of the model.
  1. Release 1: stop reading and writing the field anywhere (views, forms, serializers, admin, raw SQL).
  2. Release 2: remove the field from the model, but keep the column with SeparateDatabaseAndState; if the column is NOT NULL without a default, make it nullable first.
  3. Release 3: drop the column with a migration once no running code knows about it.
  4. Drop dependent indexes and constraints concurrently if the table is large.
  5. Check other consumers: reports, other services, ETL jobs, and database views.

Step-by-step solution

1. Stop using the field

Search the codebase for the field name, including templates, serializers, values() calls, raw SQL, and admin list_display. Deploy code that no longer reads or writes it. The column stays in the database and in the model for now, so old and new code both work.

2. Make NOT NULL columns safe for inserts

If the column is NOT NULL without a database default, new code that no longer sets it will fail on insert once the field is gone from the model. Before removing the field, make it nullable (a fast metadata change on PostgreSQL):

legacy_code = models.CharField(max_length=20, null=True)

Or give it a database default with db_default (Django 5.0+).

3. Remove the field from Django's state only

class Migration(migrations.Migration):
    dependencies = [("orders", "0050_order_legacy_code_nullable")]
    operations = [
        migrations.SeparateDatabaseAndState(
            state_operations=[migrations.RemoveField(model_name="order", name="legacy_code")],
            database_operations=[],
        ),
    ]

Django stops including the column in queries, but the column still exists. Old instances that still have the field in their model can keep selecting it during the rollout.

code first, column lastR1: stop using fieldmodel unchangedMake column nullableif NOT NULLR2: remove from statecolumn keptR2 fully rolled outno code selects itR3: DROP COLUMNsafe nowThe database only loses the column after every process has forgotten itdjango-deployment.com
Celery workers and cron jobs count as running code too.

4. Drop the column later

After release 2 is deployed everywhere (web, workers, scheduled jobs):

class Migration(migrations.Migration):
    dependencies = [("orders", "0051_remove_order_legacy_code_state")]
    operations = [
        migrations.RunSQL(
            "ALTER TABLE orders_order DROP COLUMN IF EXISTS legacy_code;",
            reverse_sql=migrations.RunSQL.noop,
        ),
    ]

DROP COLUMN on PostgreSQL is fast (it only marks the column dropped) but needs a brief ACCESS EXCLUSIVE lock. Set a lock_timeout so it fails fast instead of queueing behind long transactions and blocking everyone:

migrations.RunSQL("SET lock_timeout = '5s';"),

5. Handle indexes and foreign keys

Indexes on the column are dropped with it. On very large tables, drop them first with RemoveIndexConcurrently to avoid doing it under the exclusive lock. For a ForeignKey, also check constraints and that no other code joins through it.

safe column removalNo code reads or writes itsearch all reposNullable or has db_defaultinserts keep workingRemoved from state, deployedweb, workers, cronIndexes dropped concurrentlylarge tableslock_timeout on DROPfail fast, retrySkipping any item can turn a cleanup into an outagedjango-deployment.com
Reports and other services often depend on columns the app no longer uses.

Explanation

Django's ORM generates explicit column lists for SELECT statements based on the model definition each process loaded at startup. During a rolling deploy, processes with the old model definition still list the dropped column and fail. Removing the field from the model state first (while keeping the column) makes all processes stop referencing it; only then is the physical drop harmless. This is the contract half of the expand-and-contract approach.

Edge cases / notes

  • Rollbacks. Once the column is dropped, rolling back to code that uses it is impossible without restoring data; drop only after you are sure.
  • Deferred fields. Code using .only() or .defer() still breaks if the field name is referenced.
  • MySQL. DROP COLUMN rebuilds the table on older MySQL versions; use online DDL or tools like gh-ost on large tables.

Next steps

FAQ

Why does removing an unused field break the site during deploy?

Old processes still select the column on every query of that model. Remove the field from code first, drop the column later.

What does SeparateDatabaseAndState do here?

It removes the field from Django's model state without running any SQL, so the column stays until a later migration drops it.

Is DROP COLUMN slow on PostgreSQL?

No, it only marks the column dropped, but it needs a brief exclusive lock; use a lock timeout.

Can I roll back after dropping the column?

Only by restoring the data from a backup. Make sure no release needs it before dropping.

Learning path · step 7 of 14

Releases without downtime

Keep going