Multi-tenant SaaS: silo, pool, and bridge patterns for user isolation

The architecture you choose for tenant isolation in a multi-tenant SaaS application is one of the most consequential early decisions you will make. It affects your operational costs, compliance posture, performance profile, and the complexity of your data access layer for the entire life of the product. There are three primary patterns, each with meaningful tradeoffs.

Pattern 1: Silo (database per tenant)

In the silo pattern, each tenant gets a completely isolated database instance (or at minimum, a separate schema). Tenant A's data never touches the same storage as Tenant B's. This provides the strongest possible isolation guarantee.

Advantages

  • True data isolation — a bug in your query code cannot leak data across tenants because the database connection itself is scoped to one tenant
  • Tenant-specific database configuration — different retention periods, backup schedules, or replication topologies per tenant
  • Compliance simplicity — SOC 2 and HIPAA auditors love this model because data residency is unambiguous
  • Independent performance — a heavy query from one tenant does not affect others
  • Easy data deletion — delete the tenant's database on churn, done

Disadvantages

  • Cost: even a small PostgreSQL RDS instance costs ~$15–25/month. At 500 tenants you are paying $7,500–$12,500/month in database costs before a single application server
  • Schema migrations across hundreds of databases require a migration orchestration layer
  • Cross-tenant analytics and aggregate reporting require federated queries
  • Connection pool management becomes complex — 500 databases × multiple app servers = connection limits you will hit

The silo model makes sense when customers are large enterprises paying $50K+ ARR, where the isolation guarantee is a contractual requirement and the per-tenant infrastructure cost is a rounding error.

Pattern 2: Pool (shared schema with tenant_id)

In the pool pattern, all tenants share the same database schema. Every table has a tenant_id column, and all queries filter by tenant. This is the default pattern for most SaaS applications targeting SMB and developer customers.

-- Shared schema example
CREATE TABLE projects (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id  UUID NOT NULL REFERENCES tenants(id),
    name       TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX projects_tenant_id_idx ON projects(tenant_id);

-- Every query must include tenant_id
SELECT * FROM projects WHERE tenant_id = $1 AND id = $2;

The single biggest risk with this pattern is a developer forgetting to include the tenant_id filter in a query. This results in returning data from all tenants — a catastrophic data leak. The solution is to enforce tenant filtering at the ORM or database layer so it cannot be omitted.

Row-level security in PostgreSQL

PostgreSQL's row-level security (RLS) feature lets you enforce tenant isolation at the database engine level. Even if your application code has a bug and omits the tenant_id filter, the database will not return rows for other tenants. This is the best available safeguard for the pool pattern.

-- Enable RLS on the projects table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Policy: users can only see their own tenant's rows
CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

-- In your application, set the tenant context before executing queries
-- (this must happen within the same transaction or connection scope)
SET LOCAL app.current_tenant_id = 'b4e7c3a1-...';
SELECT * FROM projects;  -- automatically filtered to current tenant

-- With a connection pool, set it per-query:
await db.query("SET LOCAL app.current_tenant_id = $1", [tenantId]);
await db.query("SELECT * FROM projects WHERE name = $1", [name]);
RLS has a performance cost. PostgreSQL adds an implicit filter to every query on the table. For high-throughput tables, benchmark the overhead. You can bypass RLS for your superuser role in admin contexts with BYPASSRLS role attribute, but never bypass it in application code paths.

The tenant_id leak risk in practice

Beyond missing filters, watch for these pool-pattern leak vectors:

  • Aggregation queries that cross tenant boundaries in GROUP BY — add tenant_id to the GROUP BY
  • Autocomplete and search endpoints that query across the full index — always scope by tenant first
  • Webhooks and background jobs that process all records — partition job queues by tenant or add tenant filter to every batch query
  • File storage: if user-uploaded files are stored in a single S3 bucket with path-based tenant routing, ensure download URLs are signed and tenant-verified server-side

Pattern 3: Bridge (hybrid)

The bridge pattern uses a pool (shared schema) for the majority of tenants but can move individual tenants to dedicated silo databases when their requirements or contract dictates it. Your application code must support resolving the correct database connection for a given tenant at runtime.

// Tenant connection resolver
class TenantResolver {
  constructor(defaultPool, tenantConfigs) {
    this.defaultPool = defaultPool;
    this.tenantConfigs = tenantConfigs;  // { tenantId -> connection config }
    this.dedicatedPools = new Map();
  }

  async getConnection(tenantId) {
    const config = this.tenantConfigs.get(tenantId);

    if (config?.dedicated) {
      if (!this.dedicatedPools.has(tenantId)) {
        this.dedicatedPools.set(tenantId, createPool(config.connection));
      }
      return this.dedicatedPools.get(tenantId);
    }

    return this.defaultPool;
  }
}

// Middleware
app.use(async (req, res, next) => {
  const tenantId = req.headers['x-tenant-id'] || req.user?.tenantId;
  req.db = await tenantResolver.getConnection(tenantId);
  req.tenantId = tenantId;
  next();
});

The bridge pattern lets you start with a pool model (cheap, simple) and offer silo isolation as an upgrade tier. This is the right commercial model for B2B SaaS: most customers are fine with pool isolation, enterprise customers who require dedicated infrastructure pay for it.

Compliance implications

The isolation pattern affects how you answer compliance questionnaires. Key questions and how each pattern answers them:

GDPR data deletion (right to erasure): Silo makes this trivial — drop the database. Pool requires a cascade delete that touches every table with a tenant_id foreign key, and you must verify no orphaned data remains. Build a dedicated deletion job that can be audited.

Data residency: If a customer requires EU data residency, the silo model lets you spin up a database in eu-west. With a pool model you need a separate regional deployment of the entire application stack.

SOC 2 logical separation control: Both pool with RLS and silo satisfy this control, but the auditor narrative is different. Silo: "each customer's data is in a physically isolated database." Pool: "row-level security at the database layer enforces tenant isolation and has been verified through penetration testing."

HIPAA: Covered entities often contractually require dedicated infrastructure. The silo pattern is the clearest path to a HIPAA BAA that healthcare customers will sign.

Practical recommendation

Start with the pool pattern and RLS from day one. The incremental cost to add RLS correctly is a few hours; retrofitting it later into a codebase that has grown without it is a multi-week project. Design your tenant resolution middleware to support dedicated pools (the bridge pattern) even if you do not deploy any dedicated pools initially — this keeps the upgrade path open without a major refactor.

← Back to blog Try Bastionary free →