Adding a Non-Null Field in Django Without Locking

You added region = models.CharField(max_length=32, default="eu") to the Order model, generated the migration, and it applied in under a second — PostgreSQL did not even rewrite the table. Then, for the six minutes of the rolling deploy, the old pods threw IntegrityError: null value in column "region" of relation "orders_order" violates not-null constraint on every checkout. The migration was fast; it was not backward compatible. Django added the column with the default, then removed the database default, and the old code — which has never heard of region — inserts rows without it. This guide shows the two safe ways to add a non-null field: Django 5.0’s db_default, and the classic nullable, backfill, tighten sequence for older versions or computed values. It is one of the core patterns in Django Migrations Without Downtime.

Why Old Pods Fail After AddField(default=…) Sequence between the migrate job, PostgreSQL and an old application pod. The migration adds the column with DEFAULT eu and NOT NULL, then drops the default. An old pod then inserts an order without the region column; PostgreSQL has no default to fill it and rejects the insert with a not-null violation. Why Old Pods Fail After AddField(default=…) migrate job PostgreSQL Old pod (v41) ADD COLUMN region varchar(32) DEFAULT 'eu' NOT NULL ALTER COLUMN region DROP DEFAULT INSERT INTO orders_order (id, total, …) region omitted, no default IntegrityError: not-null violation
The column is instant to add; the failure comes from Django dropping the database default while code that omits the column is still running.

Symptom / Error Signatures

The failure appears only during the overlap between the migration and the end of the rollout:

django.db.utils.IntegrityError: null value in column "region" of relation "orders_order" violates not-null constraint
DETAIL:  Failing row contains (184223, 42.00, 2026-09-18 10:14:03+00, null).

On MySQL the same situation produces ERROR 1364 (HY000): Field 'region' doesn't have a default value under strict SQL mode. sqlmigrate shows the cause plainly:

BEGIN;
ALTER TABLE "orders_order" ADD COLUMN "region" varchar(32) DEFAULT 'eu' NOT NULL;
ALTER TABLE "orders_order" ALTER COLUMN "region" DROP DEFAULT;
COMMIT;

A second, older symptom appears on PostgreSQL 10 and earlier, or when the default is volatile (for example default=uuid.uuid4 implemented in the database, or now()-style expressions): the ADD COLUMN ... DEFAULT rewrites the whole table under ACCESS EXCLUSIVE, and every query on the table stalls for the duration.

Root Cause Analysis

Django’s position is that defaults belong to the model, not the database. When a new field has default=, the migration uses the default only to populate existing rows during ADD COLUMN, then drops it so that the database schema matches Django’s model of it. For code that knows about the field, nothing changes — Django fills in the default in Python on every save. For code that does not, the database no longer has a default to apply, and NOT NULL rejects the insert.

On PostgreSQL 11+, ADD COLUMN ... DEFAULT <constant> is metadata-only: the default is stored in the catalog and returned for existing rows without rewriting them. So the lock is instant. MySQL 8.0 similarly supports ALGORITHM=INSTANT for adding columns with defaults. The table-rewrite risk only applies to old PostgreSQL versions or volatile defaults.

Approach DDL cost (PG 11+) Old pods during rollout Django version
default="eu" instant fail: default dropped any
db_default="eu" instant work: DB fills 'eu' 5.0+
null=True, backfill, then null=False instant + batched backfill + validated tighten work throughout any
volatile default (e.g. DB-side random) full table rewrite work, but table blocked any
db_default vs Nullable-Backfill-Tighten Two panels comparing safe approaches. db_default: one migration, ADD COLUMN with DEFAULT kept in the database, old pods work immediately; best for constant defaults on Django 5.0+. Nullable-backfill-tighten: three migrations across releases, add nullable, batched backfill, CHECK NOT VALID then VALIDATE then SET NOT NULL; works on any version and for computed values. db_default vs Nullable-Backfill-Tighten db_default (Django 5.0+) one migration ADD COLUMN … DEFAULT 'eu' NOT NULL, default kept old pods: DB fills the value instant on PostgreSQL 11+ simplest when the value is constant Nullable → backfill → tighten release 1: AddField(null=True) backfill in batches (RunPython, atomic = False) release 2: CHECK … NOT VALID, VALIDATE SET NOT NULL uses the validated check works for computed values, any version
Use db_default whenever the value is a constant and you are on Django 5.0+; fall back to the three-step sequence for computed values or older Django.

Immediate Mitigation

If old pods are failing right now, the fastest relief is to restore a database default. It is a metadata-only change on PostgreSQL and MySQL 8.0 and immediately makes old inserts succeed.

1. Put the default back in the database.

