skip to content

questions

28

A table stores contact details as columns named phone1, phone2 and phone3. What problems does that layout cause, and what would you replace it with?

level: juniorimportance: must knowfreq 60%

answer

  1. Names differ only by a number = slot pattern
  2. OR across columns, one index per slot
  3. Cannot constrain the set: unique, at-least-one, one-primary
  4. Fix: child table + FK + partial unique index
  5. Fixed pair like latitude/longitude is not this

basics

~20 s

Numbered columns hard-code a limit, force every query to repeat itself across all three columns, cannot be indexed or constrained as a group, and need a schema change to add a fourth. Replace them with a child table holding one row per phone, keyed to the parent.

solid answer

~60 s

Repeated numbered columns are a repeating group flattened into a row. The costs: - **Arbitrary cap.** Three is a guess; a fourth number requires DDL plus changes in every query and every piece of code that touches the group. - **Query duplication.** "Find the customer with this number" becomes an OR across three columns; searching all values needs a UNION. None of it uses one index well - you need an index per column. - **No group constraints.** You cannot make the set unique, cannot require at least one, and cannot enforce ordering or labels. - **Sparsity.** Most rows carry nulls, and "is slot 2 empty or is it the second number?" becomes ambiguous when slots are shuffled by updates. - **No per-value attributes.** There is nowhere to put type, verified flag or added-at without inventing `phone2_type` and multiplying the problem. **Fix:** a child table `customer_phone(customer_id, phone, phone_type, is_primary, ...)` with a foreign key, a uniqueness constraint on the values that must be unique, and a partial unique index enforcing at most one primary per customer. Fixed-arity columns that are genuinely fixed and semantically distinct - `latitude`/`longitude`, `first_name`/`last_name` - are not this anti-pattern.

code

sql · 14 lines
sql
CREATE TABLE customer_phone (
  customer_phone_id bigserial PRIMARY KEY,
  customer_id  bigint NOT NULL REFERENCES customer(customer_id) ON DELETE CASCADE,
  phone        varchar(32) NOT NULL,
  phone_type   varchar(16) NOT NULL,
  is_primary   boolean NOT NULL DEFAULT false,
  created_at   timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT uq_customer_phone UNIQUE (customer_id, phone)
);

CREATE UNIQUE INDEX uq_customer_primary_phone
  ON customer_phone (customer_id) WHERE is_primary;

CREATE INDEX ix_customer_phone_lookup ON customer_phone (phone);

go deeper

for a junior

Name the concrete pains - the cap, the OR-heavy queries, the nulls - and propose the child table with a foreign key.

for a middle

Add the constraints the child table makes possible, including at-most-one-primary via a partial unique index, and the indexing argument.

for a senior

Cover the incremental migration path on a live table and the boundary case where fixed named roles are correct.

for a principal

Weigh migration cost against ongoing friction, and set the policy that stops new slots being added while the migration proceeds.

