skip to content

Partitioning & Scaling

How a relational database keeps performing when a table grows past what one heap and one set of indexes can handle, and when the whole workload outgrows a single server. Interviewers use this area to test whether you know the engine-level mechanics — partitioning, sharding, tenancy layout, read/write scaling — rather than just the buzzwords.

part ofRelational database conceptsoverview, primer and where to startread it →
on this pageshow

questions

page 1 of 2

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

open as a page

When you create an index on a partitioned table, what is physically created, and how does that differ from indexing an ordinary non-partitioned table?

level: juniorimportance: must knowfreq 55%

basics

~20 s

On a non-partitioned table you get one physical index over all rows. On a partitioned table, most engines build one physical index per partition, each covering only that partition's rows; the index you declared on the parent is just metadata tying them together.

open as a page

What is partition pruning in a partitioned table, what does a query have to contain for the database to do it, and how is it different from using an index?

level: juniorimportance: must knowfreq 62%

basics

~20 s

Pruning is the database eliminating partitions that cannot contain matching rows, using each partition's declared bounds. It needs a predicate on the partition key. An index narrows the search inside a table; pruning removes whole tables from the plan before any scan happens.

open as a page

What does adding read replicas to a relational database actually scale, and what does it not scale?

level: juniorimportance: must knowfreq 66%

basics

~20 s

Replicas add read capacity: full copies of the data that can serve queries. They do not add write capacity — every replica must apply the same write stream as the primary — and with asynchronous replication their data is slightly behind, so reads can be stale.

open as a page

A relational engine lets you declare one logical table as a set of physical child tables using RANGE, LIST or HASH. Explain what table partitioning is and what each of the three strategies is for.

level: juniorimportance: must knowfreq 60%

basics

~20 s

Partitioning splits one logical table into physical pieces inside the same database, chosen by a partition key. RANGE assigns contiguous intervals (usually dates), LIST assigns explicit value sets (region, status), HASH spreads rows evenly by a hash of the key.

open as a page

What is the difference between scaling a relational database vertically and horizontally, and what does each approach actually buy you?

level: juniorimportance: must knowfreq 70%

basics

~20 s

Vertical scaling means one bigger machine: more CPU, RAM, faster disks. Horizontal scaling means more machines: read replicas to spread reads, or shards to split data across independent primaries. Vertical is simple but capped; horizontal is unbounded but changes the programming model.

open as a page

A team assigned rows to database shards with hash(key) mod N, where N is the number of servers. Why does adding a server become painful, and what assignment schemes avoid that?

level: middleimportance: must knowfreq 58%

basics

~20 s

Changing N changes the modulus, so nearly every key maps somewhere new and almost the whole dataset has to move. Consistent hashing (a hash ring) moves only about 1/N of keys; the common relational approach is fixed virtual buckets — hash into e.g. 4096 slots and move whole slots between servers.

open as a page

Two large tables in a sharded relational database must be joined, but rows with the same join value can live on different shards. What options do you have, and which do you pick for an OLTP request path?

level: middleimportance: must knowfreq 52%

basics

~20 s

Options: co-locate both tables on the same shard key so the join is local; replicate small reference tables to every shard; denormalize the needed columns into the row; or join in the application after two lookups. For OLTP, co-locate or denormalize — never shuffle data between shards per request.

open as a page

In a horizontally sharded relational deployment, what happens when a query's filter does not contain the shard key, and how does its cost differ from a single-shard query?

level: middleimportance: must knowfreq 58%

basics

~20 s

The router cannot pick one shard, so it broadcasts to all of them and merges the results — a scatter-gather. Latency becomes the slowest shard's, work multiplies by shard count, and ORDER BY, LIMIT, and aggregates must be recombined by the router.

open as a page

Compare the three common ways to lay out data for many customer organizations in a relational system: all tenants sharing tables with a tenant_id column, one schema per tenant inside a shared database, and one database per tenant. How do they differ in isolation, cost per tenant, and operational effort?

level: middleimportance: must knowfreq 62%

basics

~20 s

Shared tables are cheapest and densest but rely on code for isolation and make per-tenant restore hard. Schema-per-tenant separates data structurally but fans out migrations and bloats the catalog. Database-per-tenant gives the strongest isolation and easy per-tenant backup, at the highest fixed cost per tenant.

open as a page

Compare a partition-local index with an Oracle-style global index on the same partitioned table: what does each buy you at query time, and what does each cost you when partitions are dropped or exchanged?

level: middleimportance: must knowfreq 48%

basics

~20 s

A local index is one index per partition: cheap partition lifecycle, per-partition rebuilds, but N probes when the query cannot be pruned, and uniqueness only within a partition. A global index is one index over all partitions: single-probe lookups and true global uniqueness, but dropping or exchanging a partition invalidates it and forces maintenance.

open as a page

