Multi-Tenant Database Strategies: Row-Level Security vs. Schema-per-Tenant
Shared schema, schema-per-tenant and a hybrid mix of both — how to choose a multi-tenant database model from day one, with real trade-offs for PostgreSQL, row-level security and analytics.
On this page
Every SaaS application starts with the same quietly terrifying decision: how do we partition customer data so that tenant A can never see tenant B? The database schema you choose in week one becomes the constraint you architect around in year three, and retrofitting a new tenancy model into a live product with real customers is about as fun as migrating sharks in open water.
The good news: PostgreSQL gives you a spectrum of options rather than a binary choice. In this article I want to lay out the three realistic strategies — shared schema, schema-per-tenant, and the database-per-tenant variant — and then argue that most teams should land on shared schema with row-level security (RLS) plus a naming convention, unless they have hard per-tenant isolation requirements.
Why tenancy is a schema problem, not a code problem
When people say "our app is multi-tenant," they usually mean the business layer is: every row has a tenant_id, and every query filters on it. That works right up until the moment a developer forgets the filter, a join drops a tenant condition, or a support script runs UPDATE emails SET read = true and touches thirty customers at once.
The core insight is this: tenancy is a data concern, so it should be enforced where the data lives. If your boundary is "we promise to remember to add a WHERE clause," you are not multi-tenant — you're hopeful. RLS moves that promise from application discipline into the database itself.
Strategy one: shared schema with a tenant_id column
This is the default for a reason. Every table gets a tenant_id foreign key, and application queries filter by it. It's simple, it scales, you can build features across tenants trivially, and analytics queries are easy because all data lives in one place.
The classic failure mode is a missing predicate. Let's fix that:
-- RLS: policy the whole table, not just the query
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.tenant_id')::uuid);The application sets the tenant context once per connection in a transaction:
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
export async function withTenant<T>(
tenantId: string,
fn: () => Promise<T>
): Promise<T> {
return prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
return fn();
});
}With set_config(..., true) the setting is scoped to the transaction. If your team is using a connection pool (and you should be), the setting can't leak between tenants because it's transaction-local.
Note
PostgreSQL does not automatically reset set_config values on Prisma's pooled connections unless the third argument is true (transaction-local). Use the boolean flag or reset role configuration explicitly, or a connection could serve tenant A's context to tenant B.
Strategy two: schema-per-tenant
Here every customer gets their own PostgreSQL schema — tenant_abc — containing the full set of tables. The application rewrites the search path per request:
ALTER ROLE app_user SET search_path TO tenant_abc, public;In Prisma you can't cleanly switch schemas per request with migrations the same way you can with raw SQL, which is one of the reasons a lot of ORM-first teams avoid this model. The payoff is brisk: deleting a tenant is dropping a schema, backups are per-tenant, and a runaway customer can't trigger a seq_scan on everyone else's data.
The price is operational. You now run migrations across N schemas instead of one, each schema is a schema in your migration bookkeeping, and cross-tenant reporting requires either a shared analytics schema fed by triggers or a data warehouse. Most teams underestimate schema sprawl more than they overestimate it.
Strategy three: database-per-tenant
The most extreme option: one physical database per tenant. It buys hard isolation, independent performance tuning, and per-tenant backup/restore — genuinely valuable for a regulated vertical like healthcare or fintech.
But your database connection story becomes a hard problem: a thousand tenants means a thousand connection pools, and connection pooling tools like PgBouncer have to be told about every database. If you take this route, do it deliberately: provision databases programmatically, monitor max_connections, and treat "create tenant" as a Terraform-style operation, not a manual one. This is almost never the right default for a bootstrap-stage SaaS.
The comparison most people skip: analytics
The strategy table usually compares cost to build, but your second year is dominated by cost to report.
| Concern | Shared schema | Schema-per-tenant | Database-per-tenant |
|---|---|---|---|
| Isolation strength | Logical (RLS) | Strong | Absolute |
| Developer ergonomics | Single schema, ORM-friendly | Schema sprawl, careful search paths | Connection chaos |
| Cross-tenant analytics | Direct SQL | Union or shared analytics DB | Warehouse required |
| Per-tenant backup | Full-database granularity | Per-schema | Per-database |
| Migration effort | One per app | Per tenant schema | Per tenant database |
| Cost to operate | Lowest | Medium | Highest |
Notice the asymmetry: the only place schema-per-tenant or database-per-tenant wins on the table above is isolation strength and per-tenant backup. If your compliance obligations don't require byte-level isolation, you're paying a large operational tax for a property your customers can't observe.
The hybrid that works in practice
Real teams converge on this: shared schema with RLS for all transactional data, plus a dedicated analytics schema (or warehouse) built from a nightly read replica. The replica avoids putting read load and report queries on the transactional path, and because the shared schema has a tenant_id everywhere, building the analytics pipeline is a single materialized view per report rather than a shard-by-shard exercise.
CREATE MATERIALIZED VIEW tenant_health_daily AS
SELECT
tenant_id,
date_trunc('day', created_at) AS day,
count(*) AS events,
count(DISTINCT user_id) AS active_users
FROM events
WHERE created_at > now() - interval '90 days'
GROUP BY 1, 2;
CREATE UNIQUE INDEX ON tenant_health_daily (tenant_id, day);Run this on a dedicated replica so REFRESH MATERIALIZED VIEW can't stall writes on the primary, and you get cross-tenant reporting without touching the hot path.
Caution
If you start with shared schema, add RLS in the same migration you add the tenant_id column. Backfilling RLS onto a table that already has a billion rows and an ORM that sometimes issues raw SQL is where tenancy bugs are born.
A checklist for deciding
- Your security model is "logical isolation is acceptable" → shared schema + RLS.
- You have per-tenant data retention or compliance rules → schema-per-tenant, maybe database-per-tenant.
- Cross-tenant analytics is a first-class product feature → shared schema is dramatically easier.
- Your ORM must support the model day one → shared schema with Prisma + RLS is the most supported path.
- A tenant can legitimately exceed a hosting plan's row-count limits → you'll be migrating to schema-per-tenant later anyway; plan the migration seam (a
tenant.plancolumn) early.
The real takeaway: tenancy isolation belongs in the database, and for the overwhelming majority of teams that means shared schema with row-level security, an analytics replica, and a migration seam ready in case one customer grows past the shared model. Everything else is optimizing a property the market won't pay for until it's too late to change cheaply.
Related reading
Caching Strategies for SaaS Backends: Redis, HTTP & CDN
Where to put the cache so it actually helps: application vs. HTTP vs. edge, cache invalidation by version not by prayer, and the metrics that tell you the cache is lying.
A Practical Guide to PostgreSQL Indexing
B-tree, GIN, partial and composite indexes — how PostgreSQL actually uses them, when each earns its disk, and the questions to ask before adding another index.
PostgreSQL Connection Pooling With PgBouncer: A Practical Guide
Why Node.js apps exhaust PostgreSQL connections, how transaction pooling with PgBouncer fixes it, and the gotchas — prepared statements, set_config, and monitoring — that trip teams up.
Work together
Building something similar?
I design and build production-ready web applications, SaaS platforms and backend APIs — from data modelling to deployment. If a project or technical challenge is on your roadmap, let's talk it through.