Multi-Tenancy in SaaS: The Three Patterns and When to Use Each
Schema isolation vs. row-level security vs. separate databases. A practical breakdown from someone who's implemented all three in production.
Multi-tenancy is one of those architectural decisions you make early and live with for years. Get it wrong and you’ll either have a security incident, a scaling problem, or a refactor that takes six months. Get it right and you’ll barely think about it.
I’ve built multi-tenant systems three different ways in production. Here’s an honest assessment of each.
What We’re Solving
Multi-tenancy means one application instance serves multiple customers (tenants), with their data isolated from each other. The core challenge is: how do you store and query that data efficiently while guaranteeing one tenant can never see another’s data?
The three main approaches:
- Shared schema with row-level security — everyone in the same tables, isolated by a
tenant_idcolumn - Schema-per-tenant — separate PostgreSQL schemas per tenant, same database
- Database-per-tenant — completely separate databases per tenant
Let me walk through each with real trade-offs.
Pattern 1: Shared Schema + Row-Level Security
Every tenant’s data lives in the same tables. You add a tenant_id foreign key to every tenant-scoped model, and PostgreSQL’s Row-Level Security (RLS) enforces that queries only return rows belonging to the current tenant.
Django Implementation
Add tenant_id to your models and set up a custom manager:
class TenantManager(models.Manager):
def get_queryset(self):
tenant = get_current_tenant() # from thread-local or context var
return super().get_queryset().filter(tenant=tenant)
class Order(models.Model):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
customer_name = models.CharField(max_length=200)
total = models.DecimalField(max_digits=10, decimal_places=2)
objects = TenantManager()
all_objects = models.Manager() # bypass tenant filter when needed
For the database-level enforcement with PostgreSQL RLS:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::integer);
Set the tenant context at the start of each request:
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated:
tenant = request.user.tenant
set_current_tenant(tenant)
with connection.cursor() as cursor:
cursor.execute(
"SET LOCAL app.tenant_id = %s",
[tenant.id]
)
return self.get_response(request)
When to Use It
- Early stage product with unknown tenant count and size
- Tenants have broadly similar data volumes
- You want a single codebase and deployment without database management overhead
- You need cross-tenant analytics (aggregate queries across all tenants)
The Real Trade-offs
Pros: Simple deployment, efficient use of connections, easy to add tenants (no DDL operations), single migration path.
Cons: One misconfigured query leaks data across tenants. Index design is harder — you need composite indexes that include tenant_id. One large tenant can affect query performance for everyone (noisy neighbor problem). Compliance customers sometimes reject “shared database” architectures on principle, even with RLS.
I’ve built two systems on this pattern. Both times the noisy neighbor problem eventually surfaced with 1-2 tenants who had 10x more data than the median.
Pattern 2: Schema-Per-Tenant
PostgreSQL supports multiple schemas within a single database. Each tenant gets their own schema (e.g., tenant_123.orders, tenant_456.orders). The application switches the search_path to the tenant’s schema at request time.
Django Implementation
The django-tenants library handles most of this:
# settings.py
INSTALLED_APPS = [
'django_tenants',
# ...
]
DATABASE_ROUTERS = ('django_tenants.routers.TenantSyncRouter',)
TENANT_MODEL = 'tenants.Client'
TENANT_DOMAIN_MODEL = 'tenants.Domain'
SHARED_APPS = [
'django_tenants',
'django.contrib.contenttypes',
'tenants', # tenant management
]
TENANT_APPS = [
'orders',
'products',
# other tenant-specific apps
]
Each request, django-tenants reads the subdomain or hostname, looks up the tenant, and sets search_path = tenant_schema, public.
Creating a new tenant:
from tenants.models import Client, Domain
tenant = Client(schema_name='tenant_acme', name='Acme Corp')
tenant.save() # triggers CREATE SCHEMA tenant_acme + runs migrations
domain = Domain(domain='acme.yourapp.com', tenant=tenant, is_primary=True)
domain.save()
When to Use It
- B2B SaaS where tenant data isolation is a selling point
- Tenants need schema-level customizations (adding columns, different indexes)
- You anticipate compliance requirements (SOC 2, ISO 27001) where logical isolation isn’t enough
- Medium scale (hundreds to low thousands of tenants)
The Real Trade-offs
Pros: Strong isolation without multiple databases. Each tenant can have different indexes. Easier per-tenant backup and restore. No cross-tenant query accidents (wrong search_path returns an error, not another tenant’s data).
Cons: Schema migrations become non-trivial — you migrate every tenant schema, which takes time proportional to tenant count. We had ~300 tenants and a complex migration that took 45 minutes to run. Connection pooling is harder (PgBouncer transaction mode doesn’t work well with search_path). New tenant creation requires DDL operations.
This is what I used for Vender.cloud. For a B2B platform with paying customers who care about their data, schema isolation gives you something tangible to point to.
Pattern 3: Database-Per-Tenant
Each tenant gets their own PostgreSQL database. The application dynamically selects the database connection at request time using Django’s multiple-database support.
# Dynamically configure tenant databases
DATABASES = {
'default': {...}, # for shared data (tenant registry, billing)
}
def get_tenant_db_config(tenant):
return {
'ENGINE': 'django.db.backends.postgresql',
'NAME': f'tenant_{tenant.id}',
'USER': 'app',
'PASSWORD': settings.DB_PASSWORD,
'HOST': tenant.db_host or settings.DEFAULT_DB_HOST,
}
# In middleware, add the tenant DB to DATABASES dynamically
# and use a custom router to direct queries there
This pattern requires more infrastructure — either one database server per tenant, or many tenant databases on shared servers with careful resource limits.
When to Use It
- Enterprise customers who contractually require data residency (their data in their region or their own infrastructure)
- Tenants with massive data volumes that would overwhelm shared infrastructure
- Maximum isolation for regulated industries (healthcare, finance, government)
The Real Trade-offs
Pros: Complete isolation. One tenant’s performance problems literally cannot affect another. Easy to move a tenant to dedicated hardware. Simplest backup/restore story per tenant.
Cons: Operational overhead is significant. N databases means N times the maintenance, monitoring, and migration complexity. Connection limits become a real constraint — PostgreSQL handles a limited number of connections, and if each tenant needs a connection pool, you hit limits fast. We use PgBouncer aggressively and still have to manage this carefully.
I’ve used this for government systems where the client specified that their data could not share any infrastructure with other customers. It works, but it’s expensive to operate.
Choosing the Right Pattern
Here’s my honest decision framework:
| Situation | Pattern |
|---|---|
| Early-stage product, < 50 tenants, validating PMF | Shared schema |
| Growth-stage B2B, compliance matters, < 1000 tenants | Schema-per-tenant |
| Enterprise / government, contractual isolation | Database-per-tenant |
| One tenant has 100x more data than others | Consider hybrid |
The hybrid approach worth mentioning: shared schema for small tenants, schema-per-tenant for “professional” plans, database-per-tenant for “enterprise” plans. This is what many mature SaaS platforms do. The downside is you now have three code paths to maintain.
What I’d Do Today
Starting a new SaaS product: shared schema with RLS. It’s the fastest to build and the easiest to reason about. The data isolation concern is real but manageable with careful ORM manager design and PostgreSQL RLS as the backup.
If I knew from day one that enterprise clients with data residency requirements were part of the go-to-market, I’d start with schema-per-tenant. The django-tenants library handles the heavy lifting, and the operational complexity at small scale is acceptable.
Database-per-tenant I’d only reach for under explicit contractual requirements or when the math on tenant data volumes makes shared infrastructure impractical.
The worst outcome is choosing the “most architecturally correct” option upfront and spending six months on infrastructure before you’ve validated that anyone wants the product. Migrations between patterns are painful but possible. Shipping nothing because you were optimizing prematurely is fatal.
Working on something similar?
Let's talk about your project.
If you are facing a similar challenge, we can make the scope, risks, and technical next step concrete.
Discuss your project