All posts
Backend Architecture

Multi-Tenant SaaS: 4 Database Isolation Strategies Compared

Arif Iqbal·March 24, 2026·4 min read

Multi-tenant SaaS is one of those problems that looks simple on the surface and turns into a deeply architectural decision the moment you have real customers with real data isolation requirements.

I've architected multi-tenant systems across waste management logistics, e-commerce, and B2B SaaS — and the isolation strategy you pick in week one shapes everything: your query patterns, your migration story, your compliance posture, and how badly a bug in one tenant can affect another.

Here are the four main strategies, when to use each, and what the code actually looks like.

The Four Strategies

1. Shared Schema (Row-Level Isolation)

Every tenant's data lives in the same tables, separated by a tenant_id column.

schema.sql
CREATE TABLE orders (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id),
  customer_id UUID NOT NULL,
  total       INTEGER NOT NULL, -- stored in cents
  created_at  TIMESTAMPTZ DEFAULT now()
);
 
CREATE INDEX idx_orders_tenant ON orders(tenant_id);

In NestJS, you enforce this at the service layer using a request-scoped tenant context:

src/common/tenant.interceptor.ts
@Injectable()
export class TenantInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    const req = context.switchToHttp().getRequest();
    const tenantId = req.headers['x-tenant-id'];
 
    if (!tenantId) throw new UnauthorizedException('Missing tenant context');
 
    // Attach to AsyncLocalStorage so all downstream queries can read it
    return tenantStorage.run({ tenantId }, () => next.handle());
  }
}

Pros: Simple to operate, cheap, easy migrations (one schema to update).
Cons: One bad query missing WHERE tenant_id = ? leaks data across tenants. Noisy neighbour problem on large tables.

2. Schema-Per-Tenant

Each tenant gets their own PostgreSQL schema (tenant_abc.orders, tenant_xyz.orders) within the same database.

src/database/tenant-schema.service.ts
async function setTenantSchema(tenantSlug: string) {
  await dataSource.query(`SET search_path TO "${tenantSlug}", public`);
}

Pros: Strong logical isolation, shared connection pool, migrations are per-schema (good for customisation).
Cons: search_path must be set on every connection — easy to miss in async contexts. 1000+ tenants means 1000+ schemas, which PostgreSQL handles but monitoring becomes complex.

3. Database-Per-Tenant

Each tenant has a completely separate database. Your app manages a connection pool per tenant, resolved at runtime.

src/database/connection-manager.ts
const pools = new Map<string, DataSource>();
 
export async function getTenantConnection(tenantId: string): Promise<DataSource> {
  if (pools.has(tenantId)) return pools.get(tenantId)!;
 
  const config = await TenantConfigService.find(tenantId);
  const ds = new DataSource({
    type: 'postgres',
    url: config.databaseUrl,
    entities: [...],
  });
 
  await ds.initialize();
  pools.set(tenantId, ds);
  return ds;
}

Pros: Maximum isolation — a runaway migration on one tenant can't touch another. Compliance-friendly (GDPR right-to-erasure is just DROP DATABASE).
Cons: Expensive. Each database needs its own connection pool. Cross-tenant analytics becomes painful. Cold-start latency when the pool hasn't been initialised yet.

4. Row-Level Security (PostgreSQL Native)

PostgreSQL's built-in RLS enforces tenant isolation at the database engine level — not at the application layer. Even if your app sends a query missing WHERE tenant_id = ?, PostgreSQL silently adds the filter.

enable-rls.sql
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
 
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

Your app sets the setting at the start of each transaction:

src/common/rls.middleware.ts
async function withTenantRLS<T>(
  tenantId: string,
  fn: () => Promise<T>
): Promise<T> {
  return dataSource.transaction(async (em) => {
    await em.query(`SET LOCAL app.tenant_id = '${tenantId}'`);
    return fn();
  });
}

Pros: Strongest protection — isolation enforced at the database level, not just the app layer. Works transparently with ORMs.
Cons: SET LOCAL must happen inside a transaction. Debugging RLS policy failures is non-obvious. ORM query builders don't always cooperate.

How to Choose

StrategyTenantsComplianceCostComplexity
Shared schemaAnyLowLowLow
Schema-per-tenant< 500MediumLowMedium
Database-per-tenant< 50 enterpriseHighHighHigh
Row-Level SecurityAnyHighLowMedium

For most B2B SaaS: start with shared schema + RLS. You get strong isolation guarantees at low operational cost, and you can always migrate individual high-value tenants to their own database later.

That's the playbook I followed on the waste management SaaS at Sprinting Software — shared schema with RLS for the 40+ tenants, and a separate database for the two enterprise clients who needed contractual data isolation.

If you're building this in NestJS and want to see the full tenant context implementation with AsyncLocalStorage, get in touch — happy to share the pattern.


postgresqlsaasarchitecturenestjs

Arif Iqbal

Senior Backend Engineer with 10+ years building high-traffic platforms. NestJS · Node.js · Laravel · AWS · PostgreSQL. Open to remote & relocation.

Enjoyed this post?

Get my technical deep-dives in your inbox. No spam, unsubscribe anytime.

Discussion