
- Schema-per-tenant becomes an operational bottleneck beyond 500 tenants due to migration complexity and catalog bloat.
- PostgreSQL Row-Level Security (RLS) enforces tenant isolation directly inside the database engine, preventing accidental cross-tenant data leaks.
- Composite indexing with tenant_id as the leading column ensures fast index scans and smooth future partitioning.
The Fundamental Architectural Decision of Every SaaS Platform
Every multi-tenant SaaS application faces the same foundational question: how do we store data for thousands of organizations while guaranteeing that Organization A can never accidentally read or mutate Organization B's records—even if a developer forgets a WHERE clause in application code?
Comparing the Three Multi-Tenancy Models
- Model 1: Database-per-Tenant (Siloed): Maximum physical isolation, ideal for regulated healthcare or banking tiers, but expensive to maintain and pool connections across thousands of small customers.
- Model 2: Schema-per-Tenant: Shares a single PostgreSQL cluster while separating tables into
tenant_123.ordersschemas. While appealing initially, running schema migrations across 5,000+ schemas slows CI/CD deployments to a crawl and bloats PostgreSQL system catalogs. - Model 3: Shared Tables with Row-Level Security (RLS): All tenants share the same core tables with a mandatory
tenant_idcolumn, while PostgreSQL's native RLS engine enforces strict cryptographic isolation at the query planner level.
Implementing Bulletproof PostgreSQL Row-Level Security
By binding the authenticated user's organization ID into the PostgreSQL transaction session variable (app.current_tenant_id), the database engine automatically filters every SELECT, INSERT, UPDATE, and DELETE statement:
-- Enable Row-Level Security on multi-tenant table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
-- Create mandatory tenant isolation policy
CREATE POLICY tenant_isolation_policy ON invoices
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid)
With CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Always place tenant_id FIRST in composite B-Tree indexes
CREATE INDEX idx_invoices_tenant_created
ON invoices (tenant_id, created_at DESC);
Placing tenant_id as the leading column in every composite index ensures that PostgreSQL narrows the B-Tree search space to a single customer's slice in microseconds—keeping query performance consistently fast even as the table grows past hundreds of millions of rows.