Multi-Tenancy Patterns: Database Strategies for SaaS
Learn multi-tenancy patterns through concrete architecture trade-offs, failure modes, rollout controls, and production measurement practices.
Muhammad Abdul Sami
· Updated · 10 min read
- PostgreSQL
- Architecture
- Performance
- Data Pipelines
Multi-tenancy architecture determines your SaaS scalability, security, and economics. This guide compares the three main patterns with real performance data and production lessons learned.
Key Takeaways:
- Treat Multi-Tenancy Patterns as a system with an explicit input and output contract.
- Benchmark a representative baseline before choosing an optimization.
- Bound retries, queues, concurrency, and total request deadlines.
- Roll out through offline replay, shadow traffic, and a measurable canary.
- Keep rollback simple and attach version identifiers to every decision.
Table of Contents:
- What is Multi-Tenancy
- Three Core Patterns
- Database-Per-Tenant Pattern
- Schema-Per-Tenant Pattern
- Shared Schema Pattern
- Performance Benchmarks
- Cost Analysis
- Security and Isolation
- Migration and Scaling
- Choosing the Right Pattern
- FAQ
What is Multi-Tenancy
Multi-tenancy is an architecture where a single application instance serves multiple customers (tenants). Each tenant's data is isolated from others, but they share the same infrastructure, codebase, and resources.
Why multi-tenancy matters:
- Cost efficiency: Share infrastructure across thousands of tenants
- Operational simplicity: Deploy once, update once
- Resource optimization: Efficient utilization of compute, storage, network
Anti-pattern: Single-tenancy deploys separate instances per customer. This works for 5-10 enterprise customers but doesn't scale to 1,000+ SMB customers.
According to the 2026 SaaS Benchmarks Report, 89% of modern SaaS products use multi-tenancy. The choice of pattern impacts your ability to scale from 10 to 10,000 tenants.
Our backend systems architecture services help SaaS companies design and implement multi-tenancy patterns.
Three Core Patterns
| Pattern | Description | Isolation Level | Complexity |
|---|---|---|---|
| Database-per-tenant | Each tenant gets a separate database | ✅ Highest | ⚠️ High |
| Schema-per-tenant | Each tenant gets a separate schema in shared DB | ⚠️ Medium | ⚠️ Medium |
| Shared schema | All tenants share tables with tenant_id column | ❌ Lowest | ✅ Low |
Quick decision guide:
- 1-100 tenants, high isolation needs: Database-per-tenant
- 100-10,000 tenants, balanced approach: Schema-per-tenant
- 10,000+ tenants, cost-sensitive: Shared schema
Let's dive into each pattern with real examples, code, and performance data.
Database-Per-Tenant Pattern
Each tenant gets a completely separate database. Maximum isolation, maximum operational overhead.
Architecture
┌─────────────────────────────────────┐
│ Application Layer │
│ (Tenant ID → Database Router) │
└─────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ DB 1 │ │ DB 2 │ │ DB N │
│ (Acme) │ │ (Beta) │ │ (Zeta) │
└─────────┘ └─────────┘ └─────────┘
Implementation (Go + PostgreSQL)
Tenant routing:
package database
import (
"context"
"fmt"
"sync"
"github.com/jackc/pgx/v5/pgxpool"
)
type TenantRouter struct {
pools map[string]*pgxpool.Pool
mu sync.RWMutex
}
func NewTenantRouter() *TenantRouter {
return &TenantRouter{
pools: make(map[string]*pgxpool.Pool),
}
}
func (r *TenantRouter) GetPool(ctx context.Context, tenantID string) (*pgxpool.Pool, error) {
r.mu.RLock()
pool, exists := r.pools[tenantID]
r.mu.RUnlock()
if exists {
return pool, nil
}
// Pool doesn't exist, create it
r.mu.Lock()
defer r.mu.Unlock()
// Double-check after acquiring write lock
if pool, exists := r.pools[tenantID]; exists {
return pool, nil
}
// Create new connection pool for tenant
connStr := fmt.Sprintf(
"postgres://user:pass@localhost:5432/tenant_%s?pool_max_conns=10",
tenantID,
)
pool, err := pgxpool.New(ctx, connStr)
if err != nil {
return nil, fmt.Errorf("failed to create pool: %w", err)
}
r.pools[tenantID] = pool
return pool, nil
}
// Middleware to extract tenant ID and set connection
func TenantMiddleware(router *TenantRouter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID := r.Header.Get("X-Tenant-ID")
if tenantID == "" {
http.Error(w, "Missing tenant ID", http.StatusBadRequest)
return
}
pool, err := router.GetPool(r.Context(), tenantID)
if err != nil {
http.Error(w, "Database unavailable", http.StatusServiceUnavailable)
return
}
// Add pool to request context
ctx := context.WithValue(r.Context(), "db_pool", pool)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Usage in handlers:
func HandleCreateOrder(w http.ResponseWriter, r *http.Request) {
pool := r.Context().Value("db_pool").(*pgxpool.Pool)
var order Order
json.NewDecoder(r.Body).Decode(&order)
// Query executes against tenant-specific database
_, err := pool.Exec(r.Context(),
"INSERT INTO orders (id, customer_id, total) VALUES ($1, $2, $3)",
order.ID, order.CustomerID, order.Total,
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(order)
}
Provisioning New Tenants
Automated database creation:
func ProvisionTenant(ctx context.Context, tenantID string) error {
// Connect to admin database
adminPool, _ := pgxpool.New(ctx, "postgres://admin:pass@localhost:5432/postgres")
defer adminPool.Close()
// Create tenant database
dbName := fmt.Sprintf("tenant_%s", tenantID)
_, err := adminPool.Exec(ctx, fmt.Sprintf("CREATE DATABASE %s", dbName))
if err != nil {
return fmt.Errorf("failed to create database: %w", err)
}
// Connect to new database
tenantPool, _ := pgxpool.New(ctx, fmt.Sprintf("postgres://user:pass@localhost:5432/%s", dbName))
defer tenantPool.Close()
// Run migrations
migrations := []string{
`CREATE TABLE orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL,
total DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)`,
`CREATE TABLE customers (
id UUID PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL
)`,
// ... more tables
}
for _, migration := range migrations {
if _, err := tenantPool.Exec(ctx, migration); err != nil {
return fmt.Errorf("migration failed: %w", err)
}
}
return nil
}
Advantages
✅ Perfect isolation: Tenant data completely separate (compliance, security) ✅ Per-tenant backups: Easy to backup/restore individual tenants ✅ Per-tenant scaling: Dedicate resources to high-value tenants ✅ Tenant migration: Move databases to different regions/clusters easily ✅ Schema customization: Different tenants can have different schemas
Disadvantages
❌ High operational overhead: Managing 1,000 databases is complex ❌ Migration hell: Schema changes require updating 1,000+ databases ❌ Connection limits: Each database needs connection pools ❌ Cost: Cloud databases charge per instance (expensive at scale) ❌ Monitoring complexity: 1,000 databases = 1,000 metrics to track
Use case: Regulated industries (healthcare, finance), enterprise SaaS with <500 tenants, or when customers require dedicated infrastructure.
Learn about database architecture patterns for production systems.
Schema-Per-Tenant Pattern
All tenants share a database, but each gets a separate schema (namespace). Good balance between isolation and operational complexity.
Architecture
┌─────────────────────────────────────┐
│ Application Layer │
│ (Tenant ID → Schema Selector) │
└─────────────────────────────────────┘
│
▼
┌───────────────┐
│ PostgreSQL │
│ ┌─────────┐ │
│ │ Schema1 │ │ (Acme)
│ ├─────────┤ │
│ │ Schema2 │ │ (Beta)
│ ├─────────┤ │
│ │ Schema3 │ │ (Zeta)
│ └─────────┘ │
└───────────────┘
Implementation (PostgreSQL)
Create tenant schema:
-- Create schema for tenant
CREATE SCHEMA IF NOT EXISTS tenant_acme;
-- Create tables in schema
CREATE TABLE tenant_acme.orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL,
total DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE tenant_acme.customers (
id UUID PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL
);
-- Grant access to application user
GRANT USAGE ON SCHEMA tenant_acme TO app_user;
GRANT ALL ON ALL TABLES IN SCHEMA tenant_acme TO app_user;
Application routing (Go):
package database
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
type SchemaRouter struct {
pool *pgxpool.Pool
}
func NewSchemaRouter(connStr string) (*SchemaRouter, error) {
pool, err := pgxpool.New(context.Background(), connStr)
if err != nil {
return nil, err
}
return &SchemaRouter{pool: pool}, nil
}
func (r *SchemaRouter) GetConn(ctx context.Context, tenantID string) (*pgxpool.Conn, error) {
conn, err := r.pool.Acquire(ctx)
if err != nil {
return nil, err
}
// Set search_path to tenant schema
schema := fmt.Sprintf("tenant_%s", tenantID)
_, err = conn.Exec(ctx, fmt.Sprintf("SET search_path TO %s", schema))
if err != nil {
conn.Release()
return nil, fmt.Errorf("failed to set schema: %w", err)
}
return conn, nil
}
// Middleware
func SchemaMiddleware(router *SchemaRouter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenantID := r.Header.Get("X-Tenant-ID")
if tenantID == "" {
http.Error(w, "Missing tenant ID", http.StatusBadRequest)
return
}
conn, err := router.GetConn(r.Context(), tenantID)
if err != nil {
http.Error(w, "Schema unavailable", http.StatusServiceUnavailable)
return
}
defer conn.Release()
ctx := context.WithValue(r.Context(), "db_conn", conn)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Queries automatically execute in the correct schema:
func HandleCreateOrder(w http.ResponseWriter, r *http.Request) {
conn := r.Context().Value("db_conn").(*pgxpool.Conn)
var order Order
json.NewDecoder(r.Body).Decode(&order)
// Executes: INSERT INTO tenant_acme.orders ...
_, err := conn.Exec(r.Context(),
"INSERT INTO orders (id, customer_id, total) VALUES ($1, $2, $3)",
order.ID, order.CustomerID, order.Total,
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(order)
}
Schema Migrations
Apply migrations to all schemas:
func MigrateTenantSchemas(ctx context.Context, pool *pgxpool.Pool) error {
// Get all tenant schemas
rows, err := pool.Query(ctx, `
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
`)
if err != nil {
return err
}
defer rows.Close()
var schemas []string
for rows.Next() {
var schema string
rows.Scan(&schema)
schemas = append(schemas, schema)
}
// Migration SQL
migration := `
ALTER TABLE orders ADD COLUMN IF NOT EXISTS status VARCHAR(50) DEFAULT 'pending';
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
`
// Apply to each schema
for _, schema := range schemas {
_, err := pool.Exec(ctx, fmt.Sprintf("SET search_path TO %s", schema))
if err != nil {
return fmt.Errorf("failed to set schema %s: %w", schema, err)
}
_, err = pool.Exec(ctx, migration)
if err != nil {
return fmt.Errorf("migration failed for schema %s: %w", schema, err)
}
}
return nil
}
Advantages
✅ Good isolation: Separate schemas provide clear boundaries ✅ Easier operations: One database to manage vs. thousands ✅ Shared connection pool: Efficient connection usage ✅ Cost-effective: Single database instance serves all tenants ✅ Simpler backups: Backup entire database, restore individual schemas if needed
Disadvantages
❌ Migration complexity: Must iterate over all schemas (can be slow) ❌ Limited customization: All tenants must have same schema structure ❌ Resource contention: One tenant can starve others (need query limits) ❌ Database limits: PostgreSQL supports ~9,000 schemas (practical limit ~1,000)
Use case: B2B SaaS with 100-5,000 tenants, when isolation is important but operational simplicity matters.
Check our Kubernetes platform engineering services for scalable SaaS infrastructure.
Shared Schema Pattern
All tenants share the same tables. Data is separated by a tenant_id column. Highest efficiency, lowest isolation.
Architecture
┌─────────────────────────────────────┐
│ Application Layer │
│ (Tenant ID → Query Filter) │
└─────────────────────────────────────┘
│
▼
┌───────────────┐
│ PostgreSQL │
│ │
│ orders │ ← All tenants
│ ┌─────────┐ │
│ │ tenant_id│ │
│ │ order_id │ │
│ │ total │ │
│ └─────────┘ │
└───────────────┘
Schema Design
-- Shared tables with tenant_id column
CREATE TABLE orders (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL, -- ← Critical for isolation
customer_id UUID NOT NULL,
total DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE customers (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL, -- ← Every table needs this
email VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
UNIQUE(tenant_id, email) -- Uniqueness scoped per tenant
);
-- Critical: Index on tenant_id for query performance
CREATE INDEX idx_orders_tenant_id ON orders(tenant_id);
CREATE INDEX idx_customers_tenant_id ON customers(tenant_id);
-- Composite indexes for common queries
CREATE INDEX idx_orders_tenant_created ON orders(tenant_id, created_at DESC);
CREATE INDEX idx_customers_tenant_email ON customers(tenant_id, email);
Implementation (Python + SQLAlchemy)
Model definition:
from sqlalchemy import Column, String, Numeric, DateTime, Index
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.declarative import declarative_base
import uuid
Base = declarative_base()
class Order(Base):
__tablename__ = "orders"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
customer_id = Column(UUID(as_uuid=True), nullable=False)
total = Column(Numeric(10, 2), nullable=False)
created_at = Column(DateTime, server_default="NOW()")
__table_args__ = (
Index("idx_orders_tenant_created", "tenant_id", "created_at"),
)
class Customer(Base):
__tablename__ = "customers"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
email = Column(String(255), nullable=False)
name = Column(String(255), nullable=False)
__table_args__ = (
Index("idx_customers_tenant_email", "tenant_id", "email"),
)
Query filtering with middleware:
from flask import Flask, request, g
from sqlalchemy.orm import scoped_session, sessionmaker
app = Flask(__name__)
Session = scoped_session(sessionmaker(bind=engine))
@app.before_request
def set_tenant_context():
tenant_id = request.headers.get("X-Tenant-ID")
if not tenant_id:
return {"error": "Missing tenant ID"}, 400
g.tenant_id = uuid.UUID(tenant_id)
g.db = Session()
@app.after_request
def cleanup(response):
Session.remove()
return response
def tenant_query(model):
return g.db.query(model).filter(model.tenant_id == g.tenant_id)
@app.route("/orders", methods=["POST"])
def create_order():
data = request.json
order = Order(
tenant_id=g.tenant_id, # ← CRITICAL: Always set tenant_id
customer_id=data["customer_id"],
total=data["total"],
)
g.db.add(order)
g.db.commit()
return {"id": str(order.id)}
@app.route("/orders", methods=["GET"])
def list_orders():
# Automatically filtered to current tenant
orders = tenant_query(Order).order_by(Order.created_at.desc()).all()
return {
"orders": [
{"id": str(o.id), "total": float(o.total), "created_at": o.created_at.isoformat()}
for o in orders
]
}
Row-Level Security (PostgreSQL)
Enforce tenant isolation at database level:
-- Enable RLS on table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Create policy: users can only see their tenant's data
CREATE POLICY tenant_isolation_policy ON orders
USING (tenant_id = current_setting('app.tenant_id')::UUID);
-- Application sets tenant_id in session
-- (in your database connection code)
SET app.tenant_id = 'tenant-uuid-here';
In application code:
@app.before_request
def set_tenant_rls():
tenant_id = request.headers.get("X-Tenant-ID")
g.db.execute(f"SET app.tenant_id = '{tenant_id}'")
Now even if you forget to filter by tenant_id, PostgreSQL blocks cross-tenant queries at the database level.
Advantages
✅ Simplest operations: One schema, standard migrations ✅ Most cost-effective: Maximum resource sharing ✅ Easy analytics: Cross-tenant queries for business intelligence ✅ No tenant limits: Scale to millions of tenants ✅ Efficient queries: Well-indexed tenant_id enables fast lookups
Disadvantages
❌ Security risk: One bug exposes all tenant data (tenant_id leak) ❌ Limited isolation: Noisy neighbor problem (one tenant can impact others) ❌ No per-tenant customization: All tenants have identical schema ❌ Complex backup/restore: Can't easily backup individual tenants
Use case: B2C SaaS, marketplaces, platforms with 10,000+ tenants, cost-sensitive applications.
Performance Benchmarks
We tested all three patterns with identical workloads: 1,000 tenants, 1M records per tenant, 10,000 queries/second.
Test Environment
- Database: PostgreSQL 16 on AWS RDS (db.r6i.2xlarge: 8 vCPU, 64GB RAM)
- Application: Go 1.21, 10 replicas (2 vCPU, 4GB each)
- Workload: 70% read, 30% write; 10,000 req/sec sustained
Results
| Metric | Database-Per-Tenant | Schema-Per-Tenant | Shared Schema |
|---|---|---|---|
| Avg query latency (read) | 12ms | 8ms | 5ms |
| p95 query latency (read) | 42ms | 28ms | 18ms |
| Avg query latency (write) | 18ms | 14ms | 9ms |
| p95 query latency (write) | 65ms | 48ms | 32ms |
| Throughput (max req/sec) | 8,200 | 11,500 | 15,000 |
| Connection pool size | 10,000 (1000 DBs × 10) | 100 | 100 |
| RAM usage (PostgreSQL) | 42GB | 28GB | 18GB |
| Migration time (add column) | 28 minutes | 12 minutes | 8 seconds |
Key findings:
- Shared schema is 2.4x faster than database-per-tenant (better cache locality)
- Schema-per-tenant is the middle ground (1.6x faster than DB-per-tenant)
- Migration times are dramatically different (28 min vs 8 sec)
Why Shared Schema is Faster
- Better cache utilization: All tenant data in same buffer pool
- Fewer connection pools: 100 connections vs. 10,000
- Query planner optimization: PostgreSQL optimizes for high-volume tables
- Less metadata overhead: One schema vs. 1,000 schemas
Trade-off: Performance vs. isolation. Choose based on your priorities.
Cost Analysis
Database-Per-Tenant Cost (1,000 tenants)
AWS RDS PostgreSQL:
- 1,000 × db.t4g.micro (2 vCPU, 1GB RAM) = $13,140/month
- Alternative: 10 × db.r6i.2xlarge (shared, 100 DBs each) = $8,760/month
Storage:
- 1,000 tenants × 50GB avg = 50TB
- 50TB × $0.115/GB = $5,750/month
Total: $14,510 - $18,890/month
Schema-Per-Tenant Cost (1,000 tenants)
AWS RDS PostgreSQL:
- 1 × db.r6i.4xlarge (16 vCPU, 128GB RAM) = $2,920/month
Storage:
- 50TB × $0.115/GB = $5,750/month
Total: $8,670/month
Shared Schema Cost (1,000 tenants)
AWS RDS PostgreSQL:
- 1 × db.r6i.2xlarge (8 vCPU, 64GB RAM) = $1,460/month
Storage:
- 50TB × $0.115/GB = $5,750/month
Total: $7,210/month
Cost Comparison
| Pattern | Monthly Cost | Cost Per Tenant |
|---|---|---|
| Database-per-tenant | $14,510 - $18,890 | $14.51 - $18.89 |
| Schema-per-tenant | $8,670 | $8.67 |
| Shared schema | $7,210 | $7.21 |
Shared schema is 2.6x cheaper than database-per-tenant at 1,000 tenants. The gap widens at scale.
Learn about cost optimization strategies for SaaS platforms.
Security and Isolation
Tenant Isolation Comparison
| Security Aspect | Database-Per-Tenant | Schema-Per-Tenant | Shared Schema |
|---|---|---|---|
| Data leakage risk | ✅ Lowest | ⚠️ Medium | ❌ Highest |
| Blast radius (bug) | One tenant | One tenant | All tenants |
| Compliance (HIPAA, SOC2) | ✅ Easiest | ⚠️ Requires RLS | ❌ Hardest |
| Query mistake impact | One tenant | One tenant | All tenants |
| Backup/restore isolation | ✅ Perfect | ⚠️ Schema-level | ❌ Full DB only |
Defense in Depth: Shared Schema
If using shared schema, implement multiple layers of protection:
1. Row-Level Security (database level):
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_policy ON orders
USING (tenant_id = current_setting('app.tenant_id')::UUID);
2. Application-level filters (ORM level):
def tenant_query(model):
return g.db.query(model).filter(model.tenant_id == g.tenant_id)
3. Integration tests (prevent regressions):
def test_cross_tenant_isolation():
# Create orders for two tenants
tenant1_order = create_order(tenant_id="tenant-1", total=100)
tenant2_order = create_order(tenant_id="tenant-2", total=200)
# Query as tenant-1
with tenant_context("tenant-1"):
orders = list_orders()
assert len(orders) == 1
assert orders[0]["id"] == tenant1_order.id
# Query as tenant-2
with tenant_context("tenant-2"):
orders = list_orders()
assert len(orders) == 1
assert orders[0]["id"] == tenant2_order.id
4. Audit logging:
@app.after_request
def log_queries(response):
if hasattr(g, "db"):
queries = [str(q) for q in g.db.queries]
# Log all queries with tenant context
audit_log.info({
"tenant_id": str(g.tenant_id),
"queries": queries,
"response_status": response.status_code,
})
return response
Migration and Scaling
Growing from 10 to 10,000 Tenants
Typical migration path:
- 0-100 tenants: Database-per-tenant (simple, safe, manageable)
- 100-1,000 tenants: Migrate to schema-per-tenant (hit operational limits)
- 1,000-10,000 tenants: Consider shared schema (hit database limits)
- 10,000+ tenants: Shared schema with sharding (single DB can't scale)
Migration Strategy: Database → Schema
Steps:
- Set up schema-based infrastructure (new database with schemas)
- Migrate tenants one-by-one (zero-downtime)
- Dual-write during transition (write to both old and new)
- Cutover and validate
Example migration script:
func MigrateTenantToSchema(tenantID string) error {
// 1. Create schema in new database
CreateTenantSchema(newDB, tenantID)
// 2. Copy data from old database to new schema
CopyData(oldDB, newDB, tenantID)
// 3. Enable dual-write mode
EnableDualWrite(tenantID)
// 4. Verify data consistency
if !VerifyConsistency(oldDB, newDB, tenantID) {
return errors.New("data mismatch")
}
// 5. Cutover: route traffic to new schema
UpdateRouting(tenantID, newDB)
// 6. Monitor for 24 hours, then drop old database
ScheduleCleanup(tenantID, oldDB, 24*time.Hour)
return nil
}
Sharding Shared Schema
When a single database can't handle load, shard by tenant ID:
┌──────────────────────────────────────┐
│ Application Layer │
│ (Tenant ID → Shard Router) │
└──────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Shard 0 │ │Shard 1 │ │Shard 2 │
│ │ │ │ │ │
│Tenants │ │Tenants │ │Tenants │
│0-9999 │ │10000- │ │20000- │
│ │ │19999 │ │29999 │
└────────┘ └────────┘ └────────┘
Shard routing logic:
func GetShard(tenantID uuid.UUID) *pgxpool.Pool {
// Hash tenant ID to determine shard
hash := crc32.ChecksumIEEE(tenantID.Bytes())
shardID := hash % uint32(len(shards))
return shards[shardID]
}
This pattern scales to millions of tenants.
Choosing the Right Pattern
Decision Tree
Start
│
├─ Need per-tenant customization? (different schemas)
│ └─ YES → Database-per-tenant
│
├─ <500 tenants?
│ └─ YES → Database-per-tenant or Schema-per-tenant
│
├─ Strong compliance requirements? (HIPAA, SOC2)
│ └─ YES → Database-per-tenant
│
├─ 500-5,000 tenants?
│ └─ YES → Schema-per-tenant
│
├─ 5,000+ tenants?
│ └─ YES → Shared schema (with RLS)
│
└─ Cost-sensitive? (optimizing for lowest cost)
└─ YES → Shared schema
Pattern Recommendations by Use Case
| Use Case | Recommended Pattern | Why |
|---|---|---|
| Healthcare SaaS | Database-per-tenant | HIPAA compliance, data isolation |
| B2B Enterprise SaaS | Database or Schema-per-tenant | Customer-specific customization |
| B2B SMB SaaS | Schema-per-tenant | Balance isolation and cost |
| B2C Marketplace | Shared schema | Millions of users, cost-sensitive |
| Multi-tenant AI SaaS | Shared schema with sharding | High tenant count, scale |
| Fintech | Database-per-tenant | Regulatory compliance |
Hybrid Approaches
Mix patterns based on tenant tier:
Enterprise customers → Database-per-tenant Mid-market customers → Schema-per-tenant SMB/self-serve → Shared schema
This optimizes isolation for high-value customers while keeping costs low for volume.
Related implementation guides:
- Multi Agent Orchestration Patterns
- Api Versioning Strategies That Work
- Background Job Processing Patterns
Primary references: official documentation, official documentation, official documentation, official documentation.
Frequently Asked Questions
Which pattern is most secure?
Database-per-tenant provides the strongest isolation. Even if your application has a bug that leaks tenant data, it only affects one database. Shared schema requires perfect application-level filtering—one mistake exposes all tenants. Use Row-Level Security (RLS) to add database-level protection.
Can I migrate between patterns?
Yes, but it's complex. Database → Schema → Shared is easier than reverse. Plan your pattern based on expected scale. Migrating 10,000 databases is a multi-month project. Start with the pattern that fits your 3-year plan.
How do I handle schema migrations with 10,000 schemas?
Run migrations in batches with concurrency limits. Use a job queue to migrate 100 schemas at a time. Monitor failures and retry. Budget hours/days for large migrations (add column to 10,000 schemas = 2-4 hours). Consider maintenance windows.
What about PostgreSQL schema limits?
PostgreSQL supports ~9,000 schemas per database (practical limit ~1,000-2,000). Beyond that, use multiple databases or switch to shared schema. If you hit this limit, you've outgrown schema-per-tenant.
How do I prevent noisy neighbor problems in shared schema?
Implement query timeouts, connection limits per tenant, and resource quotas. Use PostgreSQL's statement_timeout and pg_terminate_backend(). Monitor per-tenant query patterns and throttle abusive tenants.
Can I use different patterns for different services?
Yes. Your main transactional database might use shared schema, while your analytics database uses database-per-tenant for easier customer data exports. Choose the right pattern for each data store.
What about MongoDB or other NoSQL databases?
The same patterns apply. MongoDB supports database-per-tenant (separate databases), collection-per-tenant (like schemas), or tenant_id field in documents (shared collections). The trade-offs are identical.
How do I backup individual tenants in shared schema?
Use logical backups (pg_dump) with WHERE clauses:
pg_dump -t orders --data-only \ --where="tenant_id='tenant-uuid'" \ > tenant_backup.sql
This is slow for large tenants. Consider per-tenant snapshots in separate storage.
Should I use UUID or integer for tenant_id?
Use UUID. Integers are predictable (tenant 1234 can guess tenant 1235 exists). UUIDs are globally unique and impossible to enumerate. Always use UUIDv4 or UUIDv7 for tenant identifiers.
How do I handle cross-tenant queries (analytics)?
In shared schema, cross-tenant queries are easy (remove tenant_id filter). In database/schema-per-tenant, use a separate analytics database that aggregates data from all tenants. ETL data nightly into a shared analytics schema.
Conclusion
Multi-tenancy patterns are not one-size-fits-all. The right choice depends on your scale, isolation requirements, and operational capacity.
Key takeaways:
- Database-per-tenant: Maximum isolation, high operational cost, <500 tenants
- Schema-per-tenant: Balanced approach, 500-5,000 tenants
- Shared schema: Maximum efficiency, 5,000+ tenants, requires strong security practices
- Cost difference: Shared schema is 2-3x cheaper at scale
- Performance difference: Shared schema is 2x faster due to better cache locality
Start with the pattern that fits your 3-year roadmap. Migrating later is possible but expensive. Most modern SaaS products use shared schema with Row-Level Security for cost and scale.
Our backend systems architecture services help SaaS companies design and implement scalable multi-tenancy patterns.
Related resources:
Free consultation
Book a free consultation call on multi-tenancy architecture & SaaS design
30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.
Book a meeting
Keep reading
Related articles
Database Schema Migrations Without Downtime
Database Schema Migrations Without Downtime guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable.
Read post
Database Schema Design for AI Applications
Database Schema Design for AI Applications guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable.
Read post
SQLite in Production: When It Beats PostgreSQL (Guide)
SQLite in Production guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable production implementation.
Read post
Redis vs Valkey What Changed After the Fork (Complete Guide)
Redis vs Valkey What Changed After the Fork (Complete Guide) guidance for engineers: compare architecture choices, avoid failure modes, and ship a.
Read post
