Back to Blog
February 12, 20261,000 views

Building Multi-Tenant SaaS: Database Per Tenant vs Shared Schema

The most important architectural decision in SaaS development: how to isolate tenant data. We compare three approaches with real cost analysis, migration strategies, and code examples.

The Decision That Shapes Everything

When building a SaaS product, the #1 architectural decision is tenant isolation. Get it wrong, and you'll spend months migrating later. There are three approaches, each with clear trade-offs:

Approach 1: Shared Database, Shared Schema (Row-Level Isolation)

Every tenant's data lives in the same tables, distinguished by a tenant_id column. This is the simplest and cheapest approach.

# Django: automatic tenant filtering with middleware
class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response
    
    def __call__(self, request):
        # Set tenant from JWT or subdomain
        request.tenant = get_tenant_from_request(request)
        return self.get_response(request)


class TenantManager(models.Manager):
    def get_queryset(self):
        from threading import local
        _thread_locals = local()
        tenant = getattr(_thread_locals, 'tenant', None)
        qs = super().get_queryset()
        if tenant:
            qs = qs.filter(tenant_id=tenant.id)
        return qs


class Order(models.Model):
    tenant = models.ForeignKey('Tenant', on_delete=models.CASCADE)
    # ... other fields
    
    objects = TenantManager()  # All queries auto-filtered

Pros: Simple, cheap ($50-200/month for one database), easy to deploy, shared connection pool. Cons: Risk of data leaks if you forget a filter, noisy neighbor problem (one tenant's heavy queries affect others), harder compliance (GDPR data deletion).

Approach 2: Shared Database, Separate Schemas (PostgreSQL Schemas)

Each tenant gets their own PostgreSQL schema within a single database. Tables are identical but isolated by namespace.

# Using django-tenants library
# Each request sets the schema based on subdomain

# settings.py
DATABASE_ROUTERS = ['django_tenants.routers.TenantSyncRouter']

# Middleware sets schema per request
# acme.yourapp.com -> schema 'acme'
# globex.yourapp.com -> schema 'globex'

# SQL equivalent:
# SET search_path TO 'acme';  -- all queries now hit acme's tables
# SELECT * FROM orders;       -- returns only acme's orders

Pros: Strong isolation without extra databases, easy data deletion (DROP SCHEMA CASCADE), separate indexes per tenant. Cons: Migration complexity (must migrate every schema), connection pooling is harder, max ~500 tenants before performance degrades.

Approach 3: Database Per Tenant

Each tenant gets their own database. Maximum isolation, maximum operational complexity.

Pros: Complete isolation, independent scaling, easy compliance, can offer dedicated infrastructure to enterprise clients. Cons: Expensive ($50-200 per tenant per month), connection pooling nightmare, migrations across hundreds of databases, cross-tenant analytics requires a data warehouse.

Decision Matrix: Which Approach to Choose

FactorShared SchemaSeparate SchemasSeparate DBs
Monthly cost (100 tenants)$100-200$200-500$5,000-20,000
Tenant count limitUnlimited~500~100 (manageable)
Data isolationWeak (row-level)Strong (schema-level)Complete
Compliance (GDPR/SOC2)DifficultModerateEasy
Noisy neighbor riskHighMediumNone
Best forB2C, SMB SaaSMid-market SaaSEnterprise SaaS

Our Recommendation: Start Shared, Migrate Up

Start with shared schema + row-level isolation. It gets you to market fast and handles 90% of use cases. When you land enterprise clients who need isolation guarantees, offer separate schemas or databases as a premium tier.

The key is designing your models with a tenant_id from day one. This makes migration to any approach possible later without rewriting your application.

The best multi-tenant architecture is the one that matches your current stage. A startup with 10 tenants using database-per-tenant is paying 10x what they should.

— alokknight Engineering