## What the pattern is A repeating group is a set of same-meaning values belonging to one entity. Numbered columns encode it positionally in a single row: `phone1, phone2, phone3`, or `tag1..tag5`, or `child_name_1..child_name_4`. Each slot holds a value of the same kind, and the number is nothing but an index. ## Why it hurts **The cap is arbitrary and wrong.** Whatever number was chosen, the real world will exceed it. Extending means a schema migration plus edits in every query, every mapper, every report and every export - the change ripples through code that had no reason to care. **Every query duplicates itself.** Lookup by value becomes `WHERE phone1 = ? OR phone2 = ? OR phone3 = ?`. Listing all values requires unpivoting with a UNION. Counting how many contacts a customer has means counting non-nulls across columns. Each of these grows with the arity, and a query written when there were three columns silently misses values once a fourth exists. **Indexing degrades.** A single index cannot serve an OR across three independent columns; you need one index per slot, tripling write cost and storage for one logical lookup. A child table needs exactly one index on the value column. **Constraints become impossible.** Common requirements that you simply cannot express: the values must be distinct within a row; at least one must be present; exactly one is the primary; each has a type from a fixed set; the set is ordered. In a child table each of these is a normal constraint or a partial unique index. **Slots carry no identity.** Deleting the middle value leaves a hole, and whether code compacts the slots or leaves the gap is undefined behaviour that different call sites will get wrong. Two rows with the same set of numbers in a different slot order compare unequal. **No room for per-value attributes.** The moment the business wants to know when a number was verified, the layout demands `phone1_verified_at, phone2_verified_at, ...`, and column count grows multiplicatively. **Row width and update cost.** Mostly-null wide rows waste space and, in engines that rewrite the whole row on update, make every write more expensive than it needs to be. ## The replacement ``` customer(customer_id PK, ...) customer_phone( customer_phone_id PK, customer_id FK -> customer, phone, phone_type, is_primary, created_at ) ``` With: - a foreign key so orphan values are impossible, - `UNIQUE (customer_id, phone)` so a number is not stored twice for one customer, - a partial unique index on `(customer_id) WHERE is_primary` so at most one primary exists, - an index on `phone` for reverse lookup, - optionally a check that `phone_type` is from an allowed set, or a foreign key to a lookup table. Queries become single-shape and arity-independent: joining, filtering, aggregating and paging all work with no knowledge of how many values exist. ## When fixed columns are correct The test is whether the positions are **semantically distinct** or merely **indexed**. - `latitude` and `longitude` are distinct concepts that always come as a pair; `coordinate1`/`coordinate2` would be the anti-pattern, `latitude`/`longitude` are not. - `home_phone` and `mobile_phone`, when the business genuinely has exactly those two named roles with different rules, are named roles rather than slots - though this often becomes a slot pattern in disguise once someone adds `mobile_phone_2`. - `billing_address_id` and `shipping_address_id` are two roles, not two slots. The warning signs of a genuine slot pattern are: the names differ only by a number; adding another is conceivable; code loops over them or repeats itself per slot; nulls dominate the higher-numbered ones. ## Migrating an existing table Work incrementally and non-destructively: 1. Create the child table and its constraints. 2. Backfill by unpivoting the non-null slots, preserving slot order as an ordinal if order matters. 3. Dual-write from application code so both representations stay current. 4. Move readers to the child table one at a time, verifying counts match. 5. Once no reader remains, drop the numbered columns. Stop-the-bleeding rule while the migration is in flight: no new code may read or write the numbered columns, and no fourth slot is ever added. ## What interviewers listen for The weak answer says only "it violates first normal form" and stops. The strong answer names the operational consequences - query duplication, per-slot indexes, unenforceable constraints, migration cost on every extension - proposes the concrete child table with its constraints, and knows the boundary case where fixed columns are the right design.

  • When are several same-typed columns on one row the right design rather than this anti-pattern?
    When the positions are semantically distinct roles rather than indexed slots, and the arity is fixed by the domain itself. Latitude and longitude, or billing and shipping address references, are named roles with different meanings and different rules. The tell for the anti-pattern is that names differ only by a number, another one is conceivable, and code repeats itself once per slot.
  • How would you migrate a live table off numbered columns without downtime?
    Create the child table with its constraints, backfill by unpivoting non-null slots while preserving slot order as an ordinal, then dual-write so both shapes stay current. Move readers across one at a time and reconcile counts, and only drop the numbered columns once no reader remains. Throughout, forbid new code from touching the old columns so the problem stops growing.

saying these in an interview costs you the question

  • Answering only 'it breaks first normal form' with no operational consequence
  • Proposing a comma-separated string or array column as the fix without discussing constraints and lookup cost
  • Claiming a single index can serve an OR across three separate columns
  • Adding a fourth numbered column as the solution to running out of slots
  • Treating latitude/longitude or first/last name as the same anti-pattern

context

open as a page

