Skip to content
SP StackPractices
advanced By StackPractices

Multi-Tenant Data Isolation Pattern

Isolate tenant data in shared infrastructure using row-level security, schema-per-tenant, or database-per-tenant strategies. A pattern for SaaS applications.

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Multi-Tenant Data Isolation Pattern

Overview

The Multi-Tenant Data Isolation Pattern ensures each tenant in a SaaS application can only access their own data. Three primary strategies exist: database-per-tenant (strongest isolation), schema-per-tenant (good isolation, shared database), and row-level security (shared schema, tenant column + RLS). Each strategy trades isolation strength against operational cost and complexity.

When to Use

Use the Multi-Tenant Data Isolation Pattern when:

  • You are building a SaaS application serving multiple organizations
  • Tenants must never see each other’s data
  • You need to balance isolation, cost, and operational complexity
  • Compliance requirements (GDPR, HIPAA, SOC2) mandate data isolation
  • You need per-tenant backup, migration, or deletion capabilities

Solution

Strategy 1: Row-Level Security (Shared Schema)

from sqlalchemy import create_engine, Column, String, Integer, text
from sqlalchemy.orm import declarative_base, sessionmaker, Session
from contextlib import contextmanager

Base = declarative_base()

class Tenant(Base):
    __tablename__ = "tenants"
    id = Column(Integer, primary_key=True)
    name = Column(String, unique=True, nullable=False)

class Document(Base):
    __tablename__ = "documents"
    id = Column(Integer, primary_key=True)
    tenant_id = Column(Integer, nullable=False, index=True)
    title = Column(String, nullable=False)
    content = Column(String)

engine = create_engine("postgresql://user:pass@localhost/saas_db")

# Enable RLS at database level
with engine.connect() as conn:
    conn.execute(text("ALTER TABLE documents ENABLE ROW LEVEL SECURITY"))
    conn.execute(text("""
        CREATE POLICY tenant_isolation ON documents
        USING (tenant_id = current_setting('app.tenant_id')::int)
    """))
    conn.commit()

@contextmanager
def tenant_session(tenant_id: int):
    session = Session(engine)
    try:
        session.execute(text(f"SET app.tenant_id = {tenant_id}"))
        yield session
        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()

# Usage — tenant 1 can only see their documents
with tenant_session(tenant_id=1) as session:
    docs = session.query(Document).all()  # RLS filters automatically
    print(f"Tenant 1 has {len(docs)} documents")

# Tenant 2 gets a different set
with tenant_session(tenant_id=2) as session:
    docs = session.query(Document).all()
    print(f"Tenant 2 has {len(docs)} documents")

Strategy 2: Schema-Per-Tenant

from sqlalchemy import create_engine, Column, String, Integer
from sqlalchemy.orm import declarative_base, sessionmaker
import schema_manager

Base = declarative_base()

class Document(Base):
    __tablename__ = "documents"
    id = Column(Integer, primary_key=True)
    title = Column(String, nullable=False)
    content = Column(String)

engine = create_engine("postgresql://user:pass@localhost/saas_db")

def provision_tenant(tenant_name: str):
    schema = f"tenant_{tenant_name.lower().replace('-', '_')}"
    with engine.connect() as conn:
        conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema}"))
        conn.execute(text(f"SET search_path TO {schema}"))
        Base.metadata.create_all(engine)
        conn.commit()

def get_tenant_engine(tenant_name: str):
    schema = f"tenant_{tenant_name.lower().replace('-', '_')}"
    return create_engine(
        f"postgresql://user:pass@localhost/saas_db",
        connect_args={"options": f"-csearch_path={schema}"},
    )

# Usage — each tenant gets isolated schema
provision_tenant("acme")
provision_tenant("globex")

acme_engine = get_tenant_engine("acme")
AcmeSession = sessionmaker(bind=acme_engine)
session = AcmeSession()
docs = session.query(Document).all()

Strategy 3: Database-Per-Tenant

const { Pool } = require("pg");

const tenantPools = new Map();

function getTenantPool(tenantId) {
    if (!tenantPools.has(tenantId)) {
        const pool = new Pool({
            host: "localhost",
            port: 5432,
            user: "saas_app",
            password: process.env.DB_PASSWORD,
            database: `tenant_${tenantId}`,
            max: 10,
        });
        tenantPools.set(tenantId, pool);
    }
    return tenantPools.get(tenantId);
}

async function getDocuments(tenantId) {
    const pool = getTenantPool(tenantId);
    const result = await pool.query(
        "SELECT id, title, content FROM documents ORDER BY id"
    );
    return result.rows;
}

async function createDocument(tenantId, title, content) {
    const pool = getTenantPool(tenantId);
    const result = await pool.query(
        "INSERT INTO documents (title, content) VALUES ($1, $2) RETURNING id",
        [title, content]
    );
    return result.rows[0];
}

