Hybrid & Tiered Isolation Models

A hybrid isolation model runs several isolation strategies side by side β€” pooled rows for self-serve volume, a dedicated schema for mid-market, a dedicated database for the regulated few β€” and it is the pragmatic end state of the Multi-Tenant Database Isolation Models framework rather than a compromise of it. Almost every platform that reaches a few thousand customers ends up here, because no single model prices correctly across a three-order-of-magnitude range of customer size.

The reason is economic, not technical. Pooled tenants cost fractions of a cent each and scale to hundreds of thousands; dedicated databases cost tens of dollars a month before a single query runs. Serving a two-seat trial account from its own database destroys the unit economics of the free tier, and serving a hospital system from a shared table with everyone else fails procurement. A tiered model lets each customer pay for the isolation they actually require.

What makes hybrids hard is not running three models β€” it is making the rest of the system unaware that there are three. This page builds the tier registry, the routing layer that hides the difference from application code, the promotion path between tiers, and the operational model for running all of it without triplicating your migration tooling.

Prerequisites

Tier Definitions & Assignment Rules

Three tiers is the sweet spot; four is usually one tier of operational overhead too many. Define them by the boundary they enforce, then attach commercial and compliance triggers that move a tenant between them.

Tier Boundary Tenants per unit Marginal cost Assignment trigger
Pooled Row-level, RLS-enforced 10,000+ per database Storage only, ~cents Default for self-serve and trials
Bridged Schema-per-tenant in a shared cluster 200–2,000 per database Schema catalogue overhead Contractual "logical separation" language, noisy-neighbour risk
Siloed Database-per-tenant, sometimes own instance 1 Instance floor, tens of dollars HIPAA/FedRAMP, BYOK, residency, or contractual audit rights
Siloed + regional Dedicated database in a named region 1 Instance + cross-region ops Data residency commitments

Assignment must be a property of the tenant record, evaluated by a rule engine, not an ad-hoc decision made during onboarding. The rule set is short and should be readable by a non-engineer: any tenant with a signed BAA is siloed; any tenant on the enterprise plan is at least bridged; any tenant that exceeds a storage or query-time threshold in a shared database is promoted to bridged regardless of plan, because at that point they are a noisy neighbour whether or not they are paying for isolation.

Step-by-Step Implementation

Step 1 β€” Make the tenant registry the single source of placement truth

Every request resolves placement from one authoritative, cached, never-sharded table. If placement can be inferred from two places, they will disagree, and the disagreement will be a cross-tenant read.

CREATE TYPE isolation_tier AS ENUM ('pooled','bridged','siloed');

CREATE TABLE tenant_placement (
  tenant_id     uuid PRIMARY KEY,
  tier          isolation_tier NOT NULL,
  cluster_key   text NOT NULL,           -- logical cluster name, resolved to DSN at runtime
  schema_name   text,                    -- NULL for pooled
  database_name text,                    -- NULL unless siloed
  region        text NOT NULL DEFAULT 'eu-west-1',
  migrated_at   timestamptz,
  CONSTRAINT placement_shape CHECK (
    (tier = 'pooled'  AND schema_name IS NULL     AND database_name IS NULL) OR
    (tier = 'bridged' AND schema_name IS NOT NULL AND database_name IS NULL) OR
    (tier = 'siloed'  AND database_name IS NOT NULL)
  )
);

The CHECK constraint prevents the single most common hybrid bug: a tenant marked siloed with a null database name, which silently falls back to the pooled connection and writes a regulated customer's data into the shared table.

Step 2 β€” Resolve a connection through one router, never in application code

Application code must ask for "a connection for the current tenant" and receive one that is already correct. The moment a feature team writes if (tenant.tier === 'siloed'), the abstraction has failed and the next feature will get it wrong.

// placement-router.ts
import { Pool, PoolClient } from 'pg';

interface Placement { tier: 'pooled' | 'bridged' | 'siloed'; clusterKey: string; schemaName?: string; databaseName?: string; }

const pools = new Map<string, Pool>();

