How to Migrate a Live Multi-Tenant SaaS DB With Zero Downtime
For: A CTO or lead engineer at a 50–200-seat B2B SaaS company who needs to migrate a live Postgres multi-tenant database — adding a new column, splitting a table, or moving tenants to isolated schemas — without scheduling a maintenance window their enterprise customers will notice or complain about
To migrate a live multi-tenant SaaS database with zero downtime, run an expand-migrate-contract sequence gated by a per-tenant write-version flag: deploy code that can read both old and new schemas, cut writes over one tenant at a time, backfill only rows tagged with the old version, then drop the old columns. The schema change is never the risky part. The risky part is the dual-write window — the interval where two versions of your application are simultaneously writing to overlapping columns, and you cannot tell which rows are safe to touch without an explicit marker on the row itself.
Most guides skip that detail. They tell you to "dual-write, backfill, then cut over," and leave you to discover — in production, at 2 a.m., for one enterprise tenant — that your backfill job overwrote a write that was in flight when the job started. This playbook is the version we actually run.
When this playbook applies
Use this if all of the following are true:
- You run a B2B SaaS on Postgres (RDS, Aurora, Cloud SQL, or self-managed — all fine) with a shared-schema or shared-database multi-tenant model.
- You have between roughly 50 and a few thousand tenants, at least some of whom are enterprise customers with contractual uptime expectations.
- The change is non-trivial: adding a NOT NULL column with a computed default, splitting a wide table into two, renaming a column that application code reads, or moving specific tenants from a shared schema into isolated schemas.
- You cannot take a maintenance window that any tenant will notice. Read-only windows are also off the table.
If you have five tenants and they're all on Slack with you, just take the window. This playbook has real overhead and is not worth it for small blast radii.
The core idea: expand, dual-write with a version flag, contract
The pattern is expand-contract, but the dual-write phase is gated per tenant, not globally. Every row in the affected table gets a schema_version column (or the tenant row does, if you can safely cut over an entire tenant atomically). Writes and backfills check that flag. Reads fall back to the old columns until the flag flips.
This gives you four properties you cannot get from a global dual-write:
- Rollback is per-tenant, not global. If tenant 47 breaks, you flip its flag back.
- Backfill jobs know exactly which rows are frozen (old version, safe to migrate) and which are live (new version, hands off).
- You can canary the migration on your own internal tenants and a friendly design-partner tenant before touching enterprise accounts.
- Support can answer "is customer X on the new schema yet?" with a single query.
The playbook
Step 1. Add the new schema in additive-only mode
Deploy the new columns, tables, or schemas alongside the existing ones. Nothing reads them yet. Nothing writes to them yet. This is pure DDL.
Rules:
- New columns must be nullable. Never
NOT NULLat this stage, even with a default — on Postgres 11+ a default is metadata-only, but aNOT NULLwithout a default will rewrite the table and lock it. - New indexes go up with
CREATE INDEX CONCURRENTLY. Watch forINVALIDindex state afterward and rebuild if needed. - Add the
schema_versioncolumn to the affected table (or tenants table) with a default of1. Every existing row is version 1. New writes stay version 1 until step 3. - If you're splitting a table, create the new tables empty. Do not add foreign keys yet — those are validated at creation and will lock.
Anti-pattern: adding a generated column that references existing data. It rewrites the table under an ACCESS EXCLUSIVE lock. Compute the value in application code instead.
Done when: the new schema exists in production, the application does not reference it, and your monitoring shows no change in query latency or lock waits.
Step 2. Ship read-old, write-old code that is aware of the new schema
This is the deploy nobody remembers to do separately, and it's the one that saves you. Ship application code that:
- Knows the new columns exist.
- Reads only from the old columns.
- Writes only to the old columns.
- Contains the branching logic for the new path, gated behind a per-tenant flag that is off for every tenant.
The point is to decouple the code deploy from the behavior change. If the deploy itself has a bug — a bad import, a misconfigured feature flag client, a serialization issue — you find out with zero tenants affected. Every tenant is still on the old path.
Anti-pattern: shipping the code deploy and the first tenant cutover in the same release. When something breaks you won't know which change caused it.
Done when: the new code is running on 100% of application instances, the tenant flag table has entries for every tenant set to the old version, and error rates are flat for at least a full business day including your peak-traffic hour.
Step 3. Turn on dual-write for one canary tenant
Pick a tenant. Ideally your own company's internal tenant, or a design partner who knows a migration is happening. Flip that tenant's schema_version to 2.
Now, for that tenant only, the application:
- Writes to both the old and new columns on every INSERT and UPDATE, inside the same transaction.
- Still reads from the old columns. Reads do not change yet.
- Stamps
schema_version = 2on every row it writes.
Two rules that matter more than they sound:
Both writes must be in the same transaction. If you write to the old column, commit, then write to the new column in a separate transaction, a crash between them leaves the row inconsistent. Wrap them. If your ORM makes that hard, drop to raw SQL for this one query.
The new-column write must be idempotent under retry. Application retries, background job retries, at-least-once queue delivery — any of these can replay the write. If the new column is a derived value, make sure recomputing it produces the same result. If it's a counter, use an upsert with a deterministic key.
Watch this tenant for a full business cycle — at least 24 hours, ideally through a weekly batch job if they have one. Compare old and new columns row-by-row for the tenant's data. They should match. Every mismatch is a bug in your dual-write logic that will silently corrupt every other tenant when you scale up.
Anti-pattern: skipping the comparison because "the code looks right." I have never once run a dual-write migration where the first canary comparison came back clean. There is always something — a nullable field defaulting differently, a timezone in a datetime cast, a JSON field with reordered keys.
Done when: a full-table diff between old and new columns for the canary tenant returns zero rows for at least 24 hours of live traffic.
Step 4. Backfill the tenants still on version 1
Now backfill the historical rows for every tenant still on schema_version = 1. This is a background job, run in small batches, with explicit tenant scoping.
The backfill query pattern:
UPDATE affected_table
SET new_col = compute(old_col), schema_version = 1
WHERE tenant_id = $1
AND schema_version = 1
AND id BETWEEN $2 AND $3;Note what this does not do: it does not touch rows with schema_version = 2. Those rows are being actively dual-written by the application. The backfill leaves them alone. This is the entire point of the version flag.
Practical guardrails:
- Batch size of a few thousand rows, not tens of thousands. Long-running UPDATEs generate bloat and hold row locks longer than you want.
- Sleep briefly between batches. Watch replication lag if you have read replicas. If lag exceeds your threshold, pause.
- Run per-tenant, not globally. If a tenant has a huge table, you want to isolate the impact.
- Emit progress metrics. "Tenant 812: 340k of 2.1M rows backfilled" beats "backfill job is running."
Anti-pattern: a single global backfill query without the tenant_id and schema_version guards. It will race with live dual-writes and clobber recent data on the canary tenant.
Done when: for every tenant on version 1, all historical rows have the new column populated, and a random-sample comparison confirms correctness.
Step 5. Roll dual-write forward, tenant by tenant
Now expand. Flip schema_version = 2 for tenants in batches — start with small ones, then mid-market, then enterprise. For each batch:
- Confirm the previous batch has been running clean for at least a few hours.
- Flip the flag inside a transaction.
- Watch error rates, p95 latency, and any tenant-specific dashboards.
- If anything looks off, flip the tenant back to version 1. Because the code path supports both versions, this is a config change, not a deploy.
Enterprise tenants get their own batch, ideally coordinated with a low-traffic window for that specific customer (not for the platform). "We're making a change to your account at 3 a.m. your time, no downtime expected" reads very differently from "the platform will be offline Saturday."
Done when: every tenant is on version 2, dual-writes are happening for the entire fleet, and old and new columns are converged across the whole table.
Step 6. Cut reads over to the new schema
Deploy application code that reads from the new columns. Writes are still dual — old and new. This is a code deploy, not a data change. Roll it out behind a feature flag if your infra supports it, tenant by tenant or percentage-based, so you can revert quickly.
Watch for query plan changes. A new column with a different index or cardinality can shift the planner's decisions. Run EXPLAIN ANALYZE on your top ten queries against the new schema before you ship.
Anti-pattern: assuming the new read path is faster because it's newer. If you split a table, you may now be doing a join where you used to do a scan. Benchmark.
Done when: 100% of read traffic is going through the new columns, error rates are flat, and query latency is within acceptable bounds.
Step 7. Contract — drop the old columns
Ship code that stops writing to the old columns. Let it bake for at least a week. Then, and only then, drop the old columns.
Order matters:
- Deploy: stop writing to old columns.
- Wait. A week minimum. This is your rollback window. If a bug surfaces, you still have the old data intact.
- Drop old indexes first (
DROP INDEX CONCURRENTLY). - Drop the old columns. On large tables,
ALTER TABLE ... DROP COLUMNis metadata-only in Postgres and fast — but the space is not reclaimed until the next VACUUM FULL or pg_repack. - Drop the
schema_versioncolumn last, once you're certain you won't need it for the next migration.
Done when: the old columns are gone, table bloat has been reclaimed, and the next migration can start from a clean baseline.
Failure modes we've seen
The silent dual-write divergence. Old and new columns drift over weeks because of a subtle serialization difference — a JSONB field with keys in a different order, a numeric with different precision. The daily diff job you skipped would have caught it on day one. Run the diff job.
The long-running transaction that spans the flag flip. A background job started before you flipped tenant 47 to version 2, and it's still running, writing only to the old columns. When it commits, the row is version 2 in the flag table but only the old column is populated. Fix: bound your background job runtimes, and re-run the backfill for the affected tenant after the cutover completes.
Replica lag during backfill. Your reporting dashboards, which read from a replica, start returning stale data. Enterprise customers notice. Fix: throttle the backfill on replica lag, not on wall-clock time.
Connection pool exhaustion from dual-writes. Every write now runs two statements in a transaction, so transactions are longer, so connections are held longer. PgBouncer starts queuing. Fix: measure before you start, and bump pool size proactively — do not wait for the paging.
The rollback that isn't. You flipped a tenant from version 2 back to version 1 because of an issue, but rows written while they were on version 2 are now stranded — the app is reading old columns that were never populated for those rows. Fix: your rollback path must include a reverse-backfill that copies new-column values back into old columns for rows written during the version-2 window.
How CodeNicely can help
We've run this pattern for teams where a shared-database multi-tenant model needed to evolve without breaking enterprise SLAs. The closest analog in our own work is GimBooks — a YC-backed accounting SaaS where the underlying data model had to keep changing as the product moved from invoicing into a broader financial platform, without disrupting the small businesses already running their books on it. The constraint there was the same one you're facing: the customers cannot tell that a migration is happening, and every tenant's data has to remain internally consistent even mid-migration.
If you're planning a schema change of this shape and want a second set of eyes on the runbook, or engineering capacity to build the dual-write instrumentation and backfill tooling, that's the kind of work our digital transformation and custom software teams take on. We work with full IP transfer and no vendor lock-in, so the tooling we build for the migration stays with your team afterward.
The bottom line
Zero-downtime multi-tenant migrations fail in the dual-write window, not at the schema change. The fix is not more careful SQL. The fix is an explicit per-row or per-tenant version flag that tells your backfill jobs, your rollback scripts, and your on-call engineer at 3 a.m. exactly which rows are safe to touch. Every step in this playbook — the canary tenant, the same-transaction dual write, the version-gated backfill, the tenant-by-tenant rollout — exists to preserve that guarantee.
The tradeoff is real. This approach is slower than a maintenance window, more code than a straight ALTER TABLE, and it will surface bugs in your write path that a downtime migration would have hidden. For a five-tenant product, it's overkill. For a fleet of enterprise customers on annual contracts, it's the only option that lets you keep shipping.
Frequently Asked Questions
Can I do a zero-downtime migration without a per-tenant version flag?
You can, but the blast radius of a mistake is the entire fleet instead of one tenant. Without the flag, your backfill job has no way to distinguish rows being actively dual-written from historical rows that are safe to migrate, so a race condition anywhere in the pipeline corrupts data silently. The flag exists to make the safety property explicit and enforceable.
What if my ORM makes same-transaction dual-writes hard?
Drop to raw SQL for the dual-write query specifically. Most ORMs (ActiveRecord, Prisma, SQLAlchemy, Django ORM) allow raw SQL execution inside a managed transaction. The cleanliness of your ORM abstraction is not worth the risk of a partial commit corrupting one tenant's data.
How do I handle a schema change that also requires an application-level data transformation, like re-encrypting a column?
Same playbook, but the dual-write path computes the transformation in application code and writes the transformed value to the new column. The backfill job does the same for historical rows. Because the version flag isolates which rows have been transformed, you can pause and resume the backfill safely, and the canary tenant validates that your transformation logic is correct before you touch enterprise data.
How long should the dual-write window last before I drop the old columns?
Long enough that you're confident no read path still depends on the old columns, and long enough to give yourself a real rollback window if a bug surfaces after the read cutover. That's usually at least a week of production traffic, but depends on the change. For a migration on business-critical data, longer is cheaper than rolling back a drop.
Can CodeNicely help plan or execute a migration like this for our team?
Yes — we work with SaaS teams on live schema migrations, legacy modernization, and multi-tenant architecture changes where downtime isn't acceptable. For a scoped assessment of your specific situation, timelines, and the right team shape, contact CodeNicely for a personalized assessment.
Building something in SaaS?
CodeNicely partners with founders and tech teams to ship AI-native products that move metrics. Tell us about the problem you're solving.
Talk to our team_1751731246795-BygAaJJK.png)