Why do databases whose partitioned tables only support local indexes require a PRIMARY KEY or UNIQUE constraint to include the partition key columns, and what are your options when the natural key doesn't contain them?

level: middleimportance: must knowfreq 58%

basics

~20 s

Uniqueness is enforced by a per-partition index that only sees its own rows, so duplicates in different partitions would go undetected. Including the partition key guarantees a given key value can only land in one partition, making local enforcement equal global enforcement. Otherwise: repartition, use a global index engine, or enforce elsewhere.

open as a page

You keep 90 days of event history in a very large table and must expire the oldest day every night. Compare running a DELETE for the expired rows against dropping a whole time-based partition: what does each actually cost the database, and what does the cheap option require of the table's design?

level: middleimportance: must knowfreq 68%

basics

~20 s

DELETE touches every row: a log record per row, dead row versions, index entries left behind, bloat, and vacuum work afterwards, and the space is not returned. Dropping a partition is a catalog change plus file unlink: constant time, space freed at commit. It requires partitioning by the retention key.

open as a page

A query filters on the very column a table is partitioned by, yet the plan still reads every partition. What common mistakes in how the predicate is written prevent pruning, and how would you rewrite them?

level: middleimportance: must knowfreq 56%

basics

~20 s

Wrapping the partition key in a function or cast, comparing it to a mismatched type, filtering on a derived column instead of the key, using an operator the partitioning strategy cannot use (a range test on hash partitions), or having no direct predicate at all because the restriction is on a joined table. Rewrite as a plain half-open range on the raw key.

open as a page

A user saves their profile and the next page immediately shows the old values. Reads are served by an asynchronous replica. Explain what is happening and the ways to fix it.

level: middleimportance: must knowfreq 58%

basics

~20 s

The write committed on the primary but the replica hadn't replayed it yet — replica lag breaks read-your-own-writes. Fixes: route a user's reads to the primary for a short window after they write, or capture the write's log position and make the replica wait until it has applied at least that position.

open as a page

When splitting a table's rows across independent database servers, the placement function can either hash the chosen key or assign contiguous ranges of it. Compare hash-based and range-based placement, and say which workloads suit each.

level: middleimportance: must knowfreq 55%

basics

~20 s

Hashing spreads keys uniformly, so load balances well but ordered and range scans must hit every shard. Range placement keeps neighbouring keys together, so range scans are local and splits are cheap, but sequential keys create a hot shard on the newest range.

open as a page

A single relational database server can no longer absorb your write volume, so the team proposes splitting the rows of the biggest tables across many independent database servers. What is sharding, and what properties make a column a good shard key?

level: middleimportance: must knowfreq 62%

basics

~20 s

Sharding splits the rows of one logical table across several independent database servers, choosing the server by a shard key. A good shard key appears in nearly every query, has high cardinality, spreads load evenly, and keeps rows that are read together on the same shard.

open as a page

You are converting a very large table to a partitioned one and must commit to a partition key and granularity. How do you choose the column, and what makes a partition key a bad one?

level: middleimportance: must knowfreq 52%

basics

~20 s

Pick the column that appears in most query predicates and governs how data expires — usually a timestamp. Size partitions so each holds a manageable slice, typically tens to a few hundred per table. A bad key is one your queries do not filter on, or one that creates thousands of tiny partitions.

open as a page

A team adds five more database servers to a relational cluster and is surprised that overall write throughput barely changes. Why does adding machines fail to multiply write capacity in a typical single-primary relational deployment, and what actually raises that ceiling?

level: middleimportance: must knowfreq 60%

basics

~20 s

In a single-primary cluster every write is executed once on the primary and then replayed on the others, so the extra machines are copies, not additional writers. The ceiling is one machine's commit path. You raise it by making that path cheaper, or by having more primaries — i.e. sharding.

open as a page

A single business operation must write rows on two different shards atomically. Explain why teams avoid two-phase commit (2PC/XA) for this and what they build instead.

level: seniorimportance: must knowfreq 50%

basics

~20 s

Two-phase commit holds locks while it waits for a coordinator, blocks in-doubt if the coordinator dies, adds round trips and fsyncs, and makes availability the product of all participants. Teams instead redesign so the write is single-shard, or use a saga with idempotent compensating steps plus a transactional outbox.

open as a page

Walk through how you move a slice of data from one shard to another while the application keeps reading and writing, with no downtime. What are the steps and where does correctness break?

level: seniorimportance: must knowfreq 45%

basics

~20 s

Copy the slice's existing rows to the target, keep applying ongoing changes until lag is near zero, briefly fence writes for that slice, verify with checksums, flip the routing directory to the new shard, then keep the source read-only as a rollback path before deleting it.

open as a page

In a shared-table multi-tenant database where isolation depends on every query carrying a tenant filter, how would you stop one missing filter from exposing another customer's rows? Discuss database row-level security policies versus filtering only in application code, including how the tenant identity reaches the database over a connection pool.

