Lesson 6 of 8 · 50 min

OLTP schema design interviews

Keys, normalization vs denorm, N:M bridges, soft delete, multi-tenant pool indexes, money types, and a worked trips schema.

Lesson 6 · Schema design

OLTP modeling tradeoffs interviewers actually score

Schema rounds are product design in table form

You are not graded on drawing every FK arrow. You are graded on entities, keys, cardinality, integrity, evolution, and query patterns that must stay fast. This lesson covers normalization vs pragmatic denormalization, ID strategies, soft delete, multi-tenant patterns, and how to narrate a 20-minute schema design.
Open with use cases and access patterns: write path, hottest reads, consistency needs, scale. Then entities → relationships → keys → constraints → indexes → evolution. Same OS as system design, but the artifact is a schema + example queries.

Entities, keys, and identity

Primary keys: surrogate (bigserial/uuid) vs natural. Surrogates isolate from business change; natural keys encode meaning but churn. UUIDs: great for distributed writes, worse locality than sequential ints - know the tradeoff. Composite keys: OK for pure join tables if stable.
sql
1CREATE TABLE users (2  user_id        BIGSERIAL PRIMARY KEY,3  email          CITEXT NOT NULL UNIQUE,4  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),5  deleted_at     TIMESTAMPTZ6);78CREATE TABLE orders (9  order_id       BIGSERIAL PRIMARY KEY,10  user_id        BIGINT NOT NULL REFERENCES users(user_id),11  status         TEXT NOT NULL CHECK (status IN ('pending','paid','cancelled','refunded')),12  currency       CHAR(3) NOT NULL,13  amount_cents   BIGINT NOT NULL CHECK (amount_cents >= 0),14  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()15);16CREATE INDEX orders_user_created_idx ON orders (user_id, created_at DESC);

Normalization vs denormalization

3NF reduces update anomalies: one fact in one place. Denormalize when a read path is hot and the team accepts sync cost (cached counters, materializing user_email on events). Interview move: start normalized, then denormalize one field with a reason and invalidation story.
sql
1-- Normalized: order_items reference products2CREATE TABLE order_items (3  order_id     BIGINT NOT NULL REFERENCES orders(order_id),4  product_id   BIGINT NOT NULL REFERENCES products(product_id),5  qty          INT NOT NULL CHECK (qty > 0),6  -- snapshot price at purchase time (intentional denorm)7  unit_price_cents BIGINT NOT NULL,8  PRIMARY KEY (order_id, product_id)9);10-- Why snapshot price? Product price changes must not rewrite history.

Cardinality patterns you must draw

1:1 (user↔profile), 1:N (user→orders), N:M (student↔course via enrollments). For N:M always introduce a bridge table with its own attributes (enrolled_at, role). Do not fake N:M with CSV arrays unless you defend query/index limits.
sql
1CREATE TABLE courses (2  course_id BIGSERIAL PRIMARY KEY,3  slug TEXT NOT NULL UNIQUE4);5CREATE TABLE enrollments (6  user_id BIGINT NOT NULL REFERENCES users(user_id),7  course_id BIGINT NOT NULL REFERENCES courses(course_id),8  enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(),9  status TEXT NOT NULL,10  PRIMARY KEY (user_id, course_id)11);12CREATE INDEX enrollments_course_idx ON enrollments (course_id, enrolled_at);

Soft delete, audit, and history

Soft delete (deleted_at) preserves FK history but complicates UNIQUE (need partial unique indexes on active rows) and every query filter. Alternatives: hard delete + audit log table; temporal/history tables for SCD-ish OLTP. Pick based on compliance and restore needs.
sql
1-- Partial unique: email unique among live users2CREATE UNIQUE INDEX users_email_live_uidx3  ON users (email)4  WHERE deleted_at IS NULL;56-- Audit log sketch7CREATE TABLE audit_log (8  id BIGSERIAL PRIMARY KEY,9  actor_id BIGINT,10  entity_type TEXT NOT NULL,11  entity_id BIGINT NOT NULL,12  action TEXT NOT NULL,13  at TIMESTAMPTZ NOT NULL DEFAULT now(),14  payload JSONB15);

Multi-tenancy sketches

Shared tables + tenant_id (pool): cost efficient; need ruthless row-level filters and indexes leading with tenant_id. Schema-per-tenant / DB-per-tenant (silo): isolation, heavier ops. Interview: pick pool for most SaaS, mention noisy neighbor and RLS options.
sql
1CREATE TABLE tickets (2  ticket_id BIGSERIAL PRIMARY KEY,3  tenant_id BIGINT NOT NULL REFERENCES tenants(tenant_id),4  title TEXT NOT NULL,5  status TEXT NOT NULL,6  created_at TIMESTAMPTZ NOT NULL DEFAULT now()7);8CREATE INDEX tickets_tenant_created_idx9  ON tickets (tenant_id, created_at DESC);10-- Every query: WHERE tenant_id = :current

Indexing for the access path

Indexes are part of the schema answer. Propose indexes that match real WHERE/JOIN/ORDER BY. Avoid indexing every column. Mention write amplification and unused index cost. Partial indexes for hot subsets (status='open') are a senior flourish when justified.