function poolFor(p: Placement): Pool {
  const key = p.tier === 'siloed' ? `${p.clusterKey}/${p.databaseName}` : p.clusterKey;
  let pool = pools.get(key);
  if (!pool) {
    pool = new Pool({ ...dsnFor(p.clusterKey), database: p.databaseName ?? 'saas', max: maxFor(p.tier) });
    pools.set(key, pool);
  }
  return pool;
}

export async function withTenantConnection<T>(
  tenantId: string, work: (c: PoolClient) => Promise<T>,
): Promise<T> {
  const p = await placementCache.get(tenantId);          // in-memory, refreshed asynchronously
  const client = await poolFor(p).connect();
  try {
    await client.query('BEGIN');
    if (p.tier === 'bridged') {
      // Identifier, not a value β€” validate against the registry, never interpolate raw input.
      await client.query(`SET LOCAL search_path TO ${quoteIdent(p.schemaName!)}, public`);
    }
    await client.query('SELECT set_config($1, $2, true)', ['app.current_tenant_id', tenantId]);
    const out = await work(client);
    await client.query('COMMIT');
    return out;
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

Setting app.current_tenant_id on every tier β€” including siloed, where it is technically redundant β€” is deliberate. It means the RLS policies are identical everywhere, so a tenant promoted out of the pool does not lose a layer of defence, and a bug in the router that sends a siloed tenant to the pooled cluster still fails closed instead of leaking.

Step 3 β€” Apply one migration source to three physical shapes

The operational nightmare of hybrids is divergent schemas. Keep a single ordered migration set and a runner that enumerates every target from the registry, so "applied everywhere" is a computed fact rather than a hope.

# migrate_all.py
import psycopg

def targets(registry_dsn: str):
    with psycopg.connect(registry_dsn) as conn:
        rows = conn.execute(
            "SELECT DISTINCT tier, cluster_key, schema_name, database_name FROM tenant_placement"
        ).fetchall()
    seen, out = set(), []
    for tier, cluster, schema, database in rows:
        key = (cluster, database or "saas", schema if tier == "bridged" else "public")
        if key not in seen:
            seen.add(key)
            out.append(key)
    return out

def apply(migration_sql: str, registry_dsn: str) -> dict[str, str]:
    results = {}
    for cluster, database, schema in targets(registry_dsn):
        dsn = dsn_for(cluster, database)
        with psycopg.connect(dsn, autocommit=False) as conn:
            conn.execute(f'SET search_path TO "{schema}", public')
            conn.execute(migration_sql)
            conn.commit()
        results[f"{cluster}/{database}/{schema}"] = "ok"
    return results

Migration fan-out across large siloed fleets deserves its own treatment β€” batching, partial failure, and version skew are covered in running migrations across thousands of tenant databases.

Step 4 β€” Detect promotion candidates from real usage, not from sales pressure

Promotion should be triggered by measured impact on neighbours. A tenant consuming a disproportionate share of a shared database's buffer cache or lock time is a promotion candidate whether or not their contract says so.

-- Candidates: pooled tenants whose share of a shared database exceeds the noisy-neighbour ceiling.
WITH usage AS (
  SELECT tenant_id,
         sum(total_exec_time) AS ms,
         sum(rows_written)    AS rows_w,
         sum(bytes_stored)    AS bytes
  FROM   tenant_usage_daily
  WHERE  day >= current_date - 30
  GROUP  BY tenant_id
), totals AS (
  SELECT sum(ms) AS ms_all, sum(bytes) AS bytes_all FROM usage
)
SELECT u.tenant_id,
       round(100.0 * u.ms    / t.ms_all, 2)    AS pct_query_time,
       round(100.0 * u.bytes / t.bytes_all, 2) AS pct_storage
FROM   usage u CROSS JOIN totals t
WHERE  u.ms    > 0.05 * t.ms_all
   OR  u.bytes > 0.05 * t.bytes_all
ORDER  BY pct_query_time DESC;

A tenant taking more than five percent of a database shared by thousands is, by definition, no longer a pooled workload.

Step 5 β€” Promote with logical replication and a short cutover

Moving a live tenant between tiers is the operation that decides whether a hybrid model is usable. Copy first, catch up with change data capture, then flip placement inside a brief write freeze.

// promote.ts β€” pooled β†’ siloed, with a bounded write freeze
export async function promote(tenantId: string, target: Placement) {
  await provisionDatabase(target);                       // DDL from the same migration set
  const watermark = await bulkCopy(tenantId, target);    // COPY out / COPY in, no locks held
  await streamChanges(tenantId, target, watermark);      // logical decoding until lag < 1s

  await freezeWrites(tenantId);                          // reject writes with a retriable 503
  try {
    await drainChanges(tenantId, target);                // final catch-up, verify row counts
    await setPlacement(tenantId, target);                // registry flip β€” single source of truth
    await placementCache.invalidate(tenantId);           // all app nodes re-resolve
  } finally {
    await unfreezeWrites(tenantId);
  }
  await scheduleSourceCleanup(tenantId, { afterDays: 7 });
}

The seven-day delay before deleting the source rows is not laziness β€” it is the rollback path. Placement is a single row, so reverting a bad promotion is one update, but only while the old data still exists.

Tier Selection Reference

If the requirement is… Choose Because Watch out for
Lowest cost per tenant at scale Pooled Marginal cost is storage only One missing policy leaks everything
Contractual "logically separated" data Bridged Per-tenant namespace is demonstrable Catalogue bloat past a few thousand schemas
Signed BAA, FedRAMP, or audit-on-demand Siloed Physical boundary, per-tenant backup and restore Instance floor cost, migration fan-out
Per-tenant encryption keys under customer control Siloed Key scope matches database scope Key loss becomes total data loss
Named-region storage commitments Siloed + regional Placement encodes region Cross-region joins become impossible by design
Tenant-specific restore points Bridged or siloed Backup granularity matches tenant Pooled restore requires full-cluster point-in-time
A noisy tenant degrading others Bridged, then siloed Moves the contention off the shared buffer cache Promotion must be triggered on measurement

Dynamic Query Scoping & Connection Handling

The routing layer has to make three physically different things behave identically, and the mechanism is uniformity of the scoping contract: every query runs inside a transaction that has both a search_path appropriate to its tier and a transaction-local tenant variable. Pooled queries are filtered by RLS on app.current_tenant_id; bridged queries are additionally namespaced by search_path; siloed queries are namespaced by the connection itself. Application code sees one contract.

Connection budgeting is where hybrids bite. A pooled cluster serving 30,000 tenants needs one pool sized to aggregate arrival rate; a siloed fleet of 200 tenants needs 200 pools, each of which must be small β€” typically two to five connections β€” or the aggregate blows past that instance's max_connections. Size siloed pools by the tenant's traffic, not by the platform's, and let them idle down to zero, since most siloed tenants are quiet most of the time. The connection arithmetic behind these numbers is worked through in database-per-tenant vs schema-per-tenant connection pool cost.

Cross-tier queries are the operation to design out rather than support. A platform-wide report that needs data from pooled, bridged, and siloed tenants cannot be one SQL statement; it becomes a fan-out that queries each placement and aggregates in the application, or β€” better β€” a per-tenant export into a warehouse where the tenant identifier is a column again. Accepting that constraint early is what keeps the model honest; trying to preserve cross-tenant SQL is what drags teams back into a single shared database with a compliance problem.

Security Enforcement & Access Control

A hybrid has more boundaries to enforce, and the dangerous ones are the seams between tiers.

Control Implementation Boundary guarantee
Uniform RLS Policies present on every tier, including siloed Router bugs fail closed rather than leaking
Placement integrity CHECK constraint plus a nightly reconciler comparing registry to reality A siloed tenant cannot silently sit in the pool
Identifier safety search_path and database names come from the registry and are quoted as identifiers No injection via tenant-controlled strings
Per-tier credentials Distinct roles per cluster; the pooled role has no rights on siloed databases A stolen pooled credential cannot reach regulated tenants
Promotion dual-read guard During cutover, reads are served from exactly one side, never merged No split-brain reads across tiers
Source cleanup fencing Old rows deleted only after the registry flip is confirmed on all nodes Rollback stays possible for the whole window
Cross-tier query ban Static check rejecting SQL that joins registry-resolved placements Reporting cannot accidentally recreate a shared table

The nightly reconciler is the highest-value control here. It walks the registry, connects to each declared placement, and asserts that the tenant's data exists there and only there β€” that a siloed tenant has no rows in the pooled tables, and that a pooled tenant has no orphaned schema left over from a rolled-back promotion. Hybrid systems drift, and drift in this system means data in the wrong isolation domain.

Operational Overhead & Scaling Metrics

Metric Threshold Mitigation
Schemas per bridged cluster > 2,000 Catalogue queries and pg_dump slow sharply; shard across more instances
Aggregate connections across siloed fleet > 70% of cluster max_connections Shrink per-tenant pools, add idle timeout, add a pooler tier
Migration fan-out duration > 30 min Parallelise by cluster; batch siloed targets; alert on partial application
Placement cache staleness > 30s Promotions cut over into a stale route; shorten TTL and push invalidation
Pooled tenants above 5% resource share > 0 for 7 days Promote to bridged; they are already degrading neighbours
Reconciler mismatches > 0 Stop and investigate β€” this is data in the wrong isolation domain
Cost per tenant, siloed tier > 3Γ— revenue floor for the tier Tier pricing is wrong, or the tenant should be bridged

Charting marginal infrastructure cost per tenant against tier makes the pricing consequence obvious. The three tiers do not differ by a percentage; they differ by orders of magnitude, which is precisely why one price cannot cover all of them.

The cost row is the one that decides whether the hybrid was worth building. Siloed tenants should carry a price that covers an instance floor plus operational overhead with margin; if the enterprise tier is priced from a pooled cost model, every promotion loses money and the platform quietly subsidises its largest, most demanding customers. The comparative cost method is worked through in Cost vs Security Tradeoff Analysis.

Pitfalls & Anti-Patterns

Frequently Asked Questions

How many isolation tiers should a platform actually run? Two if you can, three at most. Every tier multiplies migration targets, backup procedures, runbooks, and the number of ways a router can be wrong. Pooled plus siloed covers the majority of commercial needs; the bridged middle tier earns its place only when a meaningful segment of customers demands demonstrable logical separation without paying for dedicated infrastructure.

When should a tenant move from pooled to a dedicated tier? On measurement or on contract, never on request. The measurement trigger is resource share β€” a tenant consuming more than a few percent of a shared database's query time or storage is degrading thousands of neighbours and should be promoted regardless of plan. The contract trigger is a signed obligation you cannot satisfy in a shared table: a BAA, a residency commitment, customer-managed keys, or an audit right that includes physical separation.

Does a hybrid model triple the operational burden? Only if the tooling is written three times. Keep one migration source applied by a fan-out runner, one backup policy expressed per placement, one monitoring pipeline keyed by placement rather than by tier, and the burden grows sub-linearly. The genuinely irreducible extra cost is the reconciler and the promotion pipeline, which together are a few weeks of work and are what make the model safe.

Can a tenant be demoted from siloed back to pooled? Technically yes β€” the promotion pipeline runs in reverse β€” but it is rarely wise. Demotion means moving data from a stronger boundary to a weaker one, which usually contradicts whatever contractual or regulatory reason put the tenant there. Reserve it for tenants who downgraded plan without any compliance obligation, and treat it as a change requiring the customer's written acknowledgement.

How do we keep per-tenant backups consistent across tiers? Match backup granularity to the boundary. Siloed tenants get ordinary per-database backups and can be restored independently. Bridged tenants get per-schema logical dumps on a schedule, plus cluster-level point-in-time recovery for disasters. Pooled tenants cannot be restored individually from a physical backup at all, so the honest answer is a per-tenant logical export on a cadence, restored into a staging schema and copied back β€” which is slow enough that it should be part of the case for promotion.