Multi-tenancy and Data Isolation
Run many customers (tenants) on the same software/infra, while keeping each tenant's data and performance separate from and safe from the others.
63.1 Why is it used?
- In SaaS (like Shopify, Zoho, Freshworks) thousands of businesses use one platform. A separate setup for each would be expensive.
- Data leak is the biggest risk: if Tenant A sees Tenant B's orders, the business is finished.
- Noisy neighbour: if one big tenant runs a heavy query, everyone else should not slow down.
- Big enterprise clients ask for a separate DB/region for compliance. The architecture has to be flexible.
- The right model lowers cost per tenant and makes onboarding happen in minutes.
63.2 How it works
- 1.Silo (DB per tenant): separate DBs for tenants A, B, C. Strongest isolation, highest cost. Best: enterprise, bank, health
- 2.Pool (shared + tenant_id): one shared table, tenant_id = A / B / C on each row. Isolation depends on RLS, lowest cost. Best: small SMBs, free tier
- 3.Bridge (schema per tenant): 1 DB server, a separate schema per tenant (a, b, c). Medium isolation, cost in between. Best: a mixed customer base
A tiered approach is common in the real world: free/basic plans in the pool, premium plans in silo
- 1.Shopkeeper t42 logs in: tenant_id = t42 in the JWT
- 2.API Gateway: per-tenant rate limit. t99's 100x sale traffic -> 429 throttle, other tenants normal
- 3.App service: SET app.tenant_id = 't42' in the DB session
- 4.PostgreSQL / Citus: RLS ON, non-owner role. From the orders table, after the RLS filter only t42's rows are visible (t17's and t99's rows are hidden)
- 5.tenant_id everywhere: logs, metrics, billing, cache key (t42:order:9)
Even if you forget WHERE in SELECT * FROM orders, the policy USING (tenant_id = current_setting('app.tenant_id')) returns only t42's rows
- 1.50,000 small tenants -> Pool: a Citus cluster, tenant_id as shard key + RLS
- 2.20 enterprise / bank -> Silo: a separate DB + K8s namespace (ResourceQuota) or a separate AWS account
- Silo model: a separate DB/stack per tenant (or a separate account). Best isolation, but higher cost and ops.
- Pool model: all tenants in the same DB/tables, with a tenant_id column in every row. Cheap, but isolation depends on app logic.
- Bridge model: the middle path. A shared DB server with a separate schema per tenant; or small tenants in the pool and big ones in silo.
- Row-Level Security (RLS): the DB itself adds the tenant_id filter to every query. Even if a developer forgets WHERE, there is no leak.
- Tenant-aware sharding: make tenant_id the shard key. One tenant's data sits on one shard, and a big tenant gets a dedicated shard.
- Noisy neighbour control: per-tenant rate limits, quotas, resource limits and moving a big tenant to a separate pool.
- Tenant context: tenant_id in the login token (JWT), a tenant tag on every request/log/metric. For both debugging and billing.
- Kubernetes namespaces: compute isolation with a namespace per tenant + ResourceQuota + NetworkPolicy.
63.3 Tools you can use
| Tool | Type | How it is used |
|---|---|---|
| PostgreSQL Row-Level Security | DB feature | CREATE POLICY tenant_iso ON orders USING (tenant_id = current_setting('app.tenant_id')::uuid); set the tenant in every session. |
| Separate DB per tenant (RDS / Azure SQL elastic pools) | Silo storage | Each tenant has its own database; in an Azure SQL elastic pool many DBs share one resource pool. |
| Citus (distributed PostgreSQL) | Tenant-aware sharding | Distribute tables on tenant_id; one tenant's queries go to a single node. |
| Kubernetes Namespaces | Compute isolation | CPU/memory and network are separated with a per-tenant namespace, ResourceQuota, LimitRange and NetworkPolicy. |
| AWS IAM + separate accounts (AWS Organizations) | Account-level silo | Premium tenants get a separate AWS account. Blast radius and billing are both separate. |
63.4 Comparison: Silo vs Pool vs Bridge model
| Point | Silo (DB per tenant) | Pool (shared tables + tenant_id) | Bridge (schema per tenant / mixed) |
|---|---|---|---|
| Isolation | Strongest | Weakest, depends on RLS/app logic | Medium |
| Cost per tenant | Highest | Lowest | In between |
| Noisy neighbour | Almost none | Highest risk | Low, but possible on a shared server |
| Schema migration | Run separately on every DB, slow | Once, easy | Run on every schema |
| Number of tenants | Practical up to hundreds | Up to lakhs | Up to thousands |
| Per-tenant backup/restore | Easy | Hard, rows have to be filtered | Possible at schema level |
| Best for | Enterprise, regulated (bank, health) | Small SMB tenants, free tier | A mixed customer base |
63.5 When to choose which
- Silo: big enterprise customers, strict compliance, or those who ask for a separate region/data residency.
- Pool + PostgreSQL RLS: thousands of small tenants, low cost, fast onboarding.
- Bridge/tiered: free/basic plans in the pool, premium plans in silo. The most common real-world approach.
- Citus/sharding by tenant_id: when the pool model's data does not fit in a single DB.
- Kubernetes namespace per tenant: when you also want to isolate compute but do not want the cost of a separate cluster.
63.6 Real-world example
Scenario: a B2B SaaS billing platform (like Zoho/Shopify).
- The platform has 50,000 small shopkeepers and 20 big enterprise clients.
- Small tenants are in the pool model: one PostgreSQL (Citus) cluster, tenant_id in every table, RLS policy enabled.
- A request comes in, tenant_id is taken from the JWT, and SET app.tenant_id = '...' is run on the DB session.
- Even if a developer forgets to add WHERE tenant_id, RLS returns only that tenant's rows.
- One tenant sends 100x traffic in a sale; the per-tenant rate limit throttles it, and the other tenants stay normal.
- A big bank client takes a separate DB and a separate Kubernetes namespace (or a separate account) for compliance. This is silo.
- For billing, every metric/log is tagged with tenant_id: you know exactly how much resource each tenant used.
63.7 Interview tips
- Trusting only WHERE tenant_id in app code: one bug and there is a data leak. Add DB-level RLS.
- Not putting tenant_id in cache keys: Redis ends up serving another tenant's data.
- Connecting the app as a superuser/table owner: RLS gets bypassed in PostgreSQL (use FORCE ROW LEVEL SECURITY or a non-owner role).
- Interview tip: be sure to explain the silo vs pool vs bridge trade-off (cost vs isolation) and the noisy neighbour solution.