Skip to content

Migrating Schema-Per-Tenant Databases at Scale

Naseebullah AhmadiSenior Software Engineer, London

Give each tenant its own schema and you get real isolation. You also get two new problems: knowing where each tenant lives, and running one migration correctly five hundred times instead of once.

10 min read
#engineering

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.

  1. Request to Tenant registry: resolve tenant_id
  2. Tenant registry to Schema A
  3. Tenant registry to Schema B
  4. Tenant registry to Schema C
The registry is the only place that knows physical location. Application code asks it for a connection, it never hardcodes one.
@itsnas SQL
CREATE TABLE tenant_registry (
  tenant_id   uuid PRIMARY KEY,
  db_host     text NOT NULL,
  schema_name text NOT NULL,
  tier        text NOT NULL, -- pooled | dedicated
  region      text NOT NULL
);
main
Nas (@itsnas)
One table, one answer to 'where does this tenant live'
@itsnas TypeScript
async function resolveTenantConnection(tenantId: string) {
  const [entry] = await registryDb`
    SELECT db_host, schema_name
    FROM tenant_registry
    WHERE tenant_id = ${tenantId}
  `
  if (!entry) throw new NotFoundError('tenant')
 
  return getPool(entry.db_host).withSchema(entry.schema_name)
}
main
Nas (@itsnas)
Resolve once per request, from the registry, never from the client

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.

  1. Index 0: v2
  2. Index 1: v2
  3. Index 2: v2
  4. Index 3: v2
  5. Index 4: v2
  6. Index 5: v2
  7. Index 6: ? (crash)
  8. Index 7: v1
  9. Index 8: v1
  10. Index 9: v1
  11. Index 10: v1
  12. Index 11: v1
Twelve tenants after the crash. Six migrated, one died mid-run, five never started, and nothing recorded which is which.

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".

@itsnas SQL
CREATE TABLE schema_migrations (
  version     text PRIMARY KEY,
  applied_at  timestamptz NOT NULL DEFAULT now()
);
main
Nas (@itsnas)
Lives inside every tenant schema, tracks that schema's own history
@itsnas TypeScript
async function migrateTenant(tenantId: string, migration: Migration) {
  const db = await resolveTenantConnection(tenantId)
 
  const [applied] = await db`
    SELECT version FROM schema_migrations WHERE version = ${migration.version}
  `
  if (applied) return { tenantId, status: 'already-applied' }
 
  await db.begin(async tx => {
    await migration.up(tx)
    await tx`
      INSERT INTO schema_migrations (version) VALUES (${migration.version})
    `
  })
 
  return { tenantId, status: 'migrated' }
}
main
Nas (@itsnas)
Skip what's already applied, record what isn't

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:

@itsnas TypeScript
async function migrateAllTenants(migration: Migration, concurrency = 10) {
  const tenants = await getAllTenantIds()
  const results = []
 
  for (let i = 0; i < tenants.length; i += concurrency) {
    const batch = tenants.slice(i, i + concurrency)
    results.push(
      ...(await Promise.allSettled(
        batch.map(id => migrateTenant(id, migration)),
      )),
    )
  }
 
  return results
}
main
Nas (@itsnas)
A fixed number in flight, not all-or-nothing

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

WaveWhoWhy
1internal and test tenantsa broken migration hits you first
2low-tier, low-risk customerssmall blast radius
3the largest, dedicated tenantsthe 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):

StepWhat changesOld codeNew code
1. Expandadd the new column, nullable✓ ignores it✓
2. Dual writenew code writes old and new columns✓✓
3. Backfillfill the new column for older rows✓✓
4. Cut over readsnew code reads only the new column✓✓
5. Contractdrop the old column, weeks latergone✓

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:

@itsnas TypeScript
async function detectDrift(expectedVersion: string) {
  const tenants = await getAllTenantIds()
  const drifted = []
 
  for (const tenantId of tenants) {
    const db = await resolveTenantConnection(tenantId)
    const [latest] = await db`
      SELECT version FROM schema_migrations
      ORDER BY applied_at DESC LIMIT 1
    `
    if (latest?.version !== expectedVersion) drifted.push(tenantId)
  }
 
  return drifted
}
main
Nas (@itsnas)
A tenant on the wrong version is a page, not a mystery

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.