// Usage
async function main() {
    const acmeDocs = await getDocuments("acme");
    console.log("Acme docs:", acmeDocs.length);

    const doc = await createDocument("globex", "Q3 Report", "Confidential");
    console.log("Created doc:", doc.id);
}

main();

Tenant Resolution Middleware (Java)

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@Component
public class TenantInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
            HttpServletResponse response, Object handler) {
        String tenantId = request.getHeader("X-Tenant-ID");
        if (tenantId == null || tenantId.isBlank()) {
            response.setStatus(401);
            return false;
        }
        TenantContext.setTenantId(tenantId);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
            HttpServletResponse response, Object handler, Exception ex) {
        TenantContext.clear();
    }
}

// ThreadLocal tenant context
public class TenantContext {
    private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();

    public static void setTenantId(String tenantId) {
        CONTEXT.set(tenantId);
    }

    public static String getTenantId() {
        return CONTEXT.get();
    }

    public static void clear() {
        CONTEXT.remove();
    }
}

// Repository with automatic tenant filter
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;

@Repository
public class DocumentRepository {

    private final JdbcTemplate jdbc;

    public DocumentRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public List<Map<String, Object>> findAll() {
        String tenant = TenantContext.getTenantId();
        return jdbc.queryForList(
            "SELECT id, title, content FROM tenant_" + tenant + ".documents"
        );
    }
}

Explanation

Three strategies trade isolation against cost:

  • Row-Level Security (RLS): All tenants share one schema. A tenant_id column on every table. PostgreSQL RLS policies automatically filter queries. Cheapest, most scalable, but weakest isolation — a bug in RLS policy leaks data.
  • Schema-Per-Tenant: Each tenant gets a dedicated schema in a shared database. Good isolation at the schema level. Per-tenant migrations are needed. Moderate cost.
  • Database-Per-Tenant: Each tenant gets a dedicated database. Strongest isolation. Per-tenant backup, migration, and deletion are trivial. Highest cost and operational overhead.

Key concepts:

  • Tenant Context: A thread-local or request-scoped variable holding the current tenant ID. Set by middleware from JWT, API key, or header.
  • Tenant Resolution: Extract tenant ID from the request (JWT claim, subdomain, header). Validate the tenant exists and the user belongs to it.
  • Cross-Tenant Queries: Admin operations that span tenants. Must bypass RLS or query all schemas/databases explicitly.
  • Per-Tenant Migrations: Schema-per-tenant and database-per-tenant require running migrations for each tenant. Automate with a migration runner.

Variants

StrategyIsolationCostMigrationBackupUse When
Row-Level SecurityWeak (policy bug = leak)LowSingleSharedMany small tenants, cost-sensitive
Schema-Per-TenantMedium (schema boundary)MediumPer-tenantPer-schemaMedium tenants, moderate isolation
Database-Per-TenantStrong (full DB boundary)HighPer-tenantPer-DBLarge tenants, compliance, enterprise

What Works

  • Use RLS for cost efficiency — thousands of tenants in one database, minimal overhead
  • Use schema-per-tenant for mid-size SaaS — good balance of isolation and cost
  • Use database-per-tenant for enterprise — strongest isolation, per-tenant SLA
  • Always set tenant context in middleware — never rely on application code to filter
  • Validate tenant membership — check that the user belongs to the tenant on every request
  • Index the tenant_id column — RLS filters by tenant_id on every query
  • Automate per-tenant migrations — write a migration runner that loops over all tenants
  • Provide tenant provisioning/deletion APIs — automate onboarding and offboarding
  • Monitor cross-tenant queries — admin queries that bypass isolation should be audited

Common Mistakes

  • Forgetting to set tenant context in a background job — the job runs without isolation
  • Relying on application-level filtering instead of RLS — a missed WHERE tenant_id = ? leaks data
  • Not indexing tenant_id — every query does a full table scan filtered by RLS
  • Running a single migration instead of per-tenant migrations — some tenants have stale schemas
  • Not testing isolation — write integration tests that verify tenant A cannot read tenant B’s data
  • Sharing connection pools across tenants in database-per-tenant — connection leaks to wrong DB
  • Not cleaning up tenant data on offboarding — deleted tenants’ data persists in shared tables
  • Using tenant_id in URLs without validation — tenant ID spoofing allows cross-tenant access

Frequently Asked Questions

Q: Which strategy should I choose? A: Start with row-level security if you have many small tenants and cost is a concern. Use schema-per-tenant for mid-size SaaS with moderate isolation needs. Use database-per-tenant for enterprise customers with strict compliance requirements or per-tenant SLAs.

Q: How do I handle per-tenant migrations? A: Write a migration runner that iterates over all tenants. For schema-per-tenant, run the migration in each schema. For database-per-tenant, connect to each database and run the migration. Track migration versions per tenant to handle failures gracefully.

Q: Can I mix strategies? A: Yes. Start with RLS for small tenants on a shared database. Promote large tenants to their own database when they outgrow the shared instance. Route requests based on tenant configuration. This is called the hybrid or tiered approach.