Skip to content
enes
2026-04-04engineering4 min read

Prisma + multi-tenancy: one database, many tenants, no chaos.

The reflex when your first big customer asks for a dedicated database is to say yes. That reflex scales until it kills you.

You sign your first enterprise customer. Their security team asks "where will our data live?" The honest answer is "in the same database as everyone else, separated by a foreign key." The instinct is to soften it: "we can give you a dedicated database." Sounds more enterprise. The customer says yes. You spin up a database for them.

A year later you have forty customers, fifteen on dedicated databases. Every schema migration is forty deploys. Every backup is forty backups. Every alert fires forty times. The "just give them a dedicated DB" decision became the operational reality of the team.

The clean answer is the first one. Multi-tenancy in a shared database, with tenant_id on every row, scales further than most teams realize.

The shape

Every table has a tenant_id. Every query filters on it. Indexes lead with it. Unique constraints are scoped by it.

model User {
  id        String  @id @default(cuid())
  email     String
  tenantId  String
  tenant    Tenant  @relation(fields: [tenantId], references: [id])

  @@unique([tenantId, email])
  @@index([tenantId])
}

email is unique within a tenant, not globally. tenantId leads the index so every scoped query uses it. One Postgres database, one schema, one migration history.

Diagram: three tenants send requests through the Prisma middleware, which auto-injects WHERE tenant_id = ctx, into a single Postgres database where every row carries its tenant_id.

The middleware

A tenant_id column doesn't enforce anything by itself. The enforcement is a Prisma middleware that injects the tenant filter automatically.

prisma.$use(async (params, next) => {
  const tenantId = currentTenantId(); // from request context
  if (!tenantId) throw new Error("Tenant context missing");

  if (TENANT_SCOPED_MODELS.has(params.model ?? "")) {
    if (params.action === "findMany" || params.action === "findFirst") {
      params.args.where = { ...params.args.where, tenantId };
    }
    if (params.action === "create") {
      params.args.data = { ...params.args.data, tenantId };
    }
    // same for update, delete, count, etc.
  }

  return next(params);
});

Application code reads prisma.user.findMany({ where: { email } }) and the middleware injects the tenant filter. A developer who forgets to scope by tenant doesn't accidentally leak across tenants, the query never reaches the database without it.

What you get

  • One migration applies to everyone. The schema is the contract; everyone consumes it.
  • One database to back up, monitor, scale. Vertical scaling or read replicas are one decision, not forty.
  • Cross-tenant queries when you need them. Operational dashboards and billing reconciliation are a WHERE tenant_id IN (...) away, not a federation problem.
  • Onboarding is fast. A new tenant is a row in Tenant. No infra to provision, no schema to replicate.

The one trap to defend against

The accidental cross-tenant query. A developer writes prisma.project.findMany({ where: { name: "..." } }) without a tenant filter, and a tenant sees another's data.

Defense in two layers:

  1. The middleware: deny by default. Any query against a tenant-scoped model without a tenant filter throws.
  2. Tests: for every tenant-scoped query, verify tenant A returns zero rows belonging to tenant B.

For products with strict compliance, add a third: Postgres Row-Level Security policies. They enforce at the database level, immune to middleware bugs. More work to set up, second line of defense.

When dedicated DBs actually win

Three cases where the shared model breaks:

  1. Strict residency at the DB level. Some regulators require not just "data stays in region" but "data is in a database no other customer touches."
  2. One tenant whose load dwarfs the rest. When a single customer justifies their own infrastructure, give them their own. The shared cost stops being shared.
  3. Customer-specific schema variants. Custom fields are fine via JSONB. When the schema diverges enough, a separate DB is cleaner.

The signal in all three: the cost of carrying that customer in the shared DB is more than the cost of operating a separate one.

The path

Start shared. The schema has tenantId everywhere, the middleware enforces it, the tests verify isolation. You can serve hundreds of tenants on one database before scaling becomes a question.

When the first customer asks for a dedicated DB, ask why. If the answer is "our security team asked", show your isolation model. Most accept it. The few that don't are signaling something about the deal.

When you do split a tenant out, do it because their numbers force you, not as a sales concession.

The thing to avoid is starting single-tenant and trying to multi-tenant later. That migration is months of work. Starting multi-tenant and selectively splitting a few large tenants out is days per split. The default direction matters more than the eventual exception.

Pick shared. Build the isolation properly. Split when the numbers say so.