A shared schema with a tenant_id on every row, enforced by row-level security so a missing filter cannot leak data.
The scariest bug in a multi-tenant app is one tenant seeing another tenant's data, and it usually comes from a single query that forgot its tenant filter. A shared schema with tenant_id on every row plus Postgres row-level security turns that forgotten filter from a data leak into a query that simply returns nothing.
Shared schema, tenant column
Of the three common models, database per tenant, schema per tenant, and shared schema with a tenant column, the shared column scales best for a lot of small tenants and keeps migrations sane. Every tenant-owned table carries the same column.
model Invoice {
id String @id @default(uuid())
tenantId String
amount Int
createdAt DateTime @default(now())
@@index([tenantId])
}The index on tenantId is not optional. Almost every query filters on it, so it is the most important index in the database.
Defense in depth with RLS
Application-level filtering is the first line, but humans forget. Row-level security is the backstop that does not. A policy on each table restricts rows to the current tenant, set per connection.
ALTER TABLE "Invoice" ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "Invoice"
USING ("tenantId" = current_setting('app.tenant_id'));Now a query that forgets its where tenantId clause returns only the current tenant's rows anyway, because the database itself refuses to hand over the others.
Setting the tenant per request
The connection needs to know which tenant it is serving. At the start of each request I resolve the tenant from the session and set it as a session variable that the RLS policy reads.
await prisma.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;The true makes it local to the transaction, so it cannot leak into the next request that reuses the pooled connection. That detail matters with a connection pool, because a value left set on a shared connection is exactly how cross-tenant bugs sneak back in.
The tradeoffs you accept
A shared schema means a heavy tenant can affect neighbors, and a schema migration touches everyone at once. I accept those because the operational simplicity is worth it for this shape of product. When a single tenant genuinely outgrows the shared model, the tenant column makes extracting them into their own database a mechanical job rather than a rewrite.
The combination is what makes it safe: the column makes isolation possible, the index makes it fast, and RLS makes it the default that holds even when the application code slips. I sleep better knowing the last line of defense lives in the database, not in my memory of which queries need a filter.