What is the difference between a natural primary key and a surrogate primary key, and how do you decide which to use for a table?

level: juniorimportance: must knowfreq 70%

basics

~20 s

A natural key is business data that already identifies the row (email, ISBN, country code). A surrogate key is a meaningless generated value (a sequence integer or UUID). Default to a surrogate for stability and narrowness, and keep the natural key as a UNIQUE constraint.

open as a page

What does it mean to "soft delete" a row in a relational database instead of issuing a DELETE statement, and what do you trade away by doing it?

level: juniorimportance: must knowfreq 60%

basics

~20 s

Soft delete marks a row as gone — usually by setting a deleted_at timestamp — instead of removing it. The row still physically exists, so it can be restored and still satisfies foreign keys, but every query must now filter it out and the table keeps growing.

open as a page

Your application must be able to answer "who changed this customer record, when, and what did it look like before?". What table design gives you that, and what belongs in each column?

level: juniorimportance: must knowfreq 50%

basics

~20 s

Add a history table that mirrors the live table's columns plus its own id, the changed row's id, changed_at, changed_by and the operation (insert/update/delete). Write one append-only row per change, inside the same transaction as the change.

open as a page

A team stores related rows across tables but declares no foreign key constraints, saying referential integrity is enforced in the application. What goes wrong, and when is skipping foreign keys actually defensible?

level: middleimportance: must knowfreq 60%

basics

~20 s

Application checks are racy and only cover the paths that implement them - backfills, admin tools and other services bypass them - so orphan rows accumulate silently. Foreign keys make the invariant unconditional. Skipping them is defensible mainly across separate datastores or shards, where the constraint is not expressible anyway.

open as a page

What is the Entity-Attribute-Value (EAV) modeling pattern in a relational database, and what does it cost you compared with ordinary typed columns?

level: middleimportance: must knowfreq 55%

basics

~20 s

EAV stores each property as a row (entity id, attribute name, value) instead of as a column. It buys schema flexibility without DDL, but you give up data types, NOT NULL, foreign keys, CHECK constraints and useful optimizer statistics, and every read needs pivoting.

open as a page

A users table has a UNIQUE constraint on email and uses a deleted_at column for soft deletes. Someone deletes their account, then signs up again with the same address, and the insert fails. Why does it fail, and how do you model uniqueness so it only applies to live rows?

level: middleimportance: must knowfreq 52%

basics

~20 s

A UNIQUE constraint covers every row in the table, including soft-deleted ones, so the dead row still occupies the email. Fix it with a partial (filtered) unique index restricted to live rows, or by making the marker part of the key so dead rows differ.

open as a page

How would you model a value that changes over time — say a product's price or an employee's salary — so the system can answer "what was it on 1 March 2024?", and what are the pitfalls in choosing the interval columns?

level: middleimportance: must knowfreq 46%

basics

~20 s

Store one row per period with valid_from and valid_to, treated as a half-open interval [from, to). A change closes the current row and inserts a new one in the same transaction. Point-in-time lookup is valid_from <= t AND valid_to > t.

open as a page

When does storing a document-shaped JSON value in a column beat storing the same data as attribute-value rows, and what do you give up by putting data inside JSON?

level: seniorimportance: must knowfreq 50%

basics

~20 s

A JSON column keeps one row per entity, so no pivoting, and it preserves nesting, arrays and basic scalar types; you can index whole documents or specific extracted paths. You give up foreign keys into the document, declarative per-field constraints, good selectivity estimates, and cheap partial updates.

open as a page

Compare a database sequence or auto-increment integer with a randomly generated UUID (version 4) as the primary key of a large, insert-heavy table. What are the real costs, and where does a time-ordered UUID (version 7) fit?

level: seniorimportance: must knowfreq 58%

basics

~20 s

Sequences give small, ordered keys, so inserts land at the right edge of the index and stay cache-friendly. Random UUIDv4 keys scatter inserts across the whole index, causing random I/O, page splits and a much larger working set, and they are twice the width. UUIDv7 is time-ordered, restoring locality while staying client-generatable.

