Zero-downtime migrations¶
A migration applied while the application keeps running must not keep a big table locked for long, and must not break the code still running during the deployment — the old version, which reads the columns and tables as they were. hare checks migrations for both (the safety check), and its operations make each risky change in small, safe steps — the "expand-contract" pattern.
Checking migrations¶
makemigrations checks the migrations it writes and prints what is risky, each with how to make the
same change safely:
WARNING: risky on a database in use if the tables are large - `hare checkmigrations` checks against the database:
app.0007_remove_book_pages: Remove field pages from Book [remove_field]
The column of Book.pages is dropped while the code still running reads it.
Safely: First remove the field from the models only - SeparateDatabaseAndState(state_operations=[RemoveField(model_name='Book', name='pages')]) - and drop the column with RunSQL in a later migration, once no running code reads it.
It doesn't read the database, so every table that exists before the migration may be large.
hare checkmigrations [APP ...] checks the migrations migrate would apply, in its order, against
the database: a table counts as large from migrations.safety.large_table_rows rows (100 000 by default)
— PostgreSQL reads the planner's estimate (pg_class.reltuples), counting the rows itself, no further
than that, for a table never analyzed; SQLite counts them the same way; ClickHouse reads the count its
table engines keep (system.tables.total_rows) — a view, which keeps none, counts as large. The command
exits with 1 while a risk isn't exempted — run it in CI before deploying.
large_table_rows is a whole number of rows — 0 makes every table large.
The same check from code:
from hare.migrations.api import checkmigrations
risks = await checkmigrations(config=HARE_ORM, app_labels=["library"])
for risk in risks:
print(risk.code, risk.app_label, risk.migration_name, risk.operation, risk.message, risk.safe_alternative)
Each MigrationRisk names the rule (code, a MigrationRiskCode), the migration and its operation (as
describe() writes it), what goes wrong (message) and the safe way (safe_alternative); exempted is
True for a risk its migration accepts.
- A table the same migration creates is new and empty — none of the lock rules apply to it.
- In a
SeparateDatabaseAndState, the database operations are checked by the lock rules; the rules about running code (renamed and removed fields and models) don't apply — such an operation is how the code and the database are kept in step by hand. MigrationSafetyChecker(large_table_rows=...)(hare.migrations.safety) checks one migration on a given state:await checker.check(migration, state, dialect=..., client=...)— withoutclient, every existing table may be large.
Exempting a risk¶
A risk checked by hand — raw SQL read through, a table known to stay small — is accepted by listing its
code in the migration's safety_exemptions. checkmigrations still lists it, marked (exempted), and
doesn't fail for it:
from hare import migrations
from hare.migrations import operations as ops
from hare.migrations.safety import MigrationRiskCode
class Migration(migrations.Migration):
dependencies = [("library", "0006_loans")]
safety_exemptions = [MigrationRiskCode.RUN_SQL]
operations = [ops.RunSQL("UPDATE library_settings SET value = 'on' WHERE key = 'beta'")]
An entry that isn't a MigrationRiskCode raises ConfigurationError.
The rules¶
The rules live in the dialects (Dialect.migration_safety_rules): every database has the shared ones,
PostgreSQL adds those of its locks. "Large" below means a table that may hold large_table_rows rows or
more.
| Code | Databases | Found |
|---|---|---|
add_field_backfills_rows |
all | A NOT NULL field with only a Python default added to a large table |
add_field_without_default |
all | A NOT NULL field with no default at all added to a table that may hold rows |
add_field_volatile_default |
PostgreSQL | A field with a volatile database default added to a large table |
alter_field_rewrites_table |
all | A field change that rewrites a large table |
add_index_without_concurrently |
PostgreSQL | An index built on a large table while its writes wait |
add_check_constraint_validates_rows |
PostgreSQL | A CHECK constraint added to a large table without not_valid=True |
add_foreign_key_validates_rows |
PostgreSQL | A FOREIGN KEY added to a large table without not_valid=True |
add_unique_constraint_builds_index |
PostgreSQL | A unique constraint whose index is built on a large table |
set_not_null_scans_table |
PostgreSQL | NOT NULL set by scanning a large table while it is locked |
rename_field |
all | A field renamed together with its column |
remove_field |
all | A field removed together with its column |
rename_model |
all | A table renamed |
delete_model |
all | A model deleted together with its table |
run_sql |
all | Raw SQL |
schema_change_with_run_python |
all | Python code in the transaction that changes the schema of a large table |
add_field_backfills_rows¶
AddField of a NOT NULL field whose only default is a Python default= (or auto_now) adds the column
nullable, updates every existing row, then makes it NOT NULL — all inside the migration, holding the
table. Safely: add it nullable, fill it with
BackfillColumn in a migration with atomic = False, then
make it NOT NULL with AlterColumnNotNullSafe (pattern 1) — or give it a
constant db_default, which the database stores once without touching the rows.
add_field_without_default¶
AddField of a NOT NULL field with neither a default= (or auto_now) nor a db_default: the database has
no value for the rows the table holds, and the migration fails on any table that isn't empty. It is
reported for every table the migration doesn't create — by makemigrations and by checkmigrations
without a connection, for one holding rows when the database is read. Safely: give the field a
default= or a db_default, or add it nullable, fill it, then make it NOT NULL
(pattern 1).
add_field_volatile_default¶
A database default calling a volatile function — random(), gen_random_uuid(), uuidv7(),
clock_timestamp(), nextval()... — is evaluated for every existing row: PostgreSQL rewrites the table.
A constant or a stable function (Now()) is stored once. Safely: add the field nullable without
db_default, set the db_default with AlterField (new rows get it), fill the existing rows with
BackfillColumn, then AlterColumnNotNullSafe.
alter_field_rewrites_table¶
A field change the database makes by rewriting the table, which can't be read or written meanwhile. The
dialect's schema editor tells which (rewrites_table_on_alter()): PostgreSQL rewrites for a column type
change, except to a type that holds every old value as it is — a longer or unlimited varchar, varchar
to text and back to an unlimited one, numeric with more digits and the same scale, cidr to inet;
SQLite rebuilds the table for every change but a plain rename and one that leaves the column declared as
it was (a Python-side option such as sensitive, the field's own index); ClickHouse rewrites for every
column type change. Safely: add a field of the new definition, fill it in batches, switch the code to
it, and remove the old one (pattern 2).
add_index_without_concurrently¶
AddIndex without concurrently=True, and the index of a field added (or altered to get one) with
db_index=True — a foreign key gets one by default — are built while the table's writes wait.
Safely: AddIndex(..., concurrently=True) in a migration with atomic = False (pattern
3); for a field, declare it with db_index=False and add its index in Meta.indexes
that way.
add_check_constraint_validates_rows¶
AddConstraint of a CheckConstraint checks every row while the table can't be read or written.
Safely: AddConstraint(..., not_valid=True) — only new and updated rows are checked — and
ValidateConstraint in a later migration, which doesn't block writes (pattern
4).
add_foreign_key_validates_rows¶
AddField of a ForeignKeyField/OneToOneField with a database constraint checks every row while the
writes to both tables wait; so does an AlterField giving a relation a constraint or another target.
Safely: AddField(..., not_valid=True) and ValidateConstraint with the foreign key's name later
(pattern 4); an AlterField goes into a SeparateDatabaseAndState with the
constraint added NOT VALID by RunSQL.
add_unique_constraint_builds_index¶
AddConstraint of a UniqueConstraint, and a field added or altered with unique=True, build the
constraint's unique index while the table's writes wait. Safely: build the index first with
AddIndex(..., unique=True, concurrently=True), then let the constraint take it over with
AddConstraint(..., using_index=...) (pattern 4). A unique constraint with a
condition can't take over an index — declare it as a unique PartialIndex instead.
set_not_null_scans_table¶
AlterField from null=True to null=False scans the table while it can't be read or written; so does
AlterColumnNotNullSafe inside a transaction. Safely: AlterColumnNotNullSafe in a migration with
atomic = False (below).
rename_field and rename_model¶
A field renamed with its column (RenameField), a model renamed with its table (RenameModel of a model
without Meta.table), or a changed Meta.table (AlterModelTable): the code still running uses the old
name and fails until it is replaced. A rename that keeps the column or table changes nothing in the
database and isn't reported. Safely: keep the name in the database — source_field="<old column>" on
the field, Meta.table = "<old table>" on the model — or move to a new column (pattern
2).
remove_field and delete_model¶
RemoveField drops the column, DeleteModel the table, while the code still running reads them.
Safely: remove them from the models first —
SeparateDatabaseAndState(state_operations=[RemoveField(...)]), DeleteModel(..., state_only=True) —
and drop the column or table with RunSQL in a later migration, once no running code reads it (pattern
5).
run_sql¶
RunSQL: the check can't tell which tables the SQL locks, or for how long.
Safely: read the statements through, then exempt the code in the migration.
schema_change_with_run_python¶
RunPython in an atomic migration that also changes the schema of a large table, on a database whose
schema changes are transactional: the locks the schema changes take are held until the transaction ends —
for as long as the code runs. Safely: move the RunPython into a migration of its own, or set
atomic = False on the migration.
BackfillColumn and AlterColumnNotNullSafe¶
class BackfillColumn:
def __init__(
self,
model_name: str,
field_name: str,
value: Any | Callable[[], Any],
*,
batch_size: int = 1000,
) -> None: ...
class AlterColumnNotNullSafe:
def __init__(self, model_name: str, field_name: str) -> None: ...
BackfillColumn runs a loop of bounded UPDATEs (at most batch_size rows per statement, repeated
until none remain) instead of one giant UPDATE across the whole table. value is either a literal or a
zero-argument callable; the callable is evaluated once (not once per row), so it's for "the value to
use for this migration run" (e.g. a timestamp captured at run time), not a per-row-derived value — it
can't reference another column. It only changes data, so state_forward() is a no-op, and it's not
meaningfully reversible (the NULLs it overwrote aren't recoverable) — migrate to an earlier migration
refuses to unapply it. field_name may name a foreign key by its relation name ("author") as well as
its column field ("author_id"); value is then the related row's primary key (a model instance is
accepted too). A name without a single database column raises ConfigurationError. A batch picks its
rows by the primary key — every column of a composite one — and, for a model without one, by the row's
own name in the database (ctid on PostgreSQL, rowid on SQLite); a database with no such name raises
ConfigurationError for a model without a primary key. In a migration run outside a transaction both
refusals come before any of its operations runs.
AlterColumnNotNullSafe makes a column NOT NULL after checking that no row holds NULL there: a row
that does raises hare.exceptions.ConfigurationError naming the model and field and suggesting
BackfillColumn, instead of the database's NOT NULL violation — in a migration with atomic = False
as the cause (__cause__) of the PartiallyAppliedMigrationError a failure of such a migration raises
once a statement of it has run, as it is when it is refused before the first one. How it does it:
- PostgreSQL, migration with
atomic = False— the table is never scanned while it is locked: aCHECK (column IS NOT NULL)is addedNOT VALID(no scan), validated withVALIDATE CONSTRAINT(a scan that doesn't block writes), the column is set NOT NULL — PostgreSQL reads it off the validated check instead of the rows — and the check is dropped. ANULLfound by the validation drops the check again before the error. - PostgreSQL, atomic migration —
LOCK TABLE ... IN SHARE ROW EXCLUSIVE MODEfor the rest of the transaction, theNULLcheck, thenALTER COLUMN ... SET NOT NULL: noNULLslips in between, but writes wait while the table is scanned (theset_not_null_scans_tablerisk). - SQLite — the
NULLcheck, then the table rebuilt with the column NOT NULL. - ClickHouse — the
NULLcheck, then the column altered the wayAlterFieldalters it.
Pattern 1: adding a required column to a big table¶
Splitting this into three separate migrations means no single migration blocks the table for the full duration of the bulk backfill:
# 0004_add_status_nullable.py
class Migration(Migration):
operations = [
AddField("Loan", "status", CharField(max_length=20, null=True)),
]
# 0005_backfill_status.py
class Migration(Migration):
dependencies = [("models", "0004_add_status_nullable")]
atomic = False
operations = [
BackfillColumn("Loan", "status", value="pending", batch_size=5000),
]
# 0006_status_not_null.py
class Migration(Migration):
dependencies = [("models", "0005_backfill_status")]
atomic = False
operations = [
AlterColumnNotNullSafe("Loan", "status"),
]
Pattern 2: renaming a column via dual-write¶
There's no RenameColumnSafe operation — a real zero-downtime rename is a 4-step recipe built from
existing operations (AddField/RemoveField) plus BackfillColumn, with an application-code dual-write
period in between that's not hare's concern at all:
- Expand — add the new column; old readers/writers stay unaffected:
- Backfill — populate the new column for existing rows.
BackfillColumnfits when every row gets the same value (a shared default/placeholder); when the new column has to carry each row's own value copied from the old one, use aRunPythondata migration issuing a batchedUPDATE ... SET reader_email = email WHERE reader_email IS NULLinstead —BackfillColumnonly ever writes one value across a whole batch, it can't reference another column per row.
async def backfill_reader_email(apps, schema_editor):
while True:
rowcount, _ = await schema_editor.client.execute(
'UPDATE "loan" SET "reader_email" = "email" '
'WHERE "id" IN (SELECT "id" FROM "loan" WHERE "reader_email" IS NULL LIMIT 5000)'
)
if not rowcount:
break
operations = [RunPython(backfill_reader_email, RunPython.noop)]
- Dual-write — deploy application code that writes both columns for a while, so old and new readers
both see current data (a plain
Model.save()override, or the equivalent wherever rows get written):
class Loan(Model):
email = fields.CharField(max_length=255)
reader_email = fields.CharField(max_length=255, null=True)
async def save(self, *args, **kwargs):
self.reader_email = self.email
await super().save(*args, **kwargs)
- Contract — once every reader/writer has shipped and every row has been backfilled, remove the old column the way pattern 5 does.
Each step ships and bakes in production before the next one runs — that's what makes the whole thing zero-downtime, not any machinery inside hare itself.
Pattern 3: adding an index¶
CREATE INDEX CONCURRENTLY builds the index while the table is written to; it can't run inside a
transaction, so the migration is not atomic:
class Migration(Migration):
dependencies = [("models", "0006_status_not_null")]
atomic = False
operations = [
AddIndex("Loan", Index(fields=("status",), name="loan_status"), concurrently=True),
]
A build that fails — a duplicate under a unique index, a cancelled statement — leaves the index in the
database INVALID: kept, used by no query, enforcing nothing. hare drift reports it as differing from
the declared index and inspectdb flags it with a # TODO; the migration's AddIndex run again fails
on its name. Drop it first (RunSQL("DROP INDEX CONCURRENTLY IF EXISTS loan_status") in a
non-atomic migration, or by hand), then migrate again.
Pattern 4: adding a constraint¶
A CHECK constraint or a foreign key is added unvalidated — only new and updated rows are checked, nothing
is scanned — and the existing rows are checked by ValidateConstraint in a later migration, with a lock
that doesn't block writes:
# 0008_constraints.py
operations = [
AddConstraint("Loan", CheckConstraint(check=Q(fee__gte=0), name="loan_fee_positive"), not_valid=True),
AddField("Loan", "branch", ForeignKeyField("models.Branch", null=True, db_index=False), not_valid=True),
]
# 0009_validate_constraints.py
operations = [
ValidateConstraint("Loan", "loan_fee_positive"),
ValidateConstraint("Loan", "fk_loan_branch__4b2b797a1ec4"), # the foreign key's name, as the database shows it
]
A unique constraint takes over a unique index built concurrently — UNIQUE USING INDEX renames the index
to the constraint's name and checks nothing again:
class Migration(Migration):
atomic = False
operations = [
AddIndex("Loan", Index(fields=("number",), name="loan_number_unique", unique=True), concurrently=True),
AddConstraint(
"Loan", UniqueConstraint(fields=("number",), name="loan_number_unique"), using_index="loan_number_unique"
),
]
The model declares only the constraint: using_index takes the index out of the model's Meta.indexes
in the migration state.
Pattern 5: removing a field or a model¶
The field leaves the models first; the column is dropped once no running code reads it:
# 0010_forget_legacy_code.py - deployed with the code that no longer has the field
operations = [
SeparateDatabaseAndState(state_operations=[RemoveField(model_name="Loan", name="legacy_code")]),
]
# 0011_drop_legacy_code.py - after the deployment
safety_exemptions = [MigrationRiskCode.RUN_SQL]
operations = [
RunSQL('ALTER TABLE "loan" DROP COLUMN "legacy_code"', reverse_sql=RunSQL.noop),
]
A model the same way: DeleteModel("Loan", state_only=True) first, then DROP TABLE by RunSQL. On
SQLite, drop the column before another change of that table rebuilds it — a rebuild keeps only the
columns the migration state knows (see RemoveField).