Files
upstream-zulip/docs/subsystems/schema-migrations.md
upstream-mirror 64898b2c79
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
2026-09-15 15:05:30 +05:00

717 lines
30 KiB
Markdown

# Schema migrations
Zulip uses the [standard Django system for doing schema
migrations](https://docs.djangoproject.com/en/5.0/topics/migrations/).
There is some example usage in the [new feature
tutorial](../tutorials/new-feature-tutorial.md).
This page documents the conventions and constraints that apply to
writing schema migrations for Zulip. Many of those constraints come
from how Zulip Cloud deploys, which is described in the next section
and which most of the rules below trace back to.
## How Zulip Cloud deploys migrations
> **A migration must be safe to apply with the previous release's
> application code still running, and the previous release's code
> must continue to work correctly against the new schema until
> production deploys.**
That rule follows from a few facts about how Zulip Cloud is
operated. Contributors writing migrations should internalize
these before writing anything.
- **Staging and production share a single database.** There is one
PostgreSQL cluster. Staging is a separate set of application
servers (running internal-testing traffic, not live customer
traffic), but it points at the same database production uses.
- **Staging deploys first.** Migrations are applied when staging
deploys, against the shared database. Production then deploys
some time later — typically about half an hour, occasionally
longer — running the new code against the already-migrated
database.
- **During that window, production runs the previous release's code
against the new schema.** Old code must continue to read, insert,
and update successfully against the migrated database the whole
time.
- **Staging runs migrations against production-scale data.** This
is a real safety net: a migration that's too slow, holds locks for
too long, or has any other operational problem will manifest at
staging-deploy time, on the actual database. But because the
database is shared, those problems are also a live production
issue while they're happening. The bar is to design the migration
correctly up front, not to discover problems on staging.
- **The deploy tooling can choose which commits go to staging and
production independently.** This gives the deploy operator levers:
hold a risky migration back from production for a bit, deploy a
schema change ahead of the code that uses it, or skip a migration
entirely. The cleanest version of this would be reverting a
specific commit, but in practice migrations are not always split
cleanly enough to make that easy, so we also use a stub-out /
`migrate --fake` workflow described in
[When a migration can't be online-safe](#when-a-migration-cant-be-online-safe).
This isn't only about the staging/production shared database.
Cloud's Django processes also restart in a rolling fashion at
deploy time, so even within a single deploy there's a window
during which some processes are running the old code and some
the new — and old and new code must both function against the
live schema. (Self-hosted deploys typically stop the server, run
migrations, and start it back up, so neither constraint applies
there — but Cloud inherits both.)
If you can't satisfy the rule above, the migration needs to be
deferred until the dependent code is in production; see [When a
migration can't be online-safe](#when-a-migration-cant-be-online-safe).
## The simple case
If your migration just reflects new fields you've added to
`zerver/models/*.py`, rebase your branch first, then run
`./manage.py makemigrations`, rename the generated file if
Django's auto-picked name is awkward, run `tools/provision` to
apply it locally, and commit with the migration file added.
For migrations that need custom Python to transform existing
data, see [Writing RunPython
migrations](#writing-runpython-migrations); for migrations on
large tables, see [Making large migrations
work](#making-large-migrations-work).
## Working with the migration graph
### Numbering conflicts across branches
If you've done your schema change in a branch, and meanwhile another
schema change has taken place, Django will now have two migrations
with the same number. There are two easy ways to fix this:
- If your migrations were automatically generated using `manage.py
makemigrations`, a good option is to just remove your migration and
rerun the command after rebasing. Remember to `git rebase` to do
this in the commit that changed `models/*.py` if you have a
multi-commit branch.
- If you wrote code as part of preparing your migrations, or prefer
this workflow, you can run `./tools/renumber-migrations`, which
renumbers your migration(s) and fixes up the "dependencies"
entries. It updates the working tree for you (you still need to
`git add` the result).
`./tools/test-migrations` checks that the migration graph is
consistent with the current models and flags auto-named
migrations that should be renamed. It runs as part of `test-all`
and is the first thing to run when sorting out a numbering or
graph problem.
### Release branches
When a release branch needs a migration, but `main` already has new
migrations, the migration graph must fork. The migration should be
named and numbered in `main` to follow the usual sequence, but its
dependency should be set to the last migration that exists on the
release branch. This should be followed, on `main`, by a migration
which merges the two resulting tips; you can make such a merge with
`manage.py makemigrations --merge`. Structuring the fork this way
makes the migration safe to cherry-pick back into the stable release
branch. See Django's [Controlling the order of
migrations](https://docs.djangoproject.com/en/6.0/howto/writing-migrations/#controlling-the-order-of-migrations)
for the underlying `dependencies` / `run_before` mechanics, or
`zerver/migrations/0754_merge_20251014_1855.py` for an example of
the merge migration that joins the forked tips.
## Adding columns safely
The shared-database deploy model means that adding a column requires
some care: production's old code keeps running `INSERT` and `UPDATE`
statements against the table that don't list the new column.
### Always set a database default
Set `db_default=` (in addition to Django's `default=`) on any new
**non-nullable** column introduced via `AddField`. Without
`db_default`, PostgreSQL has no
fallback when old code's `INSERT` omits the column, and those
inserts will fail the `NOT NULL` constraint in production until
the new code is deployed. (For nullable columns this isn't a
concern — the column simply takes `NULL` for omitted inserts.)
```python
migrations.AddField(
model_name="realmauditlog",
name="scrubbed",
field=models.BooleanField(db_default=False, default=False),
),
```
`default=` is the Django-level default used by ORM `.create()`,
`save()`, etc. when the new code runs; `db_default=` is the
PostgreSQL-level default that protects old code during the deploy
window. Both are needed.
### Per-row defaults: nullable, backfill, NOT NULL
Some new columns can't take a static default — for example, a
foreign key to a per-realm `NamedUserGroup`, or a value derived
from other columns on the same row. The standard shape for these
is three migrations:
1. **Add the column nullable** (`AddField(..., null=True)`). Old
code is unaffected because it doesn't reference the column;
any inserts (from old or new code) leave the column `NULL`.
2. **Backfill the pre-existing rows** in batches with a
`RunPython` data migration that has `atomic = False`. See
[Making large migrations work](#making-large-migrations-work)
for the batching patterns.
3. **Flip the column to `NOT NULL`** with an `AlterField` that
redeclares the field without `null=True` (Django defaults
non-`null` field types to `NOT NULL`, so an explicit
`null=False` is rarely written).
A canonical recent example is the trio
`0710_realm_topics_policy.py` →
`0711_set_default_value_for_realm_topics_policy.py` →
`0712_alter_realm_topics_policy.py`. Many of the per-stream and
per-realm group-permission settings follow this pattern as well.
Django's [Migrations that add unique
fields](https://docs.djangoproject.com/en/6.0/howto/writing-migrations/#migrations-that-add-unique-fields)
documents the same structural pattern.
For this sequence to be safe on the shared database, two
properties of the application code are non-negotiable:
- **The code that populates the column must be in production before
the `NOT NULL` migration runs.** It must populate the column on
every code path that creates a row. If any path is missed,
production will create a `NULL` row and the constraint will reject
the insert, failing the request with a 500.
- **The code that reads the column must tolerate `NULL` until the
backfill is complete.** Almost always this means simply not
reading the column until step 3 lands.
These are properties of the application code change, not of the
migrations themselves. Your job as a contributor is to write the
code so these properties hold, audit every relevant code path,
and make the backfill idempotent (`filter(column=None)`) so it's
safe to re-run.
The application code and all three migrations typically ship in
the same PR. The deploy operator is responsible for sequencing
them across deploy cycles so that the populating code reaches
production before the `NOT NULL` migration is applied to the
shared database — choosing which commits to ship to staging vs.
production, deferring the `NOT NULL` migration if needed, or
using the [stub-out / `migrate --fake`
workflow](#when-a-migration-cant-be-online-safe). Call this
sequence out in the PR description so the operator knows to
handle it.
## Indexes
### Adding indexes
Django's regular `AddIndex` operation (corresponding to `CREATE
INDEX` in SQL) takes a lock that blocks writes to the affected
table for the duration of the build. On large tables (`Message`,
`UserMessage`, `Subscription`, `RealmAuditLog`, etc.) that lock
can last long enough to affect production. Use
`AddIndexConcurrently` (corresponding to `CREATE INDEX
CONCURRENTLY`) for any index on those tables, and as a default
for any index on a table large enough that the lock would be
noticeable.
`AddIndexConcurrently` requires `atomic = False` on the
migration class — `CREATE INDEX CONCURRENTLY` cannot run inside
a transaction.
For an index on plain table columns, `CREATE INDEX` populates
the index relation's own size/row statistics and the planner
can already estimate selectivity from existing column
statistics — no follow-up `ANALYZE` is needed. For **expression
indexes** (e.g., `Upper(subject)`), PostgreSQL only gathers
per-expression statistics in `pg_statistic` when `ANALYZE`
runs, so add a `RunSQL("ANALYZE zerver_<table>")` after the
`AddIndexConcurrently` so the planner has the distribution info
it needs to use the new index.
### Renaming indexes
When the index naming scheme changes, prefer `RenameIndex` to
dropping and rebuilding (see
`0693_add_conditional_indexes_for_topic.py`, which also adds
expression indexes; note that it mistakenly omits the `ANALYZE`
that those would need).
### Dropping indexes
To drop an index `CONCURRENTLY` — for example, the auto-created
index on a foreign key whose `db_index` you're setting to
`False` — use
[SeparateDatabaseAndState](#separating-database-and-state).
Django's `AlterField(db_index=False)` issues a regular (locking)
`DROP INDEX`; the separate-state pattern lets you keep Django's
model tracking in sync while doing the actual drop concurrently.
## Constraints on large tables
Django's `AddConstraint` takes an `ACCESS EXCLUSIVE` lock on the
table and validates the constraint against every row before
returning, which can hold writes for a long time on a large
table. Standard Django doesn't expose the PostgreSQL features
that avoid this, so the patterns below all reach for `RunSQL` /
`RunPython` and use [SeparateDatabaseAndState](#separating-database-and-state)
to keep Django's model state in sync with what we did at the DB
level.
### `UNIQUE` constraints
Build a unique index `CONCURRENTLY`, then promote it to a table
constraint via `ALTER TABLE ... ADD CONSTRAINT ... UNIQUE USING
INDEX` (an instant metadata operation). See
`0794_alter_directmessagegroup_recipient_and_more.py` for the
canonical implementation, including the introspection-based
helper that handles the case where a previous run created the
index but crashed before promoting it. The state side is
whatever Django would have generated for the model change —
e.g., `AlterField` to `OneToOneField` if the uniqueness is on a
foreign key, or `AddConstraint(UniqueConstraint(...))` for a
non-foreign-key column.
### `CHECK` constraints
Declare the constraint in the model's `Meta.constraints` first
(without it, Django's `makemigrations --check` will see drift
when it compares state to model). Then write a single migration
that uses `SeparateDatabaseAndState` to do the DB work and
record the model-state change at the same time:
```python
migrations.SeparateDatabaseAndState(
database_operations=[
migrations.RunSQL(
"ALTER TABLE zerver_foo ADD CONSTRAINT bar CHECK (...) NOT VALID;",
reverse_sql="ALTER TABLE zerver_foo DROP CONSTRAINT bar;",
),
migrations.RunSQL(
"ALTER TABLE zerver_foo VALIDATE CONSTRAINT bar;",
reverse_sql=migrations.RunSQL.noop,
),
],
state_operations=[
migrations.AddConstraint(
model_name="foo",
constraint=models.CheckConstraint(...),
),
],
)
```
The `NOT VALID` add records the constraint and enforces it for
new rows without scanning the existing table; `VALIDATE
CONSTRAINT` then scans without holding `ACCESS EXCLUSIVE`. Both
can run in a single (atomic) migration.
### Foreign key columns
Adding a foreign key column the default way triggers an `ACCESS
EXCLUSIVE` lock to validate the constraint. Avoid that with two
migrations:
1. **Add the column with `db_constraint=False`.** A plain
`AddField` — Django emits the column-only DDL, no foreign key
constraint, no `SeparateDatabaseAndState` wrapping needed:
```python
migrations.AddField(
model_name="foo",
name="bar",
field=models.ForeignKey(
db_constraint=False,
on_delete=models.CASCADE,
to="zerver.bar",
),
)
```
2. **Wrap the constraint creation in `SeparateDatabaseAndState`.**
The DB side adds the constraint `NOT VALID` and validates it;
the state side flips `db_constraint` back to its default
(`True`) so Django's model tracking matches:
```python
migrations.SeparateDatabaseAndState(
database_operations=[
migrations.RunSQL(
"ALTER TABLE zerver_foo ADD CONSTRAINT zerver_foo_bar_id_fk "
"FOREIGN KEY (bar_id) REFERENCES zerver_bar(id) NOT VALID;",
reverse_sql="ALTER TABLE zerver_foo DROP CONSTRAINT zerver_foo_bar_id_fk;",
),
migrations.RunSQL(
"ALTER TABLE zerver_foo VALIDATE CONSTRAINT zerver_foo_bar_id_fk;",
reverse_sql=migrations.RunSQL.noop,
),
],
state_operations=[
migrations.AlterField(
model_name="foo",
name="bar",
field=models.ForeignKey(
on_delete=models.CASCADE,
to="zerver.bar",
),
),
],
)
```
## Writing RunPython migrations
A small data migration looks like this (adapted from
`0795_rename_old_twitter_profile_fields.py`):
```python
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
def update_twitter_to_x(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
CustomProfileField = apps.get_model("zerver", "CustomProfileField")
# Copied from zerver/models/custom_profile_fields.py
EXTERNAL_ACCOUNT = 7
CustomProfileField.objects.filter(
name="Twitter", field_type=EXTERNAL_ACCOUNT, field_data=...
).update(name="X username", ...)
class Migration(migrations.Migration):
dependencies = [
("zerver", "0794_alter_directmessagegroup_recipient_and_more"),
]
operations = [
migrations.RunPython(
update_twitter_to_x,
reverse_code=migrations.RunPython.noop,
elidable=True,
),
]
```
The bulk `.filter(...).update(...)` is fine for a small table
like `CustomProfileField`; for migrations that touch a large
table, see [Making large migrations
work](#making-large-migrations-work).
### Hygiene rules
- **Use `apps.get_model("zerver", "Foo")` to access models.** A
Zulip server admin runs migrations using whatever code they've
checked out, which can be thousands of commits newer than the
migration. Importing `Foo` from `zerver.models` would mean the
migration sees a model definition that didn't exist when it was
written. `apps.get_model` returns the model as the migration
graph defines it at that point in time. This works for ORM
operations like `Foo.objects.filter(...)`; it doesn't expose
Python-only methods or properties, so don't reach for those.
- **Don't import other code from `zerver` either.** If you need a
constant, an enum value, or a small helper, copy it into the
migration file with a comment noting where it came from. A linter
rule enforces this. The reason is the same as for models: the
imported code may have changed beyond recognition by the time the
migration runs on a self-hosted server.
- **Set `reverse_code=migrations.RunPython.noop`** on data migrations
unless you have a real inverse to provide. This is the project
convention; the alternative (Django's default when `reverse_code` is
unset) is to mark the migration irreversible, which is rarely the
right semantics for a data migration whose forward effect just
rewrites or fills in rows. The rare RunPython migrations which are
truly irreversible should leave `reverse_code` unset, with an
explicit comment as to why.
- **Set `elidable=True`** on data migrations whose effect won't be
needed once `squashmigrations` collapses old migrations.
- **Make the data migration idempotent.** A migration may be
interrupted partway through (operator action, restart, network
blip) and re-run from the beginning. Filter your queryset on the
"not yet migrated" condition (`field=None`,
`subject__iexact="(no topic)"`, etc.) so re-running is a no-op for
rows already done.
- **Be defensive about settings that may be removed.** If your
migration reads a Django setting whose value drives behavior, use
`getattr(settings, "FOO", default)` so the migration still runs
on a future codebase where the setting has been deleted (see
`0743_realm_require_e2ee_push_notifications.py`).
- **Use `psycopg2.sql.SQL` / `Identifier` for table and column
names** in any raw SQL — never string-format them. The
migration may end up sharing helpers or being adapted later;
building safe-by-construction SQL avoids introducing an
injection vector.
### Automated testing
Zulip has support for writing automated tests for your database
migrations, using the `MigrationsTestCase` test class. This system
is inspired by [a great blog post][django-migration-test-blog-post]
on the subject.
We have integrated this system with our test framework so that if
you use the `use_db_models` decorator, you can use some helper
methods from `test_classes.py` and friends from inside the tests
(which is normally not possible in Django's migrations framework).
If you find yourself writing logic in a `RunPython` migration, we
highly recommend adding a test using this framework. We may end up
deleting the test later (they can get slow once they are many
migrations away from current), but it can help prevent disaster
where an incorrect migration messes up a database in a way that's
impossible to undo without going to backups.
[django-migration-test-blog-post]: https://www.caktusgroup.com/blog/2016/02/02/writing-unit-tests-django-migrations/
### Pending trigger events when mixing schema and data changes
Mixing data changes (`RunPython`) and `ALTER TABLE` operations in a
single atomic migration can produce:
```text
django.db.utils.OperationalError: cannot ALTER TABLE "table_name" because it has pending trigger events
```
PostgreSQL prohibits schema changes if there are deferred trigger
events still pending. Django creates foreign-key constraints as
`DEFERRABLE INITIALLY DEFERRED` in PostgreSQL by default, so foreign
key constraint checks are queued until commit time. You cannot run
`ALTER TABLE` while those checks are still queued.
Resolve this by making the migration non-atomic, splitting it into
two migration files (recommended), or replacing the `RunPython`
logic with pure SQL.
## Making large migrations work
For migrations that touch many rows or hold locks for non-trivial
amounts of time, the goal is to keep individual lock durations
short and total wall-clock time bounded.
### Use `do_batch_update` when it fits
For straightforward bulk updates over an entire table —
set a column to a value or SQL expression on every row —
`zerver/lib/migrate.py`'s `do_batch_update` helper handles all
the iteration for you:
```python
from zerver.lib.migrate import do_batch_update
def update_is_channel_message(apps, schema_editor):
Message = apps.get_model("zerver", "Message")
with connection.cursor() as cursor:
do_batch_update(
cursor,
Message._meta.db_table,
[SQL("is_channel_message = (SELECT type = 2 ...)")],
)
class Migration(migrations.Migration):
atomic = False
operations = [
migrations.RunPython(update_is_channel_message, ...),
]
```
It computes the table's `MIN(id)` and `MAX(id)`, walks the table
in batches of 10000 by default, sleeps briefly between batches,
and extends the upper bound if new rows have arrived during the
run, so rows inserted concurrently aren't missed. Reach for it first; only write a
hand-rolled loop when the helper doesn't fit (per-realm
scoping, multi-step batches, or a non-`UPDATE` operation). See
`0691_backfill_message_is_channel_message.py` for a complete
example.
### Push logic into the database
A `RunPython` loop that reads rows in Python, computes a value,
and writes them back is almost always slower and lock-heavier
than a single SQL `UPDATE ... FROM (subquery)` per chunk. When
you can, express the migration as SQL — see
`0705_stream_subscriber_count_data_migration.py`, which uses one
`UPDATE ... FROM (subquery)` per realm to backfill stream
subscriber counts.
### Iterate with `.iterator()`
Django querysets cache their entire result set in memory by
default. Whenever a migration loops over a queryset that could be
large, use `.iterator()` to stream rows one at a time:
```python
for realm in Realm.objects.all().iterator():
...
```
This applies to any per-row processing — emoji files, audit log
entries, individual realms — not just to walking large tables.
### Writing a hand-rolled batch loop
When `do_batch_update` doesn't fit, write the batch loop
yourself. The skeleton:
```python
class Migration(migrations.Migration):
atomic = False
...
def backfill(apps, schema_editor):
while lower_bound <= max_id:
print(f"Processing {lower_bound}/{max_id}...")
with transaction.atomic():
Stream.objects.filter(
id__range=(lower_bound, lower_bound + BATCH_SIZE - 1),
...
).update(...)
lower_bound += BATCH_SIZE
```
See `0765_set_default_can_create_topic_group.py` for a complete
example. The considerations:
- **Set [`atomic = False`](https://docs.djangoproject.com/en/6.0/howto/writing-migrations/#non-atomic-migrations)
on the migration class** and wrap each batch in its own
`transaction.atomic()`. Django's default per-migration
transaction would hold row locks for the entire
run; per-batch commits release locks between iterations.
- **Batch by ID range**, not `OFFSET`. `id__range=(lower, upper)`
is O(batch size) per page; `OFFSET` makes each subsequent page
skip more rows. ID ranges aren't dense (gaps from deletes,
scattered filter matches), so the number of rows touched per
batch will vary, sometimes considerably — that's not an issue;
the goal is to bound how many rows a single transaction holds
locks on, not to do equal-sized chunks of work.
- **For tables with `realm_id`, iterate per realm.** Processing
one realm at a time keeps each transaction's lock footprint
scoped to that realm and limits the blast radius of any single
batch. See `0696_rename_no_topic_to_empty_string_topic.py` and
`0782_delete_unused_anonymous_groups.py`.
- **A few thousand rows is a reasonable batch size**; tune based
on per-row cost. If a batch takes more than a few seconds,
either the batch is too big or the work belongs in SQL.
### Separating database and state
Occasionally the SQL Django would auto-generate for a model
change isn't the SQL you want to run — most often because
Django's standard operations don't have a concurrent variant
(e.g., `AlterField(db_index=False)` issues a locking `DROP
INDEX`). `SeparateDatabaseAndState` lets you tell Django to
update its model-state tracking using one set of operations
while you actually execute a different set against the database:
```python
migrations.SeparateDatabaseAndState(
database_operations=[migrations.RunPython(...)],
state_operations=[migrations.AlterField(...)],
)
```
The two sides end up in agreement on the schema — Django's view
matches the database — but the DB-side change runs as your
custom SQL or `RunPython` rather than as Django's auto-generated
DDL. See
`0791_alter_archivedusermessage_user_profile_and_more.py` for a
recent example: the state side does `AlterField(db_index=False)`
on a foreign key, while the database side runs `DROP INDEX
CONCURRENTLY` on the underlying index.
### Multi-step rollouts: replacing a column
This pattern is only needed because Zulip Cloud migrates online —
a stop-the-server-and-migrate self-hosted upgrade can rename or
swap a column in a single migration without coordination. For a
column replacement on a large table on Cloud, split the
transition across multiple deploys that are each individually
correct against the previous deploy's code:
1. Add the new column (with `db_default`) and start writing to it
in addition to the old column. Don't read from it yet.
2. Backfill the new column from the old column.
3. Confirm that the two columns contain identical data.
4. Switch readers to the new column. Stop writing to the old
column.
5. Drop the old column.
Each deploy is safe against the previous deploy's running code.
## When a migration can't be online-safe
Sometimes a migration is genuinely incompatible with the old code
that will be running against the database during the deploy
window. The common cases are:
- **Dropping or renaming a column, table, or model that the
previous release's code still references.** Old code's queries
point at a schema element that no longer exists, so they fail
the moment the migration applies.
- **A schema reshape where the intermediate state can't be read
correctly by either the old or new code.** For example,
restructuring a many-to-many relationship (Django's
[worked example](https://docs.djangoproject.com/en/6.0/howto/writing-migrations/#changing-a-manytomanyfield-to-use-a-through-model)
for swapping in a `through` model is a useful reference), or
splitting one column's data across two new columns where the
previous release expects the original column to be authoritative.
Migrations like these can't simply land on `main` and ship: applying
them to the shared database while production still runs the old
code would break production. The deploy operator handles them with
a stub-out / `migrate --fake` workflow:
1. The migration is committed to `main` and runs in dev and CI as
normal.
2. In Cloud's internal deploy branch, the migration is replaced
with a stub (a no-op) until the dependent code has reached
production.
3. Deploy the new code to production.
4. Run `manage.py migrate --fake zerver <previous_migration>` to mark
the stub as unapplied.
5. Restore the real migration.
6. Run `manage.py migrate zerver <target_migration>` to apply it for
real.
7. Run `manage.py migrate --fake zerver <last_migration>` to move
the migration state back to the tip.
If you write a migration that needs this treatment, **call it out
explicitly in the commit** so the operators are aware when reviewing
the pending migrations for a deploy.
The stub-out workflow exists because migrations are not always
split out from the application code that depends on them in
current practice. If they were each in their own commit — schema
change, backfill, and `NOT NULL` flip standalone — the operator
could revert or reorder a single commit instead of editing
migration files in place. For multi-step migrations where you
anticipate the operator may need to defer pieces, keeping each
migration in its own commit is worth the extra discipline.
## Local schema rebuilds
If you follow the processes described above, `tools/provision` and
`tools/test-backend` should detect any changes to the declared
migrations and run migrations on (`./manage.py migrate`) or rebuild
the relevant database automatically as appropriate.
Notably, both `manage.py migrate` (`git grep post_migrate.connect`
for details) and restarting `tools/run-dev` will flush `memcached`,
so you shouldn't have to worry about cached objects from a previous
database schema.
While developing migrations, you may accidentally corrupt your
databases while debugging your new code. You can always rebuild
these databases from scratch:
- Use `tools/rebuild-test-database` to rebuild the database used
for `test-backend` and other automated tests.
- Use `tools/rebuild-dev-database` to rebuild the database used in
[manual testing](../development/using.md).