Imagine a SaaS project management platform with 500 clients. Each client creates projects, adds members, uploads files—all in a shared database. One code error, and users see each other's data: bugs with crossing tenant_id are common in systems with shared tables. You could separate clients into individual databases, but then each DB requires its own backups, migrations, and monitoring, quickly becoming unmanageable. The compromise is Schema-per-Tenant: each schema in a common PostgreSQL database, but with full namespace isolation. In this article, we share our experience implementing this approach with Prisma and Kysely.
PostgreSQL schemas act as namespaces: tables, indexes, functions in different schemas do not overlap. One database can hold up to 10,000 schemas—enough for most B2B SaaS products. Meanwhile, administration remains unified: one backup, one migration command, one monitoring. We'll cover how to set up data isolation without sacrificing flexibility.
A typical client registration flow: create a record in the shared tenants table, then dynamically create a new schema and apply the initial data schema. This requires an admin connection with DDL privileges. We'll go through the code and common pitfalls.
How Schema-Based Isolation Works
PostgreSQL database:
schema: public → common tables (tenants, plans)
schema: tenant_acme → data for client Acme
schema: tenant_globex → data for client Globex
schema: tenant_initech → data for client Initech
PostgreSQL supports up to 10,000 schemas per database, sufficient for most SaaS products. Each schema is a separate namespace: tables, indexes, functions do not overlap. Backups and migrations are a single operation per database.
How to Ensure Data Isolation?
Creating a Schema on Registration
Typical flow: new client → POST /api/tenants → create record in public.tenants → execute DDL for new schema. TypeScript code:
// lib/tenant-provisioning.ts
import { db, adminDb } from './db';
export async function createTenantSchema(tenantSlug: string): Promise<string> {
const schemaName = `tenant_${tenantSlug.replace(/-/g, '_')}`;
// Transaction in admin connection
await adminDb.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`CREATE SCHEMA "${schemaName}"`);
await tx.$executeRawUnsafe(`
SET search_path TO "${schemaName}";
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE team_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON projects (created_at DESC);
CREATE INDEX ON team_members (user_id);
`);
});
return schemaName;
}
Important: DDL is executed via an admin connection with schema creation privileges. The transaction ensures atomicity—if something fails, the schema is not created.
Prisma: Bypassing ORM Limitations
Prisma does not natively support multiple schemas. The solution is a dynamic search_path via middleware. We create a TenantPrismaClient class that switches context to the appropriate schema before each query:
// lib/tenant-client.ts
import { PrismaClient } from '@prisma/client';
export class TenantPrismaClient {
private client: PrismaClient;
private schema: string;
constructor(schema: string) {
this.schema = schema;
this.client = new PrismaClient();
// Middleware: set search_path before each query
this.client.$use(async (params, next) => {
await this.client.$executeRawUnsafe(
`SET search_path TO "${this.schema}", public`
);
return next(params);
});
}
get db() { return this.client; }
async disconnect() {
await this.client.$disconnect();
}
}
// Factory with caching
const clients = new Map<string, TenantPrismaClient>();
export async function getTenantClient(tenantId: string): Promise<TenantPrismaClient> {
if (clients.has(tenantId)) {
return clients.get(tenantId)!;
}
const tenant = await masterDb.tenant.findUniqueOrThrow({
where: { id: tenantId },
select: { schemaName: true }
});
const client = new TenantPrismaClient(tenant.schemaName);
clients.set(tenantId, client);
return client;
}
Middleware is preferable to a global search_path because in production multiple tenants are served simultaneously. Each TenantPrismaClient holds its own connection; switching occurs only within that connection—safe for others.
Alternative: Kysely
An ORM with more flexible dynamic schema support is Kysely. It allows setting search_path directly when creating the connection pool:
import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg';
function createTenantDb(schemaName: string) {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
pool.on('connect', (client) => {
client.query(`SET search_path TO "${schemaName}", public`);
});
return new Kysely({
dialect: new PostgresDialect({ pool }),
});
}
const tenantDb = createTenantDb('tenant_acme');
const projects = await tenantDb
.selectFrom('projects')
.selectAll()
.orderBy('created_at', 'desc')
.execute();
Kysely is lighter than Prisma and gives more control. But in projects with existing Prisma, the middleware approach works reliably.
Migrations Across All Schemas
When table structure changes, DDL must be applied to all schemas. We write a script that iterates over tenants and executes the migration sequentially:
// scripts/migrate-schemas.ts
import { adminDb } from '../lib/db';
async function migrateAllSchemas(migration: string) {
const tenants = await masterDb.tenant.findMany({
select: { schemaName: true, slug: true }
});
for (const tenant of tenants) {
console.log(`Migrating ${tenant.slug}...`);
try {
await adminDb.$executeRawUnsafe(`
SET search_path TO "${tenant.schemaName}";
${migration}
`);
} catch (error) {
console.error(`Failed: ${tenant.slug}`, error);
}
}
}
migrateAllSchemas(`
ALTER TABLE projects ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS projects_archived_at ON projects (archived_at);
`);
To avoid downtime, migrations are performed during a low-load window. We use IF NOT EXISTS/IF EXISTS for idempotency. If one schema fails, others are not blocked.
Cross-Tenant Queries for Analytics
One advantage of Schema-per-Tenant is the ability to aggregate data across all clients. Example: counting projects across all tenants:
SELECT
t.slug as tenant,
COUNT(p.id) as project_count
FROM public.tenants t
CROSS JOIN LATERAL (
SELECT id FROM tenant_acme.projects
UNION ALL
SELECT id FROM tenant_globex.projects
-- ...dynamically built from tenant list
) p(id)
GROUP BY t.slug;
For production, we use a PL/pgSQL function that dynamically constructs the query based on active schemas.
Row Level Security (Optional)
Within a schema, user access can be further restricted via RLS. For example, a user sees only their projects. This is only useful if multiple users with different permissions operate within the same schema. For a single-tenant schema, schema-level isolation is sufficient.
Multi-Tenancy Approach Comparison
| Criterion | Database-per-Tenant | Schema-per-Tenant | Shared Table |
|---|---|---|---|
| Isolation | Full | High | Low |
| Administration | Complex (N databases) | Medium (1 database) | Simple |
| Cross-tenant queries | Impossible | Possible | Easy |
| PostgreSQL limit | 4 GB max databases (practically ~1000) | ~10,000 schemas | Virtually none |
| Migration complexity | N operations | N operations | 1 operation |
| Data leak risk | Minimal | Low (with proper setup) | High |
Schema-per-Tenant is chosen when clients range from 50 to 2000, isolation is needed, but administration costs should be optimized. This approach reduces infrastructure operational costs by up to 40% compared to Database-per-Tenant.
Common Problems and Solutions
| Problem | Solution |
|---|---|
| Error creating schema for new client | Use a transaction in the admin connection; roll back schema on failure |
| Need to switch schema in Prisma | Middleware that sets search_path before each query |
| Poor performance of cross-tenant queries | Use a PL/pgSQL function with dynamic SQL, index common fields |
Our Work Process: From Idea to Deployment
We implement multi-tenant architecture in 4–7 working days. Stages:
- Analysis — discuss isolation model, registration logic, migration plan. Gather security requirements.
- Design — draw ERD, define common tables (tenants, plans) and tenant schemas. Choose stack: Prisma or Kysely.
- Implementation — write provisioning code, ORM middleware, migration scripts. Test on several tenants.
- Testing — load tests, isolation verification, failure scenarios. Use staging environment.
- Deployment — migrate existing clients to the new architecture (if legacy). Launch production.
What's Included
- Architectural documentation (diagrams, schema descriptions)
- Source code for tenant provisioning and database client modules
- Migration scripts and deployment instructions
- Access to repository and CI/CD
- 1 month of basic support after delivery
Why Choose Us
We have over 7 years of commercial experience with PostgreSQL and SaaS products. We have delivered over 15 projects with multi-tenant architecture. We guarantee data confidentiality—we sign NDAs. We provide transparent time-tracking and weekly reports. Contact us to discuss your project and get a custom proposal.







