Skip to content

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.

config = {**HARE_CONFIG, "migrations": {"safety": {"large_table_rows": 1_000_000}}}

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=...) — without client, 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: a CHECK (column IS NOT NULL) is added NOT VALID (no scan), validated with VALIDATE 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. A NULL found by the validation drops the check again before the error.
  • PostgreSQL, atomic migration — LOCK TABLE ... IN SHARE ROW EXCLUSIVE MODE for the rest of the transaction, the NULL check, then ALTER COLUMN ... SET NOT NULL: no NULL slips in between, but writes wait while the table is scanned (the set_not_null_scans_table risk).
  • SQLite — the NULL check, then the table rebuilt with the column NOT NULL.
  • ClickHouse — the NULL check, then the column altered the way AlterField alters 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:

  1. Expand — add the new column; old readers/writers stay unaffected:
operations = [AddField("Loan", "reader_email", CharField(max_length=255, null=True))]
  1. Backfill — populate the new column for existing rows. BackfillColumn fits 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 a RunPython data migration issuing a batched UPDATE ... SET reader_email = email WHERE reader_email IS NULL instead — BackfillColumn only 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)]
  1. 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)
  1. 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).