Worked mini-prompt: ride receipts

sql
1-- Prompt: riders, drivers, trips, payments, ratings23riders(rider_id PK, ...)4drivers(driver_id PK, ...)5trips(6  trip_id PK,7  rider_id FK, driver_id FK,8  status, requested_at, completed_at,9  pickup_cell, dropoff_cell,10  price_cents, currency11)12payments(13  payment_id PK, trip_id FK UNIQUE,  -- 1:1 with trip for MVP14  provider_ref UNIQUE,15  status, amount_cents16)17ratings(18  trip_id FK, rater_role, score,19  PRIMARY KEY (trip_id, rater_role)  -- rider rates driver & vice versa20)21-- Hot queries: trips by rider recent; driver earnings by day;22-- open trips by geo cell. Index accordingly.

Schema interview OS

  1. 01"How start schema design?" → actors/use cases → entities → cardinality → keys/constraints → indexes → evolve.
  2. 02"UUID vs bigserial?" → UUID distributed/id opaque; bigserial locality/smaller indexes; defend pick.
  3. 03"Why snapshot line price?" → history correctness when catalog prices change.
  4. 04"Soft delete tradeoff?" → restore/audit vs unique/filter complexity; partial indexes.
  5. 05"N:M how?" → bridge table with attributes; PK of pair or surrogate.
  6. 06"Money type?" → integer cents or DECIMAL; never float.
  7. 07"Multi-tenant default?" → pool + tenant_id leading indexes; silo if strict isolation.
  8. 08"When denorm counter?" → hot read of counts; maintain via txn/trigger/queue; accept lag if said.
  9. 09"FK across services?" → no cross-db FK; boundaries + async integrity checks.
  10. 10"Migration safety?" → expand/contract, dual write if needed, never break old readers casually.
docsPostgreSQL constraintsPostgreSQLdocsPostgreSQL indexesPostgreSQLarticleDatabase Migrations (expand/contract)Prisma data guidedocsAWS multi-tenant SaaS storage patternsAWS
Interview habit: before you write a join or window, say the grain of the result set out loud (one row per what?), name the filters that apply before aggregation, and sketch a 4-row toy table. That 30-second ritual catches double-counting and null traps faster than staring at syntax.
When stuck live, reduce to two tables and three rows on the whiteboard. Prove the join, then reintroduce filters and aggregates. Interviewers credit recovery more than a first-draft perfect query that you cannot explain.
State timezone and late-arriving data assumptions once. 'UTC calendar day of paid_at; events may land +2h' prevents half the metric arguments in marketplace and consumer analytics rounds.
Prefer COUNT(DISTINCT key) or EXISTS for existence metrics; prefer SUM of a measure that already lives at the target grain. If you must join a child table, aggregate the child in a CTE first and say why.
Read the English noun in the question: users, orders, sessions, days. That noun is usually the GROUP BY or partition key. If your SELECT invents a different noun, you probably changed the grain by accident.
Null policy belongs in the spoken answer: 'missing country is excluded from geo breakouts but included in totals' is a product decision. Silent COALESCE to 'unknown' without saying so is how dashboards disagree.

Checkpoint

Product price can change; order history must show what the customer paid. Modeling?

Aorder_items.product_id only; always join products.price for displayBStore unit_price_cents (and currency) on order_items at purchase time; product_id for referenceCUpdate all historical order_items via trigger when product price changes
Sign up free to answer and see why

Checkpoint

SaaS B2B tickets table, shared DB multi-tenant. Index priority for 'list my tenant's recent tickets'?

AINDEX (created_at) alone globallyBINDEX (tenant_id, created_at DESC) matching the filter+sortCINDEX (title) for full text only
Sign up free to answer and see why

Checkpoint

Why avoid FLOAT for currency amounts?

AFloat is slower than DECIMAL on modern CPUsBBinary floating point cannot represent many decimal fractions exactly; money needs exact cents/DECIMALCSQL standard forbids FLOAT columns in tables with FKs
Sign up free to answer and see why

Checkpoint

Soft-deleted users must free their email for re-registration while keeping old rows. How?

AGlobal UNIQUE(email) on users foreverBPartial unique index on email WHERE deleted_at IS NULL (or equivalent live flag)CRemove email from deleted rows by setting email=NULL always
Sign up free to answer and see why

Checkpoint

Students and courses are N:M with enrollment date and grade. Correct structure?

Astudents.course_ids as array of intsBenrollments bridge: (student_id, course_id) PK plus enrolled_at, grade, ...CDuplicate course rows per student inside courses table
Sign up free to answer and see why

Can you drive a 20-minute schema design: entities, keys, N:M bridge, indexes, and one denorm with a why?

New to itGetting thereConfident

Takeaways

  • Access patterns first, tables second.
  • Snapshot historical facts (prices) deliberately.
  • N:M needs a bridge with attributes.
  • Money: cents/DECIMAL, never float.
  • Indexes follow WHERE/JOIN/ORDER; tenant_id leads in pools.

Next: warehouse modeling - facts, dims, SCD, and event analytics patterns.

Sources

Free to read · better with Enzo

Learn it with Enzo

Save your progress, answer the checkpoints, and let Enzo quiz you on what you just read.