Schema migrations without taking every tenant down
In a shared-schema product, one bad migration affects every customer at once. The discipline that prevents it is expand-and-contract: add the new shape, write to both, backfill, switch reads, and only remove the old shape in a later release — never in the same one.
Single-tenant software can take a maintenance window. Multi-tenant software generally cannot, because there is no hour that is off-peak for everyone. That constraint rules out the migration style most teams learn first — change the schema, deploy the matching code — and replaces it with something slower and considerably safer.
Why a single deploy cannot do both
During any rolling deploy there is a period, however brief, when old and new application code are both running against the same database. A migration that renames a column breaks whichever version was not expecting it — and on a rollback, the surviving version is the one that is now wrong.
That fourth row is the one that causes incidents. A migration you cannot roll back is a migration you cannot safely deploy, because it removes your only recovery option at exactly the moment you need it.
Expand and contract, in five releases
Splitting a rename across separate releases means every intermediate state is one that both code versions tolerate. It feels laborious for a column rename. It is the reason the rename does not cause an outage.
- Expand: add the new column, nullable, with no constraint. Nothing reads it yet. This is always safe.
- Dual-write: deploy code that writes both columns and still reads the old one. Rollback remains safe.
- Backfill: copy historical rows in batches, throttled, outside the deploy. Resumable and idempotent.
- Switch reads: deploy code that reads the new column. The old one is still written, so rollback still works.
- Contract: in a later release, stop writing the old column, then drop it once you are confident.
Knowing which operations lock
Most migration outages are not caused by long-running work. They are caused by a brief operation that needs an exclusive lock, queuing behind a slow query, and then blocking every subsequent request on that table while it waits.
When every tenant has its own schema
Schema-per-tenant and database-per-tenant trade one blast radius for a different problem: the migration now runs N times, it will fail partway through, and your application must tolerate tenants on two different schema versions simultaneously.
- Record migration state per tenant, so a resumed run skips those already done
- Migrate in batches with a concurrency limit — all tenants at once will saturate the database
- Migrate a canary tenant first, ideally an internal one, and verify before proceeding
- Make the application version-tolerant, since a mixed-version window is guaranteed rather than exceptional
- Alert on tenants left behind; a tenant stuck two versions back is a silent bug waiting to surface
This is the operational cost that the tenancy-model decision was really about. It is entirely manageable — but it has to be built, and it is considerably more work than a single migration against a shared schema.
Rehearse against production-shaped data
A migration that completes in 200 milliseconds against a development database with 50 rows tells you nothing about its behaviour against 40 million. Rehearse on a restored production snapshot, and measure rather than estimate.
- Restore a recent production backup into an isolated environment — which doubles as a restore rehearsal you needed anyway.
- Run the migration and record wall-clock duration and lock waits.
- Run the rollback path and confirm it actually works.
- If duration exceeds your lock timeout, redesign it as a batched background job rather than a migration.
The rule, in one line
Every deployed state must be one that both the previous and the next version of the application can run against. If a migration breaks that property, it is not one migration — it is two releases that have not been separated yet.
Questions
Is expand-and-contract worth it for a small product?
For a product with a maintenance window and forgiving customers, a direct migration is often fine and much faster to ship. The moment you have paying tenants in more than one timezone, there is no safe window left, and the discipline stops being optional. Adopting it before you need it costs little; retrofitting it during an incident costs a great deal.
How do you handle a migration that genuinely needs hours to run?
Do not run it as a migration. Add the new structure in a fast, safe migration, then move the data in a throttled background job that is resumable, idempotent and observable. A schema migration should be a structural change measured in seconds; bulk data movement is a different kind of work with different failure handling.
Should migrations run automatically on deploy?
For expand-phase migrations — additive, fast, reversible — automatic execution on deploy is reasonable and keeps the pipeline simple. Contract-phase migrations that drop columns or tables deserve a deliberate, separately triggered step, because their failure mode is data loss rather than a failed deploy.
What about migrations on managed databases with limited access?
The same principles apply, but verify which operations your provider permits and whether CREATE INDEX CONCURRENTLY is available — some managed tiers restrict it or wrap statements in transactions, which breaks it. Test against the same tier you run in production, not against a local Postgres that behaves differently.