Multi-tenant data isolation patterns
One app serving many organizations requires careful isolation. Three architectural patterns: separate databases (strongest isolation, expensive), separate schemas (moderate isolation, good balance), and shared schema with tenant_id column (simplest, requires discipline). Most SaaS apps use shared schema because it scales, but you must enforce tenant_id on every query.
6 min read
Why multi-tenancy isolation matters
Imagine Company A and Company B both use your SaaS app. Alice (Company A) should never see Bob's (Company B) data. This is not just a permission problem — it's an architectural one.
Bad: trust authorization alone
# WRONG — relies only on authorization check
@app.get('/documents')
def get_documents():
user = request.user
# assumes authorization will filter by user_id
return db.query('SELECT * FROM documents WHERE user_id = ?', user.id)If someone finds a vulnerability (e.g., passing user_id=999 in a parameter), they can access other users' data.
Better: enforce tenant isolation at the architecture level
# CORRECT — the database itself enforces tenant boundaries
@app.get('/documents')
def get_documents():
user = request.user
org_id = user.organization_id
# even if the attacker guesses a user_id, they can't cross org boundaries
return db.query(
'SELECT * FROM documents WHERE user_id = ? AND organization_id = ?',
user.id, org_id
)Three isolation patterns
Pattern 1: Separate database per tenant (strongest)
Each organization gets its own PostgreSQL database:
Company A: postgres://postgres:pw@db-a.internal/
Company B: postgres://postgres:pw@db-b.internal/
Company C: postgres://postgres:pw@db-c.internal/
Pros:
- Strongest isolation: complete data separation.
- Easy to scale individual tenants (Company A needs more resources, provision more).
- Easy to migrate/delete: drop a database to remove a tenant.
Cons:
- Most expensive (one database server per tenant, or many databases on one server).
- Operational overhead (backups, migrations, monitoring per database).
- Onboarding is slower (create database, run migrations, seed data).
When to use: ultra-high-security requirements (HIPAA, finance), very large tenants who need isolation.
Pattern 2: Separate schema per tenant (moderate)
All organizations share one PostgreSQL instance, but each gets its own schema:
CREATE SCHEMA company_a;
CREATE SCHEMA company_b;
CREATE SCHEMA company_c;
-- Company A's documents table
CREATE TABLE company_a.documents (
id UUID PRIMARY KEY,
title TEXT,
owner_id UUID
);
-- Company B's documents table (same structure, different schema)
CREATE TABLE company_b.documents (
id UUID PRIMARY KEY,
title TEXT,
owner_id UUID
);Pros:
- Stronger isolation than shared schema.
- Cheaper than separate databases.
- Easy to migrate/delete: drop a schema.
Cons:
- Still multiple schemas to manage.
- All queries must reference the schema.
- Onboarding requires schema creation and migration.
When to use: medium to large SaaS with moderate tenant sizes.
Pattern 3: Shared schema with tenant_id (simplest, most common)
All organizations share all tables. Isolation is enforced via a tenant_id (or organization_id) column:
CREATE TABLE documents (
id UUID PRIMARY KEY,
organization_id UUID NOT NULL, -- tenant identifier
title TEXT,
owner_id UUID
);
CREATE INDEX idx_documents_org_id ON documents(organization_id);Pros:
- Simplest to implement.
- Easiest to scale (single database).
- Fastest onboarding (no schema creation).
- Easiest for reporting/analytics (query across tenants).
Cons:
- Weakest isolation: relies on discipline (always including tenant_id in queries).
- One mistake (missing tenant_id filter) exposes all data.
- Requires constant vigilance.
When to use: most SaaS apps (unless isolation requirements are exceptionally high).
Enforcing tenant_id at query time (shared schema)
If you use the shared schema pattern, every single query must include the tenant_id filter:
# WRONG — missing organization_id filter
@app.get('/documents/<doc_id>')
def get_document(doc_id):
return db.query('SELECT * FROM documents WHERE id = ?', doc_id)
# CORRECT — always filter by organization_id
@app.get('/documents/<doc_id>')
def get_document(doc_id):
user = request.user
return db.query(
'SELECT * FROM documents WHERE id = ? AND organization_id = ?',
doc_id, user.organization_id
)Automation via middleware:
def enforce_tenant_id(user):
"""Returns a context that enforces tenant_id on all queries."""
org_id = user.organization_id
class TenantAwareDB:
def query(self, sql, *args):
# Ensure organization_id is in the WHERE clause
if 'organization_id' not in sql:
raise ValueError("Query must filter by organization_id")
# Inject org_id into query
return db.query(sql, *args, org_id)
return TenantAwareDB()
@app.get('/documents/<doc_id>')
def get_document(doc_id):
user = request.user
db = enforce_tenant_id(user)
return db.query('SELECT * FROM documents WHERE id = ? AND organization_id = ?', doc_id)Even better: use an ORM that supports row-level security (RLS):
-- PostgreSQL Row-Level Security
CREATE POLICY tenant_isolation ON documents
USING (organization_id = current_user_org_id());
-- All queries are automatically filtered by organization_idMulti-tenant writes
Writing data also requires tenant_id:
@app.post('/documents')
def create_document():
user = request.user
title = request.json.get('title')
# WRONG — doesn't set organization_id
db.execute(
'INSERT INTO documents (id, title, owner_id) VALUES (?, ?, ?)',
uuid.uuid4(), title, user.id
)
# CORRECT — explicitly set organization_id
db.execute(
'INSERT INTO documents (id, title, owner_id, organization_id) VALUES (?, ?, ?, ?)',
uuid.uuid4(), title, user.id, user.organization_id
)Tenant isolation in caching
If you cache query results, include tenant_id in the cache key:
# WRONG — cache key doesn't include tenant_id
cache_key = f"documents_{doc_id}"
if cache_key in redis:
return redis.get(cache_key) # might return data from another tenant!
# CORRECT — include tenant_id
cache_key = f"documents_{user.organization_id}_{doc_id}"
if cache_key in redis:
return redis.get(cache_key)Testing tenant isolation
Write tests that verify a user from one tenant can't access data from another:
def test_users_cannot_access_other_tenants_data():
# Create two organizations
org_a = create_organization("Company A")
org_b = create_organization("Company B")
# Create users
alice = create_user("alice", org_a)
bob = create_user("bob", org_b)
# Alice creates a document in Company A
doc = create_document("Secret", owner=alice, org=org_a)
# Bob (in Company B) tries to read it
bob_session = authenticate(bob)
response = bob_session.get(f'/documents/{doc.id}')
# Bob should get 404, not the document
assert response.status_code == 404Common mistakes
Forgetting to add organization_id to bulk operations
# WRONG
db.execute('UPDATE documents SET status = ? WHERE status = ?', 'archived', 'pending')
# This updates ALL pending documents across ALL organizations!
# CORRECT
db.execute(
'UPDATE documents SET status = ? WHERE status = ? AND organization_id = ?',
'archived', 'pending', user.organization_id
)Not indexing on organization_id
-- SLOW (full table scan)
SELECT * FROM documents WHERE id = ? AND organization_id = ?
-- FAST (indexed lookup)
CREATE INDEX idx_documents_org_id ON documents(organization_id);
CREATE INDEX idx_documents_org_id_id ON documents(organization_id, id);Storing sensitive data in logs
If logs include queries, they might expose tenant_id values. Sanitize log output.
Not auditing cross-tenant access
Monitor for queries without organization_id filters. Alert on suspicious patterns.
Tenant isolation in microservices
In a microservice architecture, enforce tenant_id across services:
# API Gateway adds organization_id to request context
@app.post('/api/documents')
def create_document():
user = request.user
org_id = request.headers.get('X-Organization-ID')
# Pass org_id to downstream services
response = service_a.call('/internal/create-document', {
'title': request.json['title'],
'organization_id': org_id
})Each service must:
- Accept organization_id as a parameter.
- Verify the user belongs to that organization.
- Use organization_id in all database queries.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What happens if a shared-schema multi-tenant app forgets to filter by organization_id in a query?
2. Which isolation pattern is strongest but most expensive?
3. Why is an index on organization_id important in shared-schema multi-tenant?
4. What is PostgreSQL Row-Level Security (RLS)?