Multi-tenancy is one of those architectural decisions that's easy to get wrong early and expensive to fix later. We've built multi-tenant systems for B2B SaaS products ranging from 10 to 10,000 tenants, and the right approach depends heavily on your customer profile, compliance requirements, and growth trajectory. Here's the honest breakdown.
The Three Strategies
There are three main approaches to multi-tenancy at the database layer, and they sit on a spectrum from "cheapest to operate" to "strongest isolation":
- Shared database, shared schema (RLS) — all tenants in the same tables, separated by a
tenant_idcolumn with row-level security policies - Shared database, separate schemas — one PostgreSQL schema per tenant within the same database instance
- Separate databases — each tenant gets their own database instance, often their own infrastructure
Strategy 1: Row-Level Security (RLS)
This is the right default for most early-stage SaaS products. Every table has a tenant_id column, and PostgreSQL RLS policies enforce that queries only return rows matching the current tenant context. You set the tenant context at the start of each request:
-- Set tenant context for this transaction
SET app.current_tenant_id = 'tenant-uuid' ;
-- RLS policy on the orders table
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
The advantages: simple to operate, cheap (one database), easy to run cross-tenant analytics. The risks: a bug in your RLS policy or a missing SET call can leak data across tenants. We mitigate this with integration tests that explicitly verify tenant isolation on every table, and a middleware layer that always sets the tenant context before any database query.
Use this when: you're pre-Series A, your tenants are SMBs without strict compliance requirements, and you want to move fast.
Strategy 2: Schema-Per-Tenant
Each tenant gets their own PostgreSQL schema (e.g., tenant_acme.orders, tenant_globex.orders). The application sets the search_path at connection time to route queries to the correct schema.
This gives stronger logical isolation than RLS — a missing tenant context won't accidentally return another tenant's data, it'll just return nothing or error. Schema migrations become more complex: you need to apply them across all tenant schemas, which requires a migration runner that iterates over all schemas.
The hidden cost is connection pooling. PgBouncer's transaction-mode pooling doesn't support search_path changes reliably, so you often end up with session-mode pooling, which limits your connection efficiency. At 500+ tenants, this starts to matter.
Use this when: you have enterprise customers who ask about data isolation in security questionnaires, but you're not yet at the scale where separate databases make economic sense.
Strategy 3: Database-Per-Tenant
The strongest isolation model. Each tenant gets their own database instance — often their own RDS instance or Aurora cluster. This is the only approach that satisfies customers who require physical data isolation (common in healthcare, finance, and government).
The operational complexity is real. You need a control plane that provisions new databases on tenant signup, manages connection strings, runs migrations across all tenant databases, and handles backups. We've built this with a combination of Terraform modules (for provisioning) and a custom migration runner that parallelises schema updates across tenant databases.
Cost is the main constraint. At $50–200/month per RDS instance, 100 tenants means $5,000–20,000/month in database costs alone. Aurora Serverless v2 helps here — it scales to zero when idle, which works well for tenants who use the product infrequently.
Use this when: you're selling to enterprise or regulated industries, your ACV justifies the infrastructure cost, or you have contractual data residency requirements.
The Hybrid Approach
The pattern we use most often for growth-stage SaaS: start with RLS for all tenants, but add a "dedicated" tier for enterprise customers who need stronger isolation. Enterprise tenants get their own schema or database; everyone else shares the pool.
The application layer needs to know which strategy to use for each tenant. We store this in a central tenant registry (a separate database that's not subject to RLS) with a isolation_tier field. The connection factory reads this and routes accordingly.
Application-Layer Concerns
Regardless of which database strategy you choose, there are application-layer patterns that matter for every multi-tenant system:
- Tenant context propagation — the tenant ID must flow through every layer: HTTP middleware → service layer → repository layer → database query. A missing propagation is a data leak waiting to happen.
- Feature flags per tenant — enterprise customers often need custom features or different limits. Store these in the tenant registry, not in code.
- Rate limiting per tenant — one tenant's bulk import job shouldn't degrade performance for others. Implement per-tenant rate limits at the API gateway and job queue layers.
- Audit logging — every data mutation should be logged with the tenant ID, user ID, and timestamp. This is non-negotiable for enterprise customers.
Our Recommendation
If you're building a new B2B SaaS product today: start with RLS. It's the simplest approach that works, and you can migrate to schema-per-tenant or database-per-tenant for specific enterprise customers later without rewriting your entire data layer. The key is to abstract your data access behind a repository pattern from day one — that abstraction is what makes the later migration tractable.
Don't over-engineer for isolation requirements you don't have yet. We've seen teams spend months building a database-per-tenant system before they had a single paying customer. Ship the product, validate the market, then invest in the isolation model your actual customers require.
Building a multi-tenant SaaS product? We've navigated these trade-offs across multiple products and can help you make the right call for your situation.