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
Entities, keys, and identity
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);Key idea
Normalization vs denormalization
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.Common mistake
Always normalize to 5NF; denormalization is sloppy engineering.
Cardinality patterns you must draw
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
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
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 = :currentIndexing for the access path
Common mistake
Foreign keys are optional because the app validates everything.
Worked mini-prompt: ride receipts
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
- 01"How start schema design?" → actors/use cases → entities → cardinality → keys/constraints → indexes → evolve.
- 02"UUID vs bigserial?" → UUID distributed/id opaque; bigserial locality/smaller indexes; defend pick.
- 03"Why snapshot line price?" → history correctness when catalog prices change.
- 04"Soft delete tradeoff?" → restore/audit vs unique/filter complexity; partial indexes.
- 05"N:M how?" → bridge table with attributes; PK of pair or surrogate.
- 06"Money type?" → integer cents or DECIMAL; never float.
- 07"Multi-tenant default?" → pool + tenant_id leading indexes; silo if strict isolation.
- 08"When denorm counter?" → hot read of counts; maintain via txn/trigger/queue; accept lag if said.
- 09"FK across services?" → no cross-db FK; boundaries + async integrity checks.
- 10"Migration safety?" → expand/contract, dual write if needed, never break old readers casually.
Checkpoint
Product price can change; order history must show what the customer paid. Modeling?
Checkpoint
SaaS B2B tickets table, shared DB multi-tenant. Index priority for 'list my tenant's recent tickets'?
Checkpoint
Why avoid FLOAT for currency amounts?
Checkpoint
Soft-deleted users must free their email for re-registration while keeping old rows. How?
Checkpoint
Students and courses are N:M with enrollment date and grade. Correct structure?
Can you drive a 20-minute schema design: entities, keys, N:M bridge, indexes, and one denorm with a why?
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.