Designing the multi-tenancy tier is one of the most consequential architectural decisions for any SaaS platform. The choice impacts security compliance (SOC 2, GDPR, HIPAA), operational cost, infrastructure complexity, and query performance for years.
A flawed isolation design exposes applications to two existential risks: cross-tenant data leakage (a bug in an ORM query returning Tenant A’s customer records to Tenant B) and the noisy neighbor anomaly (a large enterprise customer consuming all shared database CPU cycles).
This guide analyzes the three primary multi-tenant architectural patterns and provides an enterprise-ready implementation of PostgreSQL Row-Level Security (RLS) with connection pooling.
1. Architectural Trade-offs: The 3 Multi-Tenancy Models
[1. Database-per-Tenant (Silo)]
Tenant A ---> [ DB Instance A ]
Tenant B ---> [ DB Instance B ]
(Maximum isolation, extreme cost, complex global migrations)
[2. Schema-per-Tenant (Bridge)]
Shared Database
+--- Schema "tenant_101" (Tables)
+--- Schema "tenant_102" (Tables)
(Moderate isolation, schema migration bottlenecks at >500 tenants)
[3. Shared Database & Shared Schema with RLS (Pool)]
Shared Database + Single Public Schema
+--- Orders (tenant_id = '101')
+--- Orders (tenant_id = '102')
(Maximum compute density, lowest cost, engine-enforced isolation)
| Dimension | Database-per-Tenant | Schema-per-Tenant | Shared Schema + RLS |
|---|---|---|---|
| Data Isolation | Complete Physical Isolation | Logical Schema Boundaries | Database Engine Policy Enforced |
| Infrastructure Cost | Extreme ($$$) | Moderate ($$) | Optimal ($) |
| Max Tenant Scale | 100–500 tenants | 500–2,000 schemas | 100,000+ tenants |
| Schema Migrations | Orchestrated multi-DB runner | Loop over thousands of schemas | Single atomic DDL migration |
| Connection Pooling | Connection starvation risk | Moderate pool complexity | Highly efficient (Shared PgBouncer) |
| Tenant Backup/Restore | Instant per-DB PITR restore | pg_dump -n schema |
Filtered logical dump required |
2. PostgreSQL Row-Level Security (RLS) Deep-Dive
PostgreSQL RLS ensures that the SQL engine itself evaluates access predicates before executing queries. Even if a junior developer writes SELECT * FROM orders without a WHERE tenant_id = ? clause, the engine filters results automatically based on session variables.
The DDL Schema Setup
-- 1. Create Core Multi-Tenant Tables
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE customer_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
order_number VARCHAR(64) NOT NULL,
total_amount NUMERIC(12, 2) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexing tenant_id is MANDATORY for performance with RLS
CREATE INDEX idx_customer_orders_tenant_lookup ON customer_orders (tenant_id, created_at DESC);
-- 2. Enable RLS and CRITICAL: FORCE RLS on table owners
ALTER TABLE customer_orders ENABLE ROW LEVEL SECURITY;
-- By default, table owners bypass RLS. FORCE ensures strict enforcement!
ALTER TABLE customer_orders FORCE ROW LEVEL SECURITY;
-- 3. Create Fine-Grained Tenant Policy reading Session Context
CREATE POLICY tenant_isolation_policy ON customer_orders
AS RESTRICTIVE
FOR ALL
USING (
tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::UUID
)
WITH CHECK (
tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::UUID
);
3. Node.js Middleware Integration with PgBouncer
When using connection poolers like PgBouncer in Transaction Pooling mode, session variables persist only for the duration of the current transaction. Using SET LOCAL guarantees that tenant credentials never bleed across requests:
import { Pool, type PoolClient } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 30, // 30 pooled connections serving thousands of tenants
idleTimeoutMillis: 10000,
});
/**
* Executes a database operation within an isolated tenant security perimeter.
* Guarantees that app.current_tenant_id is strictly bound to the active transaction.
*/
export async function withTenantContext<T>(
tenantId: string,
operation: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
// 1. Begin atomic ACID transaction
await client.query("BEGIN;");
// 2. SET LOCAL scopes variable strictly to this transaction block
// It is automatically cleared on COMMIT or ROLLBACK.
await client.query("SET LOCAL app.current_tenant_id = $1;", [tenantId]);
// 3. Execute tenant business routine
const result = await operation(client);
// 4. Commit transaction
await client.query("COMMIT;");
return result;
} catch (error) {
await client.query("ROLLBACK;");
throw error;
} finally {
// Return pristine connection back to PgBouncer pool
client.release();
}
}
Express / Fastify Controller Usage
import type { Request, Response } from "express";
export async function getOrdersHandler(req: Request, res: Response) {
// Extract tenantId from verified JWT claims
const tenantId = req.user.organizationId;
const orders = await withTenantContext(tenantId, async (client) => {
// Zero tenant filtering needed in application SQL!
// The database engine automatically filters by tenant_id behind the scenes.
const { rows } = await client.query(
"SELECT id, order_number, total_amount, status FROM customer_orders ORDER BY created_at DESC LIMIT 50;"
);
return rows;
});
return res.json({ success: true, data: orders });
}
4. Solving the “Noisy Neighbor” Problem
While Shared Schema + RLS optimizes server resource utilization, a heavy analytics query executed by one tenant can stall queries for others. Mitigate this with:
- Statement Timeouts:
SET LOCAL statement_timeout = '3000ms'; -- Kill queries exceeding 3 seconds - Read Replica Routing: Direct all read-heavy reporting and export queries to an asynchronously replicated read replica.
- Enterprise Tier Splitting: Deploy standard tier customers onto the Shared RLS cluster; migrate Tier 1 enterprise contracts paying a premium onto dedicated RDS instances.
Architectural Summary
- Enforce at the Engine Level: Do not rely on application developers remembering
WHERE tenant_id = ?. PostgreSQL RLS guarantees zero cross-tenant leakage. - Always use
SET LOCALwith Transactions: Avoid connection pool state leakage when pairing with PgBouncer. - Index the Partition Key: Every RLS policy check appends a filter on
tenant_id; ensure composite indexes includetenant_idas their leftmost column.