SaaSBackendEngineering

Multi-Tenant SaaS with PostgreSQL and Prisma: Patterns That Survive Production

How to design tenant-scoped PostgreSQL and Prisma applications that stay maintainable, observable, and safe as customer count grows.

BahrTech Team
April 22, 2026 · 5 min read

Six months after a SaaS product launches, a support ticket arrives: "I can see invoices that don't belong to my account." The engineer checks the query. There is no tenantId filter. The route was written during a sprint under deadline pressure and nobody noticed because it worked fine in staging with one test clinic.

Multi-tenant data leaks are not exotic bugs. They are the natural result of adding tenant context as an afterthought. This post covers the patterns we reach for to make correct tenant isolation the path of least resistance.

Choose your isolation model early

Three isolation models exist on the PostgreSQL spectrum, and switching between them at scale is painful:

Schema-per-tenant gives each customer their own PostgreSQL schema. Migrations run per-schema, which makes them slower and more complex. This model simplifies some compliance conversations but adds operational overhead. It scales to a few hundred tenants before management becomes a bottleneck.

Database-per-tenant maximizes isolation but costs significantly more in infrastructure and connection management. Reserved for enterprises with strict data residency requirements.

Shared database, row-level isolation is the right default for most B2B SaaS at early and mid-scale. One schema, one migration run, tenantId on every tenant-owned table. This is what we cover below.

Make tenantId part of every core table — and index it correctly

tenantId should appear as a first-class foreign key on every table that belongs to a tenant. Do not rely on joins or application memory to reconstruct tenant ownership.

model Appointment {
  id        String   @id @default(cuid())
  tenantId  String
  patientId String
  startsAt  DateTime
  status    String

  tenant  Tenant  @relation(fields: [tenantId], references: [id])
  patient Patient @relation(fields: [patientId], references: [id])

  @@index([tenantId, startsAt])
  @@index([tenantId, patientId])
  @@index([tenantId, status])
}

Composite indexes matter. A query for tenantId = 'abc' AND startsAt BETWEEN ... without the composite index will scan every appointment across all tenants before filtering. At a few thousand records that is invisible; at a few million it becomes the slowest query in your application.

Scope queries at the boundary, not optionally in services

The most reliable pattern is to pass tenantId as an explicit required parameter at every data access point. Not a global, not pulled from a context singleton, not derived from the authenticated user inside the function — passed in from the caller.

export async function listAppointments({
  prisma,
  tenantId,
  from,
  to,
}: {
  prisma: PrismaClient;
  tenantId: string;
  from: Date;
  to: Date;
}) {
  return prisma.appointment.findMany({
    where: { tenantId, startsAt: { gte: from, lt: to } },
    orderBy: { startsAt: "asc" },
  });
}

The goal is not to remember a filter everywhere. The goal is to design service APIs where omitting tenantId feels obviously wrong to the next engineer who reads the signature.

Consider Prisma client extensions for safety

Prisma client extensions let you build a tenant-scoped client that automatically injects tenantId into every query:

export function tenantClient(prisma: PrismaClient, tenantId: string) {
  return prisma.$extends({
    query: {
      $allModels: {
        async findMany({ args, query }) {
          args.where = { ...args.where, tenantId };
          return query(args);
        },
      },
    },
  });
}

This is not a replacement for explicit filtering — it is an additional safety net. Use it in request handlers; keep explicit parameters in background jobs and migrations where you may intentionally need cross-tenant access.

Background jobs are the most common source of cross-tenant bugs

Scheduled jobs do not pass through request middleware. They often read from the database without a tenant context and accidentally return or mutate data for the wrong customer.

Give every job an explicit tenant iteration pattern:

async function sendDailyReminders() {
  const tenants = await prisma.tenant.findMany({ where: { active: true } });

  for (const tenant of tenants) {
    await processRemindersForTenant(tenant.id);
  }
}

async function processRemindersForTenant(tenantId: string) {
  const appointments = await listAppointments({
    prisma,
    tenantId,
    from: startOfDay(new Date()),
    to: endOfDay(new Date()),
  });
  // ...
}

Log tenantId at the start of each tenant-scoped job run. When something fails, operators should immediately know which tenant was affected and whether a retry is safe.

Migrations across all tenants need a strategy

In a shared-schema model, one Prisma migration applies to all tenants simultaneously. That is usually fine, but a few scenarios need care:

Adding a NOT NULL column to a large table requires a default value or a backfill migration before the constraint is applied. Do it in two deployments: first add the column as nullable and backfill, then tighten the constraint.

Renaming a column while the old code is still running causes downtime if done in one step. Expand-contract: add the new column, write to both, read from new, drop the old.

Keep migrations small and focused. A migration that rewrites three tables at once is hard to roll back and expensive to debug if something goes wrong at 2AM.

Connection pooling with PgBouncer and Prisma

Direct PostgreSQL connections are expensive. At modest scale — even 20-30 concurrent users — a Node.js SaaS with one PrismaClient per request will exhaust the database connection limit.

Add PgBouncer in transaction-mode pooling and update your Prisma connection string:

DATABASE_URL="postgres://user:pass@pgbouncer-host:6432/db?pgbouncer=true"

The ?pgbouncer=true flag tells Prisma to skip prepared statements, which are incompatible with transaction-mode pooling. Without it, you get cryptic errors that only appear under load.

Cross-tenant access for admin and analytics

Shared-schema isolation does not mean you can never query across tenants. Admin dashboards, billing reports, and aggregate analytics legitimately need cross-tenant reads. Make that access explicit:

// admin-only service — never called from tenant-scoped request handlers
export async function getSystemStats(prisma: PrismaClient) {
  return prisma.appointment.groupBy({
    by: ["tenantId"],
    _count: { id: true },
    where: { startsAt: { gte: startOfMonth(new Date()) } },
  });
}

The rule is not "never cross tenant boundaries." The rule is "make it intentional and auditable." Cross-tenant reads belong in a clearly named admin namespace, ideally behind a separate auth gate.

Durable multi-tenancy is mostly discipline: model the tenant, index for the tenant, query through the tenant, and make every job and migration tenant-aware. The patterns above are small enough to set up on day one — and expensive enough to retrofit at month six.

For the GraphQL layer that sits above this data model, see Scaling GraphQL APIs with NestJS.

Tags
SaaSBackendEngineering