Skip to content
Software architecture6 min read

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.

Part ofSaaS & PaaS Product Engineering

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.

MomentOld codeNew codeSingle-deploy rename
Before deployrunning—works
Mid deployrunningrunningone version is broken
After deploy—runningworks
After rollbackrunning—broken — column is gone

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.

  1. Expand: add the new column, nullable, with no constraint. Nothing reads it yet. This is always safe.
  2. Dual-write: deploy code that writes both columns and still reads the old one. Rollback remains safe.
  3. Backfill: copy historical rows in batches, throttled, outside the deploy. Resumable and idempotent.
  4. Switch reads: deploy code that reads the new column. The old one is still written, so rollback still works.
  5. Contract: in a later release, stop writing the old column, then drop it once you are confident.
sql
-- Release 1 — expand. Instant, no table rewrite, no lock of consequence.
ALTER TABLE invoices ADD COLUMN customer_reference text;

-- Release 3 — backfill in bounded batches so no single statement
-- holds locks or bloats WAL. Re-runnable after an interruption.
UPDATE invoices
SET    customer_reference = legacy_customer_ref
WHERE  customer_reference IS NULL
  AND  id IN (
         SELECT id FROM invoices
         WHERE customer_reference IS NULL
         ORDER BY id
         LIMIT 5000
       );

-- Release 5 — contract, only once nothing reads the old column.
ALTER TABLE invoices DROP COLUMN legacy_customer_ref;

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.

OperationLock behaviour
ADD COLUMN (nullable, no default)Brief metadata lock — safe
ADD COLUMN with constant defaultMetadata-only on PG 11+ — safe
CREATE INDEXBlocks writes for the whole build — use CONCURRENTLY
CREATE INDEX CONCURRENTLYNo write block, but cannot run inside a transaction
ADD CONSTRAINT ... NOT VALIDBrief lock; validate separately afterwards
ALTER COLUMN TYPEUsually a full table rewrite — treat as a data migration
DROP COLUMNBrief metadata lock, but breaks any code still selecting it
sql
-- Fail fast rather than queueing behind a slow transaction.
SET lock_timeout = '3s';
SET statement_timeout = '30s';

-- Indexes on a live table must be built concurrently, which means
-- this statement cannot be wrapped in a transaction.
CREATE INDEX CONCURRENTLY idx_invoices_tenant_created
  ON invoices (tenant_id, created_at DESC);

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.

  1. Restore a recent production backup into an isolated environment — which doubles as a restore rehearsal you needed anyway.
  2. Run the migration and record wall-clock duration and lock waits.
  3. Run the rollback path and confirm it actually works.
  4. 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.

Want this applied to your systems?

Most of what is described here begins as an assessment: a fixed-scope review producing a prioritised written report you can act on with any supplier.

Prefer email? hello@kornspire.com