open as a page

A schema keeps every enumeration - order statuses, country codes, payment types - in one generic table with columns like (id, category, code, label). What is wrong with that consolidation, and what would you do instead?

level: middleimportance: should knowfreq 40%

basics

~20 s

A single shared lookup table cannot restrict a referencing column to one category, so a foreign key to it permits any value from any enumeration. It also forces one column type for all values, makes every join carry a category filter, and leaves nowhere for per-enumeration attributes.

open as a page

When properties are stored one row per property (property name plus value) rather than as columns, how do you return several properties as columns of a single result row, and why does that get expensive as the number of properties and rows grows?

level: middleimportance: should knowfreq 40%

basics

~20 s

You pivot: either join the value table once per property, or group by the entity and use conditional aggregation (MAX of a CASE per property). Cost grows because each property is another join or another pass, row counts multiply, and the planner's estimates for each attribute predicate are unreliable.

open as a page

What goes wrong when the value a table is keyed on changes, and how do you design for identifiers that might be corrected, re-issued, or restructured by an outside party?

level: middleimportance: should knowfreq 42%

basics

~20 s

Changing a key value forces every referencing row to change too, churns every index containing it, and breaks references already stored outside the database - logs, caches, URLs, exports, partner systems. Design so identity never changes: a generated key inside, the business identifier as a mutable UNIQUE attribute, with history and aliases if values get re-issued.

open as a page

In a schema that soft-deletes rows with a deleted_at column, what happens to foreign keys and to declared referential actions such as ON DELETE CASCADE, and how do teams handle the child rows of a soft-deleted parent?

level: middleimportance: should knowfreq 42%

basics

~20 s

Foreign keys only see row existence, so a soft delete — an UPDATE — leaves them untouched: no cascade fires, children still point at a dead parent, and the database will happily let you insert new children referencing it. Cascading and validation become application or trigger logic.

open as a page

You inherit an OLTP table with 140 columns, most of them nullable, written by several different subsystems. What symptoms tell you it should be split, how would you split it, and what does splitting cost?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Look for mutually exclusive column groups, columns that are null for most rows, groups updated by different subsystems at different rates, and hidden one-to-many groups. Split by ownership and lifecycle into one-to-one satellites and extract repeating groups into child tables. The cost is joins and coordinating writes.

open as a page

A comments table stores commentable_type ('article', 'photo', 'video') alongside commentable_id to point at whichever parent the comment belongs to. What does that design cost, and what alternatives enforce the relationship properly?

level: seniorimportance: should knowfreq 45%

basics

~20 s

A type-plus-id reference cannot be a foreign key, because the target table varies per row, so nothing prevents orphans, cascades or type strings that do not match a real table. Fix it with an exclusive-arc of nullable foreign keys plus a check, or a shared parent table every commentable type references.

open as a page

A catalog holds 200 kinds of item, and each kind has mostly different properties. What are the relational alternatives to a generic attribute-value table for that, and how do you choose between them?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Options are: one wide table with many nullable columns; a shared parent table plus one subtype table per kind holding that kind's typed columns; a separate table per kind; a JSON column for the long tail; and attribute-value rows as the last resort. Choose by who defines the properties, how stable the set is, and whether you filter or aggregate across kinds.

open as a page

What are the risks of exposing a table's internal primary key values in public URLs and API responses, and what are the alternatives?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Sequential ids are guessable, so they invite enumeration of your data and leak business volume and growth rates. The fix for access is authorization checks on every request; the fix for guessability is a separate opaque public identifier stored alongside the internal key. Obscured ids are not authorization.

open as a page

A privacy regulation requires you to erase an individual's personal data on request, but your system soft-deletes everything. How do you actually satisfy such an erasure request, and what does a purge process have to cover?

level: seniorimportance: should knowfreq 38%

