Technology

Multi-Tenant Data Isolation: Shared Schema vs Separate Databases

Advanced 18 min read VisTechie Team Technology
1 views

The question every multi-tenant app has to answer

Once a SaaS product has more than one paying customer sharing the same application, there's a single design decision that shapes almost everything downstream: how do you physically separate one tenant's data from another's? Get it right early and it barely comes up again. Get it wrong, and it becomes the kind of bug that ends up in a breach disclosure instead of a bug tracker.

There are three approaches most teams reach for, and each one trades isolation strength for operational simplicity in a different place.

Approach 1 - shared tables with a tenant_id column

This is the default most teams start with, and for good reason: one schema, one connection pool, one set of migrations. Every tenant-owned table gets a tenantId column, and every single query filters on it.

// Every query MUST include tenantId - there is no schema-level
// guarantee that protects you if a developer forgets it.
const invoices = await Invoice.find({
  tenantId: currentTenant._id,
  status: "paid",
});

The risk here isn't theoretical - it's that isolation now depends entirely on every developer remembering to add that filter, every single time, in every query, forever. Miss it once in an admin endpoint and you've built a cross-tenant data leak. The fix most production systems land on is to never let a raw query run without it, by wrapping the model layer so the filter is impossible to skip.

// A thin repository wrapper that injects tenantId automatically,
// so individual call sites can't accidentally omit it.
class TenantScopedRepo {
  constructor(model, tenantId) {
    this.model = model;
    this.tenantId = tenantId;
  }
  find(filter = {}) {
    return this.model.find({ ...filter, tenantId: this.tenantId });
  }
  findOne(filter = {}) {
    return this.model.findOne({ ...filter, tenantId: this.tenantId });
  }
}

MongoDB makes this pattern especially natural because you can also compound-index on { tenantId: 1, ...otherFields } so every tenant-scoped query stays fast even as the collection grows into the millions of documents shared across tenants.

Approach 2 - one schema per tenant, same database

PostgreSQL's schema feature lets you keep one physical database but give each tenant its own namespace - tenant_acme.invoices versus tenant_globex.invoices. Isolation is enforced by the database engine itself rather than by application code remembering to filter correctly, which removes an entire category of "someone forgot the WHERE clause" bugs.

-- Switching the search_path effectively scopes every unqualified
-- query to that tenant's schema for the rest of the session.
SET search_path TO tenant_acme, public;
SELECT * FROM invoices WHERE status = 'paid';

The catch is migrations. Adding a column now means running that migration against every tenant schema, not once. At a few dozen tenants this is a script you run in a loop. At a few thousand, it becomes its own piece of infrastructure - usually a queued migration runner that works through schemas in batches and can resume if one fails partway through.

Approach 3 - a database per tenant

This is the strongest isolation available short of physically separate infrastructure, and it's what regulated industries (healthcare, finance) usually end up requiring for their larger customers. Each tenant gets a dedicated database, sometimes on dedicated hardware. A connection router looks up which database a request belongs to before anything else happens.

async function getTenantConnection(tenantId) {
  const cached = connectionPool.get(tenantId);
  if (cached) return cached;

  const tenant = await MasterDB.Tenant.findById(tenantId).lean();
  const conn = mongoose.createConnection(tenant.dbUri, {
    maxPoolSize: 5,
  });
  connectionPool.set(tenantId, conn);
  return conn;
}

The obvious cost is operational: backups, monitoring, connection pooling, and schema migrations all now happen N times instead of once. Most teams that go this route reserve it for enterprise-tier customers specifically, while smaller customers stay on a shared database - a hybrid model rather than an all-or-nothing choice.

Picking one without over-building

Shared-table with enforced scoping is the right starting point for almost every SaaS - it's cheap to build, cheap to run, and the wrapper pattern above closes the main risk. Move to schema-per-tenant when a specific customer contractually requires stronger isolation, and reserve database-per-tenant for the handful of enterprise accounts where the isolation guarantee is actually part of what you're selling. Building database-per-tenant on day one for a product with three customers is optimizing for a scale problem you don't have yet, at the cost of a migration story you'll be maintaining forever.

1 views

Comments

0/2000

Comments are reviewed before being published.

Loading comments...

Related Articles