Multi-Tenant SaaS Database Architecture: Isolation Models and Scaling Trade-offs
The multi-tenancy model you choose on day one of your SaaS product will define your architecture for years. Teams that pick the easiest model to build — shared tables — routinely discover at scale that it is the hardest model to operate, harden, and migrate away from. This post gives you the full picture before you commit.
The Three Multi-Tenancy Models
Every SaaS database architecture falls into one of three models, each representing a different point on the isolation-vs-efficiency spectrum:
- Database-per-tenant: each tenant gets their own database instance (or cluster). Maximum isolation, maximum cost.
- Schema-per-tenant: all tenants share a single PostgreSQL instance but each tenant has their own schema (namespace). Moderate isolation, moderate cost.
- Shared table (row-level security): all tenants share the same tables, differentiated only by a
tenant_idcolumn. Minimum isolation, minimum cost per tenant.
The Production Scenario: Shared Table to Isolation Migration
A B2B SaaS HR platform starts with the shared-table model. At 50 tenants and 100,000 total employees, it runs beautifully on a single PostgreSQL 15 instance with 32 cores and 128GB RAM. At 500 tenants and 8 million employees, three things go wrong simultaneously:
- A Fortune 500 enterprise prospect requires data isolation as a contractual requirement ("their data must not share physical storage with other customers")
- A noisy-tenant problem: one large tenant runs complex payroll batch queries that lock tables and degrade response times for all other tenants by 300–500%
- A GDPR right-to-erasure request from a European tenant requires deleting all their data — which now requires carefully scoped DELETE queries across 40+ tables, with no way to verify completeness
The platform needed to migrate 500 tenants from shared tables to schema-per-tenant — a six-month engineering effort that could have been avoided with the right initial design.
Database-per-Tenant: Maximum Isolation, Maximum Complexity
When It Is Appropriate
- Enterprise or regulated industry clients (healthcare, finance, government) with strict data residency or isolation requirements
- Tenants with vastly different data volumes (one tenant with 10M rows, another with 100B rows)
- Tenants requiring custom schema extensions, specialized indexes, or different PostgreSQL configurations
- GDPR/CCPA compliance: deleting a tenant is just dropping a database — complete, verifiable, auditable
The Connection Pool Explosion Problem
PostgreSQL creates a process per connection. At 500 tenants with a minimum pool of 5 connections each, that is 2,500 backend processes — a severe memory and file descriptor burden. PgBouncer is mandatory:
# pgbouncer.ini — transaction pooling for multi-tenant databases
[databases]
tenant_001 = host=pg-shard-1 port=5432 dbname=tenant_001
tenant_002 = host=pg-shard-1 port=5432 dbname=tenant_002
; ... generated dynamically from tenant registry
[pgbouncer]
listen_port = 6432
listen_addr = *
auth_type = md5
pool_mode = transaction ; transaction pooling = lowest connection count
max_client_conn = 10000
default_pool_size = 5 ; 5 server connections per database
min_pool_size = 1
reserve_pool_size = 2
server_idle_timeout = 60
The Migration Hell Problem
With database-per-tenant, a schema change (adding a column, creating an index) requires running the migration against every tenant database. At 500 databases, a 5-minute migration becomes 40+ hours of sequential execution. Parallelise with a migration orchestrator:
@Component
public class TenantMigrationOrchestrator {
private final TenantRegistry tenantRegistry;
private final ExecutorService migrationExecutor =
Executors.newFixedThreadPool(20); // 20 parallel migrations
public MigrationReport runMigrationForAllTenants(String migrationScript) {
List<Future<TenantMigrationResult>> futures = tenantRegistry.getAllTenants()
.stream()
.map(tenant -> migrationExecutor.submit(() -> {
DataSource ds = getTenantDataSource(tenant.getId());
return runMigration(ds, migrationScript, tenant.getId());
}))
.toList();
return collectResults(futures); // Reports success/failure per tenant
}
}
Schema-per-Tenant with PostgreSQL
Schema-per-tenant gives you strong logical isolation within a single PostgreSQL instance. Each tenant's tables are in a separate schema, accessed by setting the search_path at session start:
-- Tenant schema setup
CREATE SCHEMA tenant_acme;
SET search_path TO tenant_acme;
CREATE TABLE employees (id UUID PRIMARY KEY, name TEXT, salary NUMERIC);
-- Application-level schema switching per request
SET search_path TO tenant_abc; -- all subsequent queries use tenant_abc schema
// Spring Boot: Hibernate schema-per-tenant implementation
@Component
public class SchemaBasedTenantConnectionProvider implements MultiTenantConnectionProvider {
private final DataSource primaryDataSource;
@Override
public Connection getConnection(String tenantIdentifier) throws SQLException {
Connection connection = primaryDataSource.getConnection();
// Validate tenant identifier to prevent SQL injection
String safeSchema = validateAndSanitize(tenantIdentifier);
try (Statement stmt = connection.createStatement()) {
stmt.execute("SET search_path TO " + safeSchema + ", public");
}
return connection;
}
private String validateAndSanitize(String tenantId) {
// Only allow alphanumeric and underscore — never interpolate raw user input
if (!tenantId.matches("^[a-zA-Z0-9_]{1,63}$")) {
throw new IllegalArgumentException("Invalid tenant identifier: " + tenantId);
}
return "tenant_" + tenantId;
}
}
@Component
public class RequestHeaderTenantResolver implements CurrentTenantIdentifierResolver {
@Override
public String resolveCurrentTenantIdentifier() {
// Extract from ThreadLocal set by request filter
String tenantId = TenantContext.getCurrentTenantId();
return tenantId != null ? tenantId : "public";
}
@Override
public boolean validateExistingCurrentSessions() {
return true;
}
}
// Tenant context filter — set before every request
@Component
@Order(1)
public class TenantContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
String tenantId = extractTenantId(request);
TenantContext.setCurrentTenantId(tenantId);
try {
chain.doFilter(request, response);
} finally {
TenantContext.clear(); // ALWAYS clear to prevent ThreadLocal leaks
}
}
private String extractTenantId(HttpServletRequest request) {
// Strategy 1: subdomain (acme.app.com → "acme")
String host = request.getServerName();
if (host.contains(".app.com")) {
return host.split("\\.")[0];
}
// Strategy 2: X-Tenant-ID header (API clients)
return request.getHeader("X-Tenant-ID");
}
}
Shared Table with Row-Level Security: Performance Gotchas
PostgreSQL Row-Level Security (RLS) is the most operationally simple model but has significant performance implications at scale:
-- Enable RLS on every tenant-scoped table
ALTER TABLE employees ENABLE ROW LEVEL SECURITY;
ALTER TABLE employees FORCE ROW LEVEL SECURITY; -- applies even to table owner
-- Policy: users can only see their tenant's rows
CREATE POLICY tenant_isolation_policy ON employees
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Application sets the tenant at session start
SET app.current_tenant_id = 'a1b2c3d4-...';
SELECT * FROM employees; -- automatically filtered
Index Strategy for RLS Performance
Without proper indexing, every RLS policy evaluation performs a sequential scan filtered on tenant_id. The critical optimisation: composite index with tenant_id as the leading column:
-- Essential indexes for shared-table multi-tenancy
CREATE INDEX CONCURRENTLY idx_employees_tenant_id
ON employees (tenant_id);
-- Composite indexes for common query patterns
CREATE INDEX CONCURRENTLY idx_employees_tenant_department
ON employees (tenant_id, department_id, created_at DESC);
-- Partial index for active tenants (if soft-delete pattern)
CREATE INDEX CONCURRENTLY idx_employees_active
ON employees (tenant_id, id)
WHERE deleted_at IS NULL;
A common RLS performance trap: the query planner may not use the tenant_id index if statistics are stale. After bulk tenant data loads, run ANALYZE employees to update planner statistics.
Connection Pooling Challenges: PgBouncer vs HikariCP
Schema-per-tenant and shared-table models interact differently with connection poolers. With schema-per-tenant, the search_path is set per session — which breaks PgBouncer's transaction pooling mode (which reuses connections across transactions, resetting session state). You must use statement pooling or manage it carefully:
- PgBouncer session mode: one physical connection per logical connection. Safe with session-level
SET search_path. Poor connection reuse. - PgBouncer transaction mode + SET LOCAL: use
SET LOCAL search_path(resets after transaction). Works with transaction pooling but requires every query to be in an explicit transaction. - HikariCP with statement interceptor: most Spring Boot teams prefer this for schema-per-tenant, setting
search_pathat connection checkout viaconnectionInitSqlor a customConnectionInitializationInterceptor.
Scaling Patterns: Hot Tenant Isolation and Tenant Migration
Hot Tenant Problem
One large tenant consuming disproportionate resources is a universal SaaS problem. Solution: tenant-to-shard mapping with the ability to migrate hot tenants to dedicated infrastructure at runtime:
// Tenant registry with shard routing
@Service
public class TenantRouter {
private final TenantRegistry registry;
public DataSource getDataSourceForTenant(String tenantId) {
TenantConfig config = registry.findByTenantId(tenantId);
return switch (config.getTier()) {
case ENTERPRISE_ISOLATED -> isolatedDataSources.get(config.getShardId());
case BUSINESS -> sharedShardDataSources.get(config.getShardId());
case STARTER -> sharedDataSource; // single shared instance
};
}
}
Decision Framework: Which Model to Choose
- SMB SaaS (hundreds of small tenants, homogeneous needs): shared table with RLS. Low cost, easy ops, acceptable isolation for non-regulated data.
- Mid-market SaaS (diverse tenant sizes, some enterprise): schema-per-tenant. Good isolation, reasonable ops overhead, supports tenant-level customisation.
- Enterprise SaaS (large tenants, regulated industries, SOC2 Type II): database-per-tenant for enterprise tier, shared table for SMB tier. Hybrid model with per-tenant data source routing.
- Regulated industries (healthcare HIPAA, finance PCI-DSS): database-per-tenant with physical isolation (separate RDS instances or clusters). No compromise on isolation regardless of cost.
Key Takeaways
- Choose your multi-tenancy model based on your dominant customer segment and regulatory requirements — not what is easiest to build today
- Migrating from shared-table to schema-per-tenant at 500 tenants costs 6 months; doing it at 50 tenants costs 3 weeks
- RLS is PostgreSQL's most underused feature — it enforces tenant isolation at the database engine level, immune to application-layer bugs
- Never interpolate raw tenant identifiers into SQL — always validate and prefix to prevent SQL injection via schema switching
- Always set a
FINALLYblock to clearTenantContextThreadLocal in request filters — leaking tenant context is a data breach - PgBouncer transaction mode does not work safely with session-level
SET search_path— understand your pooling mode before deploying
Conclusion
Multi-tenancy architecture is a decision that compounds over time. Every tenant you onboard, every query you write, every index you create is shaped by the isolation model you chose at the beginning. The shared-table model is seductive at small scale and punishing at large scale. Schema-per-tenant offers the best balance for most B2B SaaS products. Database-per-tenant is non-negotiable for regulated enterprise workloads. Understanding these trade-offs before your first enterprise contract is the difference between a competitive SaaS product and an expensive re-architecture project.