basics

~20 s

A flag is not erasure — the data is still readable. Satisfy the request by hard-deleting or irreversibly anonymizing the personal fields, keeping only what law requires, and run it as a batched, idempotent job that also reaches replicas, search indexes, exports and analytics copies.

open as a page

Once a table carries a deleted_at column, every read in the system has to exclude those rows. What mechanisms keep that filter from being forgotten, and where do the leaks typically show up?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Do not rely on discipline. Make the default path filtered: rename the physical table and expose a filtered view, use the ORM's global scope, or use row-level security. Leaks show up in aggregates, joins to parent tables, batch jobs, exports, caches and search indexes.

open as a page

Explain the difference between valid time — when a fact was true in the world — and system or transaction time — when the database recorded it. When does a design genuinely need both?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Valid time is when the fact holds in reality; system time is when the database believed it. They diverge on backdated corrections and future-dated changes. You need both when you must reproduce a past report exactly — what we knew then about how things were then.

open as a page

Compare three ways of capturing row-level change history: database triggers, writes issued by the application itself, and log-based change data capture. What breaks with each?

level: seniorimportance: should knowfreq 36%

basics

~20 s

Triggers catch every writer and commit atomically with the change, but cost write throughput and lack application context. Application writes carry rich context but miss anyone bypassing the app. Log-based CDC is complete and free on the write path, but asynchronous and lands the history outside the database.

open as a page

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?

level: principalimportance: should knowfreq 30%

basics

~20 s

Choose 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.

open as a page

The SQL:2011 standard added system-versioned tables and application-time periods. What do those features give you compared with a hand-rolled history table, and where do they fall short?

level: middleimportance: nice to knowfreq 26%

basics

~20 s

System-versioned tables make the engine timestamp every row version and keep old versions automatically, queryable with FOR SYSTEM_TIME AS OF. Application-time periods let you declare a business validity period the engine can keep non-overlapping and split on update. Support is uneven, and neither records who made the change.

open as a page

You take ownership of a live, revenue-carrying OLTP schema that contains several textbook anti-patterns at once - missing foreign keys, a generic shared lookup table and a polymorphic association. How do you decide what to fix, in what order, and how do you change it without downtime?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Rank by risk of silent data corruption, not by ugliness. Stop the bleeding first so new code does not extend the pattern, then fix what is causing measurable incidents, using expand-and-contract: add the new structure, dual-write, backfill, verify, move readers, drop the old last.

open as a page

Rows must be created on several database shards, in more than one region, and by offline clients that assign an identifier before the row ever reaches a database. How would you choose an identifier strategy for that, and what are the trade-offs?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Decide by where ids must be minted, whether ordering matters, and how much width you can afford. Options: random UUID (no coordination, poor locality), time-ordered UUID (locality plus decentralised minting), a timestamp-node-sequence 64-bit id (compact, needs node assignment and clock discipline), or centrally allocated ranges (best locality, needs an allocator).

open as a page

You own a system with several very large tables, an undo requirement from product, and a retention policy from legal. How would you decide between soft delete, hard delete with an archive table, and time-partitioned retention — and what makes each choice go wrong at scale?

level: principalimportance: nice to knowfreq 25%

basics

~20 s

Decide per table, from the requirement: undo needs reversibility (soft delete or archive-and-restore), a small hot table needs the dead rows out (archive), and bounded retention needs partitions you can drop. Soft delete fails at scale through unbounded growth and a predicate on every query.

open as a page

Several tables in a system need change history. How would you decide how much temporal machinery each table gets, and how do you keep the history from becoming the largest and slowest part of the database?

level: principalimportance: nice to knowfreq 22%

basics

~20 s

Scope per table from the actual requirement — compliance, support, or undo — and give each the cheapest mechanism that satisfies it. Keep history off the hot path: separate append-only tables, partitioned by time, minimally indexed, with a retention policy and an archival tier.

open as a page