A multi-tenant product lets every customer define their own extra fields on a record, and some customers want to filter and report on those fields. How would you decide among the storage strategies for that, and what would you build?
answer
- Metadata catalog first, storage second
- Tenants x fields decides shared vs per-tenant DDL
- Displayed-only fields need no index
- Promote filterable fields to generated columns + partial index
- Publish per-tenant limits on day one
basics
~20 sChoose by tenant count, field count, and whether custom fields are filtered in bulk. Candidates: attribute-value rows, a JSON column, a pool of pre-created generic typed columns mapped per tenant, or real per-tenant DDL. In practice: a field-definition catalog plus a JSON column, with a promotion path to indexed generated columns for fields tenants actually query.
solid answer
~60 sStart from the constant across all designs: a **field-definition catalog** per tenant (name, type, required, enum, uniqueness) plus a single write gate that enforces it. Whichever storage you pick, that metadata is the contract, so build it first. Then pick storage against three questions. How many tenants times fields - thousands of tenants each defining twenty fields rules out per-tenant DDL, because catalog bloat and online schema changes at that scale are an operational hazard. Are custom fields filtered, sorted or aggregated in bulk, or only displayed - display-only tails do not need per-field indexes. And what enforcement is contractual - uniqueness and referential rules on a custom field push toward real columns. My default is a JSON column for the tail, with the whole document inverted-indexed for ad-hoc containment filters, plus a promotion mechanism: when a tenant's field becomes hot, materialise it as an indexed generated column, or for heavy reporting tenants project into a per-tenant read model. Attribute-value rows only if per-attribute writes and history must be tracked individually.
code
sql · 20 linesCREATE TABLE custom_field (
tenant_id bigint NOT NULL,
field_key text NOT NULL,
datatype text NOT NULL,
required boolean NOT NULL DEFAULT false,
filterable boolean NOT NULL DEFAULT false,
PRIMARY KEY (tenant_id, field_key)
);
ALTER TABLE record ADD COLUMN custom jsonb NOT NULL DEFAULT '{}'::jsonb;
CREATE INDEX record_custom_gin ON record USING gin (custom);
-- promotion: one tenant filters heavily on 'contract_value'
ALTER TABLE record
ADD COLUMN contract_value numeric
GENERATED ALWAYS AS ((custom->>'contract_value')::numeric) STORED;
CREATE INDEX record_contract_value_t42
ON record (contract_value)
WHERE tenant_id = 42;go deeper
Recognise the options exist and that a metadata table describing each tenant's fields is required; do not be expected to pick between them.
Compare attribute-value rows, a JSON column and pre-created generic columns on query support and enforcement, and pick one with a stated reason.
Drive the choice from measured query patterns, specify the indexing strategy including tenant-scoped partial indexes, and design the promotion path plus per-tenant limits.
Treat it as a product and operational decision: tenant scale shape, what the platform contractually enforces versus what the write gate enforces, reporting split between OLTP and a read model, and the exit path if the initial choice proves wrong.
## What the question is really testing There is no correct answer; there is a defensible one. The interviewer wants to see you separate the metadata problem from the storage problem, quantify the workload, and pick something with an exit path. ## The invariant: a field-definition catalog Every viable design needs tenant-scoped metadata: field key, display name, datatype, required flag, default, allowed values, uniqueness, and whether it is filterable. This catalog is what the UI renders, what validation enforces, what the query builder consults, and what migrations read. Teams that skip it end up with the storage layer as the only source of truth, which is exactly what makes flexible schemas rot. A single write gate - one service path that validates every write against the catalog - is what substitutes for the constraints the database can no longer give you. ## The storage candidates **Attribute-value rows.** One row per (record, field, value). Strength: per-field granularity - you can attach per-value metadata, per-field history, per-field permissions, and index `(field_id, value)` for cross-tenant "find records where field X = Y". Weakness: everything covered by the classic critique - no typing, no required-ness, pivoting on every read, blended statistics, huge row counts. Choose it when per-field auditing or per-field access control is a product requirement, not merely when fields are dynamic. **A JSON column on the record.** One document per record holding the tenant's custom fields. Strength: keeps one row per record, so reads never pivot; nesting and arrays work; an inverted index supports "documents containing this key/value" so a tenant can filter on any field they defined; a specific path can be promoted to an indexed generated column. Weakness: no foreign keys into the document, constraints only via CHECKs on extracted expressions, weak selectivity estimates for path predicates, whole-value rewrites, key names repeated per row. **Pre-created generic columns.** The table ships with, say, `cf_text_1..40`, `cf_num_1..20`, `cf_date_1..10`, and the catalog maps tenant field "Contract Value" to `cf_num_3`. Strength: real typed columns with real indexes and real statistics; queries become ordinary SQL after a metadata-driven rewrite. Weakness: a hard ceiling per type, index budget shared across tenants (an index on `cf_num_3` serves one field for tenant A and a different field for tenant B, so it must usually be a filtered/partial index per tenant), and gnarly reassignment when a tenant deletes and re-adds fields. This is the classic large-SaaS approach and it works, but it demands a mature metadata layer. **Real per-tenant DDL.** Add actual columns, or a per-tenant extension table or schema. Strength: everything the relational engine offers - types, NOT NULL, CHECK, foreign keys, statistics, plain indexes. Weakness: DDL becomes a runtime operation driven by customer actions; with thousands of tenants you get catalog bloat, backup and migration times that scale with tenant count, lock risk on hot tables, and a deployment pipeline that must tolerate per-tenant divergence. Reasonable at tens to low hundreds of tenants, especially with per-tenant schemas or databases already in place for isolation reasons; unreasonable at tens of thousands of small tenants. ## The deciding questions 1. **Scale shape.** Few large tenants favours per-tenant physical schema; many small tenants favours a shared, document-shaped tail. 2. **Query pattern.** Fields that are only displayed on a record page need no index at all - fetch the row, render the document. Fields used in list filters, saved searches, sorting, or reporting need an access path, and that is what drives you toward materialised columns. 3. **Enforcement contract.** If a custom field must be unique per tenant, or must reference another record, only real columns or per-tenant tables give you database-enforced guarantees; everything else is application-enforced and will drift. 4. **Operational maturity.** Can you run online schema changes safely, at customer-action frequency, on your largest tables? If not, designs that require runtime DDL are off the table regardless of their elegance. 5. **Reporting path.** If heavy analytics on custom fields is a selling point, the honest answer is often to keep the operational store simple and project into a per-tenant read model or warehouse where custom fields become real columns, rather than making the OLTP schema carry analytics. ## What I would build - Field-definition catalog plus one validating write gate; the catalog also declares which fields are filterable. - Custom values in a JSON column on the record, always tenant-scoped in every query. - An inverted index on the document for ad-hoc containment filters, sized and monitored because it is the expensive index. - A promotion mechanism: when a field is marked filterable, or when telemetry shows it in hot queries, materialise it as a generated column with a partial index restricted to that tenant, and let the query builder use it transparently. Nothing about the tenant-facing contract changes. - Hard limits per tenant on field count, document size, and how many fields may be filterable, published as product limits from day one - retrofitting limits is much harder than launching with them. - Telemetry per field: writes, reads, filter usage. This is what tells you which fields to promote and which tenants to move to a dedicated read model. ## Failure modes to name Unbounded field counts; per-tenant index sprawl; queries that omit the tenant predicate and therefore scan every tenant's data; migrations that must rewrite every document; and the slow slide where core product attributes get created as custom fields because that path is easier than shipping a column.
- A tenant wants a custom field to be unique across their records. How do you support that?With the values in a document you cannot use a plain unique constraint, but you can create a unique index over the extracted expression restricted to that tenant - effectively a partial unique index on the materialised path where tenant_id matches. That is database-enforced and correct, but it means a DDL operation per tenant per unique field, so it must be rate-limited and capped as a product limit. The weaker alternative is an application check, which races under concurrency unless serialised by a lock or a supporting unique table.
- Why is per-tenant DDL, adding a real column when a customer creates a field, so risky at large tenant counts?It turns a customer UI action into a schema change on a production table, so you inherit lock behaviour, replication lag, and the failure modes of online schema change at customer-action frequency. It also multiplies catalog entries and index counts, which slows planning, backups, restores and every global migration, since each future change must be applied across divergent schemas. At tens of tenants it is manageable; at tens of thousands it is an operational hazard.
- How do you keep custom-field queries from scanning other tenants' data?Every query must carry the tenant predicate and every index that supports custom-field access should lead with tenant_id or be partial on it, so a filter can never widen beyond one tenant. Enforce it structurally rather than by convention - row-level security policies, a repository layer that refuses queries without a tenant scope, or per-tenant partitioning - because a single forgotten predicate is both a performance incident and a data-exposure incident.
saying these in an interview costs you the question
- Choosing a storage shape before designing the field-definition catalog
- Assuming every custom field needs an index, when most are only displayed on a detail page
- Proposing runtime DDL per customer action without addressing lock, migration and catalog-bloat consequences
- Launching with no cap on field count or document size and planning to add limits later
- Letting core product attributes be created as custom fields because that path avoids a migration