skip to content

A SaaS application stores data for many customer organizations in one relational database, and every table carries a tenant_id column naming the owning organization. Explain how that discriminator column is meant to work: what must be true of every query, of primary keys and indexes, and of unique and foreign-key constraints for the design to be correct?

level: juniorimportance: must knowfreq 52%

answer

  1. tenant_id on every row, in every predicate
  2. leading column of PK and every index
  3. UNIQUE (tenant_id, email), not UNIQUE (email)
  4. composite FK = no cross-tenant reference
  5. one choke point injects the filter

basics

~20 s

tenant_id tags each row with its owning customer. Every read and write must filter on it, it should lead composite primary keys and indexes so a scan touches one tenant, unique constraints must include it, and foreign keys must stay inside one tenant.

solid answer

~50 s

The discriminator column records which customer owns each row, so isolation becomes a property of every access path rather than of the storage itself. Four rules: 1. **Every statement filters on tenant_id** - reads, updates, deletes. One missing predicate is a cross-tenant leak or a cross-tenant write, so the filter belongs at a single choke point (repository layer, ORM filter, or database row-level security), not in each hand-written query. 2. **tenant_id leads composite keys and indexes**: PRIMARY KEY (tenant_id, id), INDEX (tenant_id, created_at). A B-tree can only range-scan on a leading prefix, so an index that omits it forces every tenant to walk entries belonging to all tenants and discard them. Leading with tenant_id also gives physical locality, so one tenant's working set occupies fewer pages. 3. **Uniqueness is scoped per tenant**: UNIQUE (tenant_id, email), never UNIQUE (email), or one tenant's data blocks another's. 4. **Foreign keys carry tenant_id** so a child row cannot structurally point at another tenant's parent.

code

sql · 14 lines
sql
CREATE TABLE invoice (
  tenant_id      uuid        NOT NULL,
  invoice_id     uuid        NOT NULL,
  customer_id    uuid        NOT NULL,
  invoice_number text        NOT NULL,
  created_at     timestamptz NOT NULL,
  PRIMARY KEY (tenant_id, invoice_id),
  UNIQUE (tenant_id, invoice_number),
  FOREIGN KEY (tenant_id, customer_id)
    REFERENCES customer (tenant_id, customer_id)
);

CREATE INDEX invoice_recent
  ON invoice (tenant_id, created_at DESC);

go deeper

for a junior

State the four rules plainly: filter everywhere, tenant_id first in keys and indexes, per-tenant uniqueness, tenant-scoped foreign keys.

for a middle

Explain why the leading index column matters (prefix range scans, locality) and where the filter should be injected so it cannot be forgotten.

for a senior

Add defense in depth (row-level security under the application filter), cross-tenant tests, and the operational costs: statistics skew, per-tenant restore, noisy neighbors.

for a principal

Frame it as an isolation-versus-density decision, note that the discriminator must propagate to every derived store, and describe how the schema keeps the option of moving a tenant to its own database later.

## What a tenant and a discriminator column are A **tenant** is one customer organization on a shared application - a company, a workspace, an account. In the *pooled* (shared-table) layout, all tenants' rows live in the same tables, and a **discriminator column**, conventionally `tenant_id`, records the owner of each row. Nothing in the storage separates tenants; the separation exists only because every access path applies the right filter. That is the central thing to understand: in this layout, isolation is a *code and schema* property, not a physical one. ## Rule 1: every access path filters on tenant_id A SELECT missing the predicate returns other customers' data. An UPDATE or DELETE missing it modifies other customers' data - much worse, because it is silent and permanent. Because a single forgotten predicate is a breach, mature systems do not rely on discipline: they route all data access through one place that injects the tenant predicate from an authenticated request context (a repository base class, an ORM global filter, a set of views), and often add database **row-level security** policies underneath as a second, independent layer. Never trust that an id is unguessable: always re-filter server-side, or an attacker who learns another tenant's row id reads it directly. ## Rule 2: tenant_id leads composite keys and indexes A B-tree index is sorted on its column list and can only restrict a scan by a **leading prefix**. An index on `(created_at)` in a table shared by 5,000 tenants means a query for "my last 50 invoices" walks index entries for all 5,000 tenants in that date range and throws most away - work that grows with total tenants, not with your tenant. An index on `(tenant_id, created_at)` seeks straight to that tenant's slice. The same argument applies to the primary key. `PRIMARY KEY (tenant_id, entity_id)` also clusters (in engines with clustered or index-organized tables) each tenant's rows together, so a small tenant's whole dataset may be a handful of pages, which is far better for buffer-cache hit rate than rows scattered across the table by a global surrogate key. ## Rule 3: uniqueness is per tenant Business uniqueness rules are almost always *within* a customer: invoice numbers, usernames, SKU codes. `UNIQUE (email)` in a pooled table means one tenant registering [email protected] prevents another tenant from doing so - a support incident and an information leak, since the failure reveals the address exists somewhere. Scope it: `UNIQUE (tenant_id, email)`. The rare exception is a genuinely global identity (a login that spans tenants), which then belongs to a separate, deliberately global table. ## Rule 4: foreign keys should carry tenant_id A plain `FOREIGN KEY (customer_id) REFERENCES customer(id)` permits an invoice in tenant A to reference a customer in tenant B if application code ever gets it wrong. Widening the child key to `(tenant_id, customer_id)` referencing `(tenant_id, customer_id)` makes crossing a tenant boundary a constraint violation - the database enforces the invariant instead of hoping the code does. ## What the pooled layout buys and costs It is the cheapest per tenant: one schema, one connection pool, one migration, and idle tenants consume almost nothing, which is what makes freemium and long-tail SaaS economically viable. The costs are the mirror image: the weakest isolation (a bug leaks data across customers), per-tenant restore and per-tenant deletion are surgery rather than a file operation, one heavy tenant's queries evict everyone's cached pages, and the optimizer's table-level statistics describe an *average* tenant, so plans chosen for a 1,000-row tenant can be badly wrong for a 50-million-row one. Those are the reasons teams reach for schema-per-tenant or database-per-tenant. ## Beyond the database The discriminator has to follow the data everywhere: cache keys, search-index documents, message payloads, file and object storage prefixes, and exported reports all need the tenant in the key. Isolation that stops at the database boundary leaks at the first derived store.

  • Why is an index on (created_at) alone a problem in a pooled multi-tenant table?
    A B-tree restricts a scan only by a leading prefix, so a query for one tenant's recent rows scans index entries for every tenant in that time range and filters the rest out. The work grows with the total number of tenants rather than with the size of the requesting tenant. Putting tenant_id first turns it into a seek into that tenant's contiguous slice.
  • Where would you put the tenant filter so a developer cannot forget it?
    At a single choke point: a repository base class or ORM global filter that reads the tenant from the authenticated request context and injects the predicate into every query, so no hand-written WHERE clause is trusted. Underneath that, database row-level security enforces the same rule independently, so an ad-hoc query or a bug above still cannot see another tenant. Add tests that assert a query issued as tenant A returns nothing belonging to tenant B.

saying these in an interview costs you the question

  • Treating tenant_id as documentation and relying on the application to 'remember' to filter
  • Leaving UNIQUE constraints global, so one tenant's value blocks another's
  • Assuming unguessable UUID primary keys make a tenant filter unnecessary
  • Appending tenant_id as the last index column instead of the first
  • Scoping the database but forgetting caches, search indexes, and file storage

context