Usually a contract forces it: a customer whose data may not share tables with anyone else's. (If nothing forces it, a shared schema with row-level security is the better default; see Designing Multi-Tenant APIs That Scale.) Once tenants live in separate schemas, two things a shared schema gave you for free are gone.
Where does this tenant live?
With one schema, the answer is "there". With five hundred, something has to map each tenant to a host and schema, and it has to be one source of truth, not config scattered across services.
- Request to Tenant registry: resolve tenant_id
- Tenant registry to Schema A
- Tenant registry to Schema B
- Tenant registry to Schema C


The tenantId feeding this lookup comes from the authenticated
request, never from the client, or the registry will happily route an
attacker to whichever schema they name. Resolve once per request and
carry the connection on a request context
(AsyncLocalStorage in
Node) instead of hitting the registry per query.
One migration, five hundred runs
A loop runs ALTER TABLE invoices ADD COLUMN due_date date across
every schema. Tenant 7's connection drops, and the script exits.
- Index 0: v2
- Index 1: v2
- Index 2: v2
- Index 3: v2
- Index 4: v2
- Index 5: v2
- Index 6: ? (crash)
- Index 7: v1
- Index 8: v1
- Index 9: v1
- Index 10: v1
- Index 11: v1
Re-run from the top and it fails on the schemas that already have the
column. Paper over that with ADD COLUMN IF NOT EXISTS and it
"succeeds" while hiding whatever went wrong. At n = 1 none of this
exists; at n = 500 it happens on the first real migration.
Each tenant keeps its own history
The fix is the idempotency pattern again: a durable record of what's done, checked before doing it. The record lives inside each tenant's own schema, because a single global "latest migration" can't express "six yes, six no".


The migration and its record commit in one transaction, so they can't
disagree. Re-run after the crash: tenants 1 to 6 report
already-applied, and the run picks up at tenant 7.
A fixed number in flight
One at a time is safe and takes all day. All five hundred at once can take the cluster down. Pick a concurrency limit on purpose:

Promise.allSettled, not Promise.all: one tenant in a weird state
should be one failed result to retry, not an aborted batch.
Order is a safety decision
| Wave | Who | Why |
|---|---|---|
| 1 | internal and test tenants | a broken migration hits you first |
| 2 | low-tier, low-risk customers | small blast radius |
| 3 | the largest, dedicated tenants | the ones paying for isolation, with notice and an agreed window |
The tenants most likely to be on their own schema are exactly the ones least tolerant of a surprise schema change. Go last with them.
Old code and new schemas, at the same time
With one schema, migrating and deploying are close enough to atomic that nobody notices the gap. With five hundred migrating over hours, new code will hit unmigrated tenants and old code migrated ones. Every intermediate state has to work. That's a parallel change (expand, then contract):
| Step | What changes | Old code | New code |
|---|---|---|---|
| 1. Expand | add the new column, nullable | ✓ ignores it | ✓ |
| 2. Dual write | new code writes old and new columns | ✓ | ✓ |
| 3. Backfill | fill the new column for older rows | ✓ | ✓ |
| 4. Cut over reads | new code reads only the new column | ✓ | ✓ |
| 5. Contract | drop the old column, weeks later | gone | ✓ |
A column of ticks is the whole point: no step depends on every tenant being on the same version.
Notice drift before a customer does
Someone will hotfix one tenant's schema by hand, or a migration will fail in a way retries miss. Diff every tenant's recorded version against the expected one on a schedule:

A tenant on the wrong version becomes a page, not a three-day debugging mystery.
Physical isolation swaps a forgotten WHERE for an operations
problem, and neither is solved by more careful application code. It
needs two pieces of infrastructure: a registry that is the only answer
to "where", and a migration runner that records progress per tenant,
limits its own concurrency, and rolls out in waves.