Skip to main content

Add a nullable field through a staged Django migration

2026-11-11 16:36:25.229853+00:00
Add a nullable field through a staged Django migration

A required new database column can break an older application instance that still inserts rows without it. For a rolling deployment, separate adding storage from enforcing the final rule.

Three rollout stages showing old and new app compatibility.

Expand, backfill, then constrain

Suppose a toy Document model needs a new source_label. First add it as nullable, deploy code that supplies it for new writes and leave older code compatible during the transition.

A data migration can fill existing rows using the historical model supplied to migrations:

from django.db import migrations

def backfill_source_labels(apps, schema_editor):
    Document = apps.get_model("content", "Document")
    alias = schema_editor.connection.alias
    Document.objects.using(alias).filter(
        source_label__isnull=True
    ).update(source_label="legacy")

class Migration(migrations.Migration):
    dependencies = [("content", "0002_add_source_label")]
    operations = [
        migrations.RunPython(
            backfill_source_labels,
            reverse_code=migrations.RunPython.noop,
        )
    ]

Record the backfill's affected-row count and investigate unexpected values before enforcing the final constraint. The names are placeholders for a toy application. The earlier migration must create source_label, and a later migration can make it non-null after the fleet has stopped writing nulls.

Using apps.get_model() keeps the migration tied to the model state at that point in migration history. Importing the current application model directly can make an old migration depend on fields introduced years later.

Account for writes during the transition

A one-time backfill does not stop an old instance from inserting another null immediately afterward. Ensure all active writers have moved to the new behavior, run the final catch-up and verify the condition before applying the constraint.

A single update is simple for a small table. On a large one, transaction duration, write volume and locks may require a batched management command or another planned migration strategy. Rehearse against a disposable database with representative size and the intended backend.

The placeholder "legacy" is a data policy, not a technically correct value for every document. If the field cannot be derived reliably, keep an explicit unknown state and decide whether the product can require it at all.

Rollback also needs planning. Removing a non-null constraint may restore compatibility, but reversing a data backfill may be impossible without knowing which values existed before it ran. A noop reverse operation makes that boundary explicit.

The deployment concerns in Nirogidhara provide related context. Django's migration documentation covers historical models and data operations.