System Design

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.

Md Sanwar Hossain March 2026 13 min read System Design
Multi-Tenant SaaS Database Architecture

Table of Contents

  1. The Three Multi-Tenancy Models
  2. The Production Scenario: Shared Table to Isolation Migration
  3. Database-per-Tenant: Maximum Isolation, Maximum Complexity
  4. Schema-per-Tenant with PostgreSQL
  5. Shared Table with Row-Level Security: Performance Gotchas
  6. Connection Pooling Challenges: PgBouncer vs HikariCP
  7. Scaling Patterns: Hot Tenant Isolation and Tenant Migration
  8. Decision Framework: Which Model to Choose
  9. Conclusion

The Three Multi-Tenancy Models

Multi-Tenant SaaS Database | mdsanwarhossain.me
Multi-Tenant SaaS Database — mdsanwarhossain.me

Every SaaS database architecture falls into one of three models, each representing a different point on the isolation-vs-efficiency spectrum:

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

SaaS Architecture Patterns | mdsanwarhossain.me
SaaS Architecture Patterns — mdsanwarhossain.me

When It Is Appropriate

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:

Multi-Tenant SaaS Database Design | mdsanwarhossain.me
Multi-Tenant SaaS Database Design — mdsanwarhossain.me
-- 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:

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

Key Takeaways

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.


Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

Last updated: March 18, 2026