level: seniorimportance: must knowfreq 44%

basics

~20 s

Layer defenses: inject the tenant predicate at one application choke point, and enforce it again in the database with row-level security policies keyed off a per-transaction session setting. Force policies so table owners are not exempt, reset the setting on pooled connections, and test cross-tenant access.

open as a page

Describe what a database does when you attach an already-populated table as a new partition of a partitioned table, and when you remove an existing partition from it. Which locks are taken, how can you make the attach avoid a full validation scan, and what does a concurrent detach (PostgreSQL's DETACH PARTITION CONCURRENTLY) buy you?

level: seniorimportance: must knowfreq 44%

basics

~20 s

Attach validates that every row in the incoming table satisfies the new bound, scanning it under an exclusive lock on that table unless a matching validated CHECK constraint lets the scan be skipped. Detach needs an exclusive lock on the parent; a concurrent detach takes a weaker lock in two steps instead of blocking all queries.

open as a page

Explain the difference between eliminating partitions when a query's plan is built and eliminating them while the query runs. Give query shapes that can only be handled at execution time, and describe what you would look for in EXPLAIN output to tell which one happened.

level: seniorimportance: must knowfreq 40%

basics

~20 s

Plan-time pruning uses values known while planning, so unneeded partitions never enter the plan. Execution-time pruning handles values known only at run time: bind parameters of a reusable plan, subquery results, and per-row values in a nested loop. In EXPLAIN ANALYZE it shows as "Subplans Removed" or child nodes marked "never executed".

open as a page

One customer organization asks you to roll their data back to yesterday's state after a bad bulk import, and another asks for complete deletion of their data at the end of their contract. How does the multi-tenant storage layout - shared tables with a tenant_id column, one schema per tenant, or one database per tenant - change how you satisfy each request?

level: middleimportance: should knowfreq 32%

basics

~20 s

With a database per tenant, restore that database to a point in time and drop it on exit. With schema per tenant, restore a side copy and dump or swap the one schema. With shared tables, restore the whole database to a side copy, extract that tenant's rows in dependency order and merge them back, and delete by cascading batched DELETEs.

open as a page

In a table partitioned by day on an event timestamp, what happens when a row arrives whose timestamp falls outside every existing partition? Describe how teams prevent that, and the tradeoffs of adding a catch-all partition (a DEFAULT partition, or a MAXVALUE range) as the safety net.

level: middleimportance: should knowfreq 50%

basics

~20 s

The insert fails and aborts the transaction, since there is nowhere to route the row. Teams pre-create future partitions on a schedule with a runway buffer and alert when the runway shrinks. A catch-all partition prevents the error but silently collects rows and must be scanned and emptied before overlapping partitions can be added.

open as a page

Before engines gained built-in partitioning syntax, teams simulated it with parent/child table inheritance plus routing triggers and CHECK constraints. Compare that approach with modern declarative partitioning, and say when you would still see the old style.

level: middleimportance: should knowfreq 32%

basics

~20 s

The old style built partitions manually: child tables with CHECK constraints, a trigger or rule to route inserts, and the planner excluding children by constraint. Declarative partitioning makes the engine own routing, bounds and pruning — faster, correct by construction, and far less code to maintain.

open as a page

You run one schema per customer organization, 3,000 schemas in total, and need to add a column plus backfill it. Describe how you would roll that change out across all tenants, and what is harder about it than making the same change to one shared set of tables.

level: seniorimportance: should knowfreq 36%

basics

~20 s

Treat it as an orchestrated job, not one DDL: backward-compatible expand/contract steps, per-tenant version tracking, idempotent and resumable execution, batched throttled backfills, canary tenants first, and application code that tolerates tenants at both old and new versions mid-rollout.

open as a page

On a database shared by many customer organizations, one large customer's reporting queries are slowing everyone else down. Explain the mechanisms by which one tenant degrades others in a shared relational database, and what you would do about it at the database and architecture level.

level: seniorimportance: should knowfreq 38%

basics

~20 s

Tenants share the buffer cache, CPU, I/O, connections and locks, so one heavy tenant evicts others' cached pages and starves workers. Fix with statement timeouts and per-tenant connection quotas, offload reporting to a replica, tune plans for skewed tenants, and move whales to their own database.

open as a page

What are the gotchas with foreign keys when a partitioned table is involved — both a foreign key declared on a partitioned table and a foreign key from another table that references a partitioned table?

level: seniorimportance: should knowfreq 34%

basics

~20 s

Outgoing FKs from a partitioned child are usually fine — they're enforced per partition. Incoming FKs to a partitioned parent need a unique key on the parent, which must include the partition key, so the referencing table must carry the whole composite key. Some engines (MySQL/InnoDB partitioning) forbid foreign keys entirely.

open as a page

showing 1–30 of 46