Multi-Tenant Best Practices
Test cross-tenant leaks in CI with negative cases. Multi-tenant correctness is a security property - treat it like auth, not a naming convention.
Search across all documentation pages
Test cross-tenant leaks in CI with negative cases. Multi-tenant correctness is a security property - treat it like auth, not a naming convention.
tenant_id on every tenant-owned row (shared schema). Composite PKs and FKs include tenant_id.UNIQUE (tenant_id, ...) not global UNIQUE. Slugs and emails collide across tenants by design.tenant_id as leading column. Match WHERE tenant_id = $1 on all hot paths.tenant_id as intentionally global.FORCE ROW LEVEL SECURITY on tenant tables. Policies on SELECT, INSERT, UPDATE, DELETE as needed.app.tenant_id (or equivalent) every request. Never trust client-supplied tenant without auth binding.server_reset_query = DISCARD ALL or equivalent.document_id joins that cross tenants.Seed tenant A and B, authenticate as A, query each endpoint/table, assert zero B rows returned. Fail build on any leak.
RLS is defense in depth. Apps should still filter - policies catch mistakes, not replace intentional query design.
Two sequential requests as different tenants through same pool without reset should not see cross data - automate in staging.
Workers must set tenant GUC per job payload. Global workers loop tenants explicitly - never scan full table without filter.
Yes for audit trails; avoid PII in same log line. Correlate tenant_id with request ID for incident response.
Start shared + RLS. Move to schema/database when contracts, noisy neighbor metrics, or restore SLAs demand it.
Missing WHERE tenant_id in one repository method or stale GUC on pooled connection.
Yes if apps connect with non-superuser roles. Replica policies match primary - test leaks on replica in CI too.
Shared schema: one DDL affects all - test on largest tenant fixture. Per-tenant schemas/DBs: parallelize with failure alerts.
No - audit logs are tenant-owned data and need same isolation and indexes.
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, and PostGIS 3.5+.
Reviewed by Chris St. John·Last updated Jul 18, 2026