-- PostgreSQL · migration role · brief ACCESS EXCLUSIVE, metadata only
-- WARNING: set lock_timeout so this cannot queue behind a long transaction during the incident.
SET lock_timeout = '2s';
ALTER TABLE orders_order ALTER COLUMN region SET DEFAULT 'eu';
-- ROLLBACK PATH: ALTER TABLE orders_order ALTER COLUMN region DROP DEFAULT;

2. Let the rollout finish. Once no old pods remain, every insert comes from code that sets region explicitly, and the database default is harmless either way.

3. Record the database default in a migration. On Django 5.0+, change the field to db_default="eu" and generate a migration so Django’s state matches the database; on older versions, add a RunSQL migration with SET DEFAULT and a matching reverse so the next makemigrations does not remove it.

Permanent Fix / Long-Term Pattern

On Django 5.0+, use db_default for constant defaults. It keeps the default in the database permanently, so any code path — old pods, raw SQL, other services — gets the value.

# Python · orders/models.py and generated migration · Django 5.0+ · PostgreSQL 11+ adds it instantly
# WARNING: db_default must be a constant or a database expression, not a Python callable.
class Order(models.Model):
    region = models.CharField(max_length=32, db_default="eu")

# generated: migrations.AddField("order", "region",
#     models.CharField(max_length=32, db_default="eu"))
# SQL: ALTER TABLE "orders_order" ADD COLUMN "region" varchar(32) DEFAULT 'eu' NOT NULL;
# ROLLBACK PATH: migrate back to the previous migration drops the column (and its data).

For computed values or older Django, use three releases. Release one adds the field as null=True and ships code that writes it. A batched RunPython migration backfills existing rows, committing per batch. Release two tightens the column without a long scan by first adding a CHECK (region IS NOT NULL) NOT VALID constraint, validating it (which takes only SHARE UPDATE EXCLUSIVE), and then setting NOT NULL, which PostgreSQL 12+ proves from the validated constraint instead of scanning — the pattern in adding NOT NULL via a CHECK constraint.

# Python · orders/migrations/0045_region_not_null.py · PostgreSQL 12+ · Django 4.0+ (use condition= instead of check= on 5.1+)
# WARNING: run only after the backfill migration has completed and new code writes region.
from django.contrib.postgres.operations import AddConstraintNotValid, ValidateConstraint
from django.db import migrations, models
from django.db.models import Q

class Migration(migrations.Migration):
    dependencies = [("orders", "0044_backfill_region")]
    operations = [
        AddConstraintNotValid("order", models.CheckConstraint(check=Q(region__isnull=False), name="order_region_nn")),
        ValidateConstraint("order", "order_region_nn"),
        migrations.AlterField("order", "region", models.CharField(max_length=32)),
    ]
# ROLLBACK PATH: reversing drops the constraint and makes region nullable again; no data is lost.

Keep the backfill itself gentle — batches of a few thousand rows keyed on the primary key, with a short pause between them — as described in tuning backfill batch size against replication lag and writing reversible RunPython data migrations.

Three Releases for a Computed Non-Null Field Five steps across three releases. Release 1: add the field nullable and deploy code that writes it. Backfill existing rows in batches. Release 2: add CHECK NOT VALID and validate it online. Set NOT NULL using the proof. Release 3: optionally drop the helper constraint. Three Releases for a Computed Non-Null Field STEP 1 AddField null=True release 1 writes it STEP 2 Batched backfill RunPython, atomic = False STEP 3 CHECK NOT VALID instant STEP 4 VALIDATE SHARE UPDATE EXCLUSIVE STEP 5 SET NOT NULL proof from the check
Each step is backward compatible with the code running at the time, so a rollback of the application never needs a schema rollback.

Verification Checklist

Frequently Asked Questions

Does adding a column with a default rewrite the table in PostgreSQL? Not since PostgreSQL 11 when the default is a constant or non-volatile expression: the value is stored in the catalog and applied to existing rows on read. Volatile defaults, such as random() or clock_timestamp(), still force a rewrite.

What is the difference between default and db_default? default is applied by Django in Python when saving model instances; the migration uses it only to populate existing rows and then drops it from the database. db_default, added in Django 5.0, is stored in the database schema and applied by the database to any insert that omits the column.

Can I use a callable with db_default? No. db_default accepts a literal value or a database expression such as Now(). For values computed in Python, add the field nullable, backfill, and tighten.

Is the old-pod failure a problem on MySQL too? Yes. Under strict SQL mode an insert that omits a NOT NULL column without a default fails with ERROR 1364. The same remedies apply: keep a database default, or add the column nullable and tighten later.