Multi-Tenancy: Serving Hundreds of Customers From One System

A database per customer or a row per customer? The decision that sets your cost, your speed, and your ceiling for years.

The first architectural decision in any platform serving more than one customer is: where does one customer end and the next begin? It looks purely technical, but it sets your monthly bill, the lead time on every feature, and what happens the day an enterprise buyer asks for a security review.

Three models, no single right answer

  • Database per tenant: maximum isolation, trivial per-customer restore, highest cost, slowest migrations — every schema change runs N times.
  • Schema per tenant: a workable middle ground on PostgreSQL, but schema count becomes its own operational burden past a few hundred.
  • Shared rows with a tenant_id column: cheapest and fastest to scale, with all the risk concentrated in one place — one missing predicate in one query.

Why shared rows usually win

Small customers are the majority in any SaaS product, and most store megabytes rather than gigabytes. Giving each one a dedicated database means paying for isolation they will never consume 1% of. Start shared, and keep full isolation as a paid tier for the buyers who ask.

Make isolation the system's job, not the developer's

The danger of shared rows is that forgetting `WHERE tenant_id = ?` does not crash anything — it silently shows one customer another customer's data. Do not rely on discipline. Enforce isolation below the query: row-level security policies, or a data-access layer that cannot be called without tenant context.

Any safety boundary that depends on a developer remembering is one you will forget exactly once, and that is the time that counts.

Shared keys mean shared limits

Logical isolation does not stop one tenant affecting another. A single heavy query slows everyone. Put per-tenant ceilings on request rate, upload size, and concurrent jobs, and track consumption per tenant rather than only in aggregate.

Migrations are the real test

  1. Add the column as nullable first, and ship the code that writes to it.
  2. Backfill in batches, never in one transaction.
  3. Switch reads to the new column behind a flag.
  4. Drop the old one only after you have confirmed nothing reads it.

That sequence feels slow until the first time you have to reverse a migration on live data belonging to hundreds of customers. The practical tool for enforcing isolation below the query is PostgreSQL row security policies, which make the predicate a property of the table rather than of whoever wrote the query.

When to revisit the decision

Reopen it when a customer requires data residency in a specific region, when one tenant exceeds 10% of your total volume, or when a compliance report becomes a condition of sale. At that point physical isolation stops being a cost and becomes something you sell.

For the full space of tenancy patterns and their limits, Microsoft's architectural guidance for multitenant solutions is the most thorough reference available. If you are choosing a model now, talk to us before it hardens into your codebase — changing it later is a migration, not a refactor.

FAQ

Does RLS replace application-level checks?

It is the last line, not the only one. Use both: the application prevents the common mistake, the database policy catches the one that slipped through.

How do I actually test isolation?

Write a test that creates two tenants and tries to read the first one's data under the second one's context through every endpoint. Any successful read is a failing test.

When should I move a customer to a dedicated database?

When their size or security requirements become an exception that costs every other customer performance or complexity.

النسخة العربية