Multi-Tenant SaaS Database Architecture: Isolation Models and Scaling Trade-offs

Multi-Tenant SaaS Database Architecture

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_id column. 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:

  1. A Fortune 500 enterprise prospect requires data isolation as a contractual requirement ("their data must not share physical storage with other customers")
  2. 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%
  3. 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_path at connection checkout via connectionInitSql or a custom ConnectionInitializationInterceptor.

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 FINALLY block to clear TenantContext ThreadLocal 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.


Related Posts