For B2B SaaS on PostgreSQL, shared-schema Row-Level Security (RLS) scales better than schema-per-tenant: one migration instead of hundreds, normal connection-pooling behaviour under PgBouncer transaction mode, and per-row tenant isolation via a session-scoped tenant_id policy. Schema-per-tenant suits fewer than ~100 high-compliance enterprise customers. Composite indexes leading with tenant_id keep RLS range scans fast under noisy-neighbor load.
When designing a B2B SaaS platform handling sensitive enterprise data, deciding how to isolate tenant records inside your primary relational database is one of the most consequential architectural decisions you will make.
Choose wrong, and you will either suffer from terrifying operational overhead (thousands of failing migrations across separate schemas) or risk existential cross-tenant data leaks caused by a forgotten WHERE tenant_id = ... clause in an ORM query.
In this essay, we compare the two dominant PostgreSQL multi-tenancy modelsβSchema-per-Tenant and Shared-Schema with Row-Level Security (RLS)βand provide the production blueprint for implementing leak-proof RLS with connection poolers like PgBouncer.
1. The Fundamental Architectural Spectrum
Multi-tenant data isolation generally exists along a continuum from maximum physical isolation to maximum resource sharing:
- Database-per-Tenant: Complete physical isolation. High infrastructure cost, connection starvation, and difficult cross-tenant analytics.
- Schema-per-Tenant: One shared database cluster, but each customer gets a dedicated PostgreSQL schema (
CREATE SCHEMA tenant_acme). - Shared-Schema with RLS: All customers share the same database tables (
public.orders), with every row tagged with atenant_idUUID and enforced by database engine policies.
2. The Hidden Trap of Schema-per-Tenant
On paper, Schema-per-Tenant looks ideal: strong logical separation, easy per-tenant data deletion (DROP SCHEMA tenant_acme CASCADE), and straightforward backups.
However, at scale (500+ tenants), Schema-per-Tenant introduces severe operational bottlenecks:
- DDL Migration Nightmares: Running an
ALTER TABLEmigration across 5,000 schemas requires 5,000 DDL statements inside a lock-heavy migration pipeline. If schema #3,401 fails midway, rolling back is complex and prone to inconsistency. - PostgreSQL Catalog & Cache Bloat: PostgreSQL stores relation metadata in its system catalogs (
pg_class,pg_attribute). 100 tables × 2,000 schemas = 200,000 tables. This exhausts shared buffer cache, slows query planning, and inflates vacuum overhead. - Connection Pool Fragmentation: When switching search paths (
SET search_path TO tenant_acme), you defeat transaction-mode connection pooling in PgBouncer without defensive resets.
3. The Modern Solution: Shared-Schema with Row-Level Security (RLS)
PostgreSQL native Row-Level Security (introduced in 9.5 and optimized in 14+) moves isolation enforcement out of application code and directly into the query planner. Even if a developer writes SELECT * FROM invoices without a WHERE clause, the PostgreSQL engine rewrites the AST to strictly restrict rows to the authenticated tenant.
Step 1: Enabling RLS and Defining Isolation Policies
-- 1. Enable RLS on the table
ALTER TABLE app_invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE app_invoices FORCE ROW LEVEL SECURITY; -- Also applies to table owners
-- 2. Create the isolation policy based on session variable
CREATE POLICY tenant_isolation_policy ON app_invoices
FOR ALL
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
By default, PostgreSQL bypasses RLS policies if the query is executed by the table owner or a superuser. Always add FORCE ROW LEVEL SECURITY and ensure your application connects using a non-superuser database role (e.g., app_user).
Step 2: Transaction-Safe Context Injection with PgBouncer
In production SaaS architectures, applications use PgBouncer in Transaction Pooling Mode to handle thousands of concurrent web requests efficiently.
Because physical connections are shared between different tenants across consecutive HTTP requests, setting session variables with standard SET app.current_tenant_id can leak context to subsequent transactions!
The fix is strictly using SET LOCAL inside every database transaction:
-- Safe execution inside an application transaction
BEGIN;
-- SET LOCAL is automatically discarded when the transaction commits or aborts
SET LOCAL app.current_tenant_id = 'c38d8792-7b16-4df0-bca8-e501a3cf8d12';
-- Query executes with engine-enforced tenant filter
SELECT id, invoice_number, total_amount, due_date
FROM app_invoices
ORDER BY created_at DESC
LIMIT 50;
COMMIT;
4. Indexing & Query Optimization for RLS
Because RLS appends an implicit WHERE tenant_id = '...' to every query, your indexing strategy must prioritize composite indexes prefixed with tenant_id:
-- BAD: Single column index on created_at (requires full index scan filtered by tenant)
CREATE INDEX idx_invoices_created ON app_invoices (created_at);
-- GOOD: Composite index leading with tenant_id (efficient index range scan)
CREATE INDEX idx_invoices_tenant_created ON app_invoices (tenant_id, created_at DESC);
5. Decision Matrix: When to Pick Which
- Choose Shared-Schema RLS if: You are building a scalable multi-tenant SaaS with hundreds or thousands of customers, require fast zero-downtime database migrations, and want minimal infrastructure overhead.
- Choose Schema-per-Tenant if: You have fewer than 100 high-paying enterprise customers with contractual requirements for isolated schema encryption keys, or require tenant-specific schema customization.