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.
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 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.
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.