Hiring BarSupport

Data Modeling & Schema Design

Foundation31 min read4 diagrams

Why This Matters#

Data modeling is not a question about drawing entity boxes. It is a question about which queries you are promising to make cheap, forever. Code can be rewritten in a sprint. A schema holding 4 TB of production data, read by 11 services and a warehouse pipeline, cannot. The data model is the longest-lived artifact in almost every system — it routinely outlives the service, the framework, the language, and the team that wrote it.

Most candidates model the domain: User, Order, Product, foreign keys between them, third normal form. That is a correct answer to "what are the nouns?" and an incomplete answer to "what will this system do all day?" Staff candidates start from the access patterns — the top 5 queries by volume and the top 3 by business criticality — and derive the tables, keys, and denormalizations that make those queries a single seek. The domain model tells you what is true; the access patterns tell you how to store it.

The Principal layer adds ownership. Every table has exactly one team that may write it, a set of consumers who read it, and a contract about how it evolves. The most expensive data-modeling failures in large organizations are not bad schemas — they are shared schemas: a users table written by four services, read by twenty, where no one can add a column without a cross-team meeting and no one can remove one at all.

The 60-Second Version#

  • Access patterns first, entities second. List the top queries with their QPS, latency budget, and consistency need before drawing a table. A model that makes the 50K QPS query a scan is wrong no matter how normalized it is.
  • Normalize for write correctness; denormalize for read cost — and name who maintains the copy. Every denormalized field is a second source of truth with an update path that can fail.
  • In relational stores, start normalized and denormalize with evidence. In key-value/wide-column stores (DynamoDB, Cassandra), model one table per query shape, because joins don't exist.
  • Schema evolution is expand → migrate → contract. Add the new shape, dual-write, backfill, switch reads, stop writing the old, remove it. Each step is independently deployable and reversible — except the last.
  • Never make a breaking schema change in one deploy. At any moment during a rollout, old and new code run side by side for minutes to hours; the schema must support both.
  • One writer per table. Shared write access to a table is a distributed system with no protocol. Other teams get an API or an event stream.
  • Backfills are production traffic. A 2B-row backfill at an unthrottled rate is indistinguishable from a DDoS on your own primary. Budget ~10–20% of write headroom and plan days, not minutes.

How Data Modeling Works#

The Basic Idea#

A data model makes four decisions:

  1. Entities and relationships — what exists and how it relates (the domain).
  2. Keys — how each record is identified and, in distributed stores, where it lives (the partition key).
  3. Shape — which attributes live together in one row/document, and which are split or duplicated (normalization vs denormalization).
  4. Evolution rules — how the schema changes without breaking readers and writers that are deployed at different times.

L5 candidates spend their time on (1). Production outages come from (2) and (4). Cost comes from (3).

Key Terms#

TermMeaningWhy It Matters
Access patternA concrete query with its filter, sort, cardinality, QPS, and latency budgetThe unit you design against
NormalizationEach fact stored once; relationships by referenceWrites update one place; reads may need joins
DenormalizationFacts copied where they're readReads are single-seek; writes fan out and can diverge
AggregateA cluster of entities that change together under one invariant (DDD)The natural transaction and partition boundary
Natural vs surrogate keyBusiness identifier (email, SKU) vs generated IDNatural keys change ("users can change email") — use surrogates for identity
Schema-on-write vs schema-on-readEnforced at insert vs interpreted at query timePostgres enforces; a JSON blob in S3 defers the pain to every reader
Expand/contractMulti-step migration that keeps old and new shapes valid simultaneouslyThe only safe way to change a live schema
System of recordThe one store whose value wins on conflictEverything else is a derived view with a rebuild path

Where Models Live#

Store TypeModeling StyleJoin SupportSchema EnforcementTypical Mistake
Relational (Postgres, MySQL)Normalized tables, FK constraintsFullStrongOver-normalizing a 50K QPS read path into a 6-way join
Document (MongoDB)Embedded sub-documents per aggregateLimited ($lookup)Optional validatorsUnbounded embedded arrays (a post with 2M comments embedded)
Key-value / wide-column (DynamoDB, Cassandra)One table (or item collection) per queryNonePer-item attributes onlyModeling it like Postgres and then scanning
Event log (Kafka + schema registry)Immutable facts, versioned schemasStream joinsRegistry compatibility rulesTreating events as mutable rows; breaking consumers with field removals
Warehouse (BigQuery, Snowflake)Star schema / wide denormalized tablesFull, columnarStrong, but evolves via pipelinesCopying OLTP normalization into analytics

🎯 Staff Insight: "I don't start with the entities. I start with the five queries that pay the bills — their QPS, latency budget, and whether they can be stale. The entities fall out of that; the keys and duplications are what I'm actually designing."

Core Strategies#

Strategy 1: Access-Pattern-First Design#

Write the query list before the schema.

# Access patterns for a marketplace orders service
AP1  Get order by id                         30K QPS  p99 < 10ms   strong
AP2  List a buyer's orders, newest first     8K QPS   p99 < 50ms   read-your-writes
AP3  List a seller's open orders by due date 2K QPS   p99 < 100ms  stale 5s OK
AP4  Daily revenue per seller                batch    hours        warehouse
AP5  Find orders by payment_intent_id        200 QPS  p99 < 50ms   strong (webhooks)

# Derived decisions
orders           PK order_id                         serves AP1
index            (buyer_id, created_at DESC)         serves AP2
seller_queue     (seller_id, status, due_at) partial serves AP3  -- or a projection table
payment_ref      UNIQUE (payment_intent_id)          serves AP5, enforces 1:1
AP4              CDC → warehouse                     not on the OLTP primary

When to use: always. Failure mode: designing only for today's patterns. Mitigate by asking "which access pattern is most likely to appear in the next 12 months?" — and making sure the partition key doesn't forbid it.

Strategy 2: Normalized Relational Model#

Store each fact once. Use foreign keys and transactions to enforce invariants.

users(id PK, email UNIQUE, created_at)
orders(id PK, buyer_id FK→users, status, total_cents, currency, created_at)
order_items(order_id FK→orders, line_no, sku FK→products, qty, unit_price_cents,
            PRIMARY KEY (order_id, line_no))
products(sku PK, name, current_price_cents)

Note unit_price_cents on order_items: it looks like denormalization but isn't — it's a historical fact (the price at purchase time), a different fact from current_price_cents. Confusing "copy of current value" with "snapshot of a past value" is one of the most common modeling bugs.

When to use: write correctness matters, access patterns are varied or unknown, and the dataset fits a single primary (up to single-digit TB and ~10–50K writes/s on modern hardware). Failure mode: read paths that need 5+ joins at high QPS; the fix is a targeted projection, not abandoning normalization.

Strategy 3: Denormalized / Query-Shaped Model#

Duplicate data so each query reads one partition.

# DynamoDB single-table style: item collection per buyer
PK = BUYER#b42    SK = PROFILE                  → name, tier
PK = BUYER#b42    SK = ORDER#2026-09-28#o991    → status, total, item_count, first_item_name
PK = ORDER#o991   SK = META                     → buyer_id, status, total, ...
PK = ORDER#o991   SK = ITEM#001                 → sku, qty, price

# AP2 = Query(PK='BUYER#b42', SK begins_with 'ORDER#', ScanIndexForward=false, Limit=20)
# status now lives in two items → every status change writes both (TransactWriteItems)

When to use: high-QPS reads with known shapes, stores without joins, and cross-region or cross-shard reads where a join would be a network fan-out. Failure mode: divergent copies. Status updated on ORDER#o991 but the write to the buyer's copy failed or was reordered. You need a transaction, a CDC-driven projector with idempotent upserts, or a reconciliation job — and someone who owns the drift metric.

The Core Tradeoff#

StrategyWhat WorksWhat BreaksWho Pays
NormalizedOne place to update; constraints enforce invariants; ad-hoc queries possibleJoin cost at high QPS; hard to shard across join boundariesRead path latency; later, the team that has to shard it
DenormalizedSingle-partition reads; predictable latency at any scaleUpdate fan-out; drift between copies; schema changes touch N shapesWrite path complexity; on-call debugging "why does the list show SHIPPED but the detail show PENDING?"
Hybrid: normalized SoR + derived projectionsCorrectness in one place, speed in the projections; projections rebuildableProjection lag; a pipeline to ownPlatform team owning CDC; product accepting seconds of staleness in lists

🎯 Staff Move: "Orders are normalized in Postgres as the system of record. The buyer order list is a projection maintained from CDC, keyed by (buyer_id, created_at), with a lag SLO of 2 seconds. For the buyer who just placed an order, the client inserts it optimistically, so read-your-writes holds without making the projection synchronous."

Schema Evolution and Migrations#

This is the hard sub-problem. Designing a schema takes an afternoon. Changing one that has 3 TB of data, 40K QPS, and six readers — without downtime — takes a quarter if you haven't planned for it.

Why It's Hard: Old and New Code Coexist#

During a rolling deploy, some instances run version N and some run N+1 — for minutes in a typical deploy, for hours during a canary, for months on mobile clients and for years in event consumers replaying history. The schema must be valid for every version still running.

ChangeSafe in One Step?Why
Add nullable columnYes (usually metadata-only in Postgres 11+ and MySQL 8 instant DDL)Old code ignores it
Add column with a defaultYes in modern Postgres (stored as metadata); older engines rewrite the tableRewrite = table lock for hours on large tables
Add NOT NULL columnNoOld code's inserts fail
Rename columnNoOld code reads/writes the old name
Drop columnNoOld code still reads it; ORMs with SELECT * mappings may crash
Change column type (int → bigint)NoFull rewrite; old code may truncate
Split one table into twoNoEvery reader and writer changes
Add a foreign key / CHECKValidate in two steps (NOT VALID then VALIDATE)Full-table validation scan holds locks otherwise

The Expand → Migrate → Contract Pattern#

# Goal: rename users.name → users.display_name (or split into first/last, or move to a new table)

Phase 1  EXPAND     ALTER TABLE users ADD COLUMN display_name text NULL;          -- deploy DDL alone
Phase 2  DUAL-WRITE deploy code writing BOTH name and display_name; reads still use name
Phase 3  BACKFILL   UPDATE users SET display_name = name WHERE id BETWEEN $a AND $b
                    AND display_name IS NULL;                                        -- batches of ~1-10K, throttled
Phase 4  VERIFY     SELECT count(*) WHERE display_name IS DISTINCT FROM name;       -- must be 0; check continuously
Phase 5  SWITCH     deploy code reading display_name (behind a flag; canary 1% → 100%)
Phase 6  STOP-WRITE deploy code that no longer writes name
Phase 7  CONTRACT   ALTER TABLE users DROP COLUMN name;                             -- the only one-way door

Rules:

  • Each phase is its own deploy; every phase before 7 can be rolled back by redeploying the previous version.
  • Wait out the longest-lived reader between 6 and 7: batch jobs, replicas feeding the warehouse, cached ORMs, and any consumer of CDC events that includes the column.
  • Backfill throttling: watch replication lag and p99 of the primary; pause when lag > ~2–5s. 500M rows at 5K rows/s is ~28 hours — that's normal. A backfill that "finished in 10 minutes" usually took the primary down with it.
  • Idempotent backfill: use WHERE new_col IS NULL or compare-and-set so a restarted batch doesn't clobber rows the dual-write already updated.

Evolving Schemas You Don't Control the Readers Of#

Events and APIs are schemas with unknown readers. Use a schema registry with compatibility modes:

ModeRuleSafe ChangesUse For
BackwardNew readers can read old dataAdd optional fields, remove fieldsConsumers upgrade first (common Kafka default)
ForwardOld readers can read new dataAdd fields, remove optional fieldsProducers upgrade first
FullBothAdd/remove optional fields onlyLong-lived topics with many consumers
NoneAnything goes—Never, in production

🎯 Staff Move: "The rename is seven deploys over two weeks, not one migration file. I'd schedule the contract step only after the warehouse pipeline and the two CDC consumers have confirmed they've moved — and I'd happily leave the old column for a quarter, because a dead nullable column costs nothing and a premature drop costs an incident."

Ownership of Data#

A schema is also an organizational contract.

PatternWhat It Looks LikeWhat BreaksWho Pays
Shared database, shared tables5 services read and write orders directlyNobody can change a column; invariants enforced in 5 codebases; lock contention from unknown writersEvery team; migrations need a committee
Shared database, owned tablesEach service owns its tables; others read via views/replicasReaders still couple to physical columns; "read-only" access quietly becomes writesOwning team, when a reader breaks on a column change
Database per service + APIs/eventsOwning service exposes an API and publishes change eventsCross-service queries need composition or projections; eventual consistencyConsumers build projections; platform owns the event bus

The Staff default: one writing service per table, full stop. Readers outside the team consume an API (for request-time reads) or a change event stream / CDC (for projections and analytics), both versioned as contracts. Direct replica reads by other teams are allowed only with a documented, versioned view as the interface — never the base table.

# Ownership record in the schema catalog
table:          orders
owner:          team-checkout            # the only writer
on_call:        checkout-primary
contracts:
  api:          orders.v1 (gRPC)
  events:       orders.order_changed.v3 (Avro, FULL compatibility)
  views:        analytics.orders_v2 (read-only, warehouse)
pii:            buyer_email (tokenized), shipping_address (restricted)
retention:      7 years (financial record)

Visual Guide#

From Access Patterns to Schema#

Diagram: From Access Patterns to Schema

Expand / Migrate / Contract Timeline#

Diagram: Expand / Migrate / Contract Timeline

Ownership Topology#

Diagram: Ownership Topology

Implementation Patterns#

Bounded vs Unbounded Relationships#

The single most useful modeling question: "How many can there be?"

RelationshipCardinalityModel
Order → line itemsBounded (~1–100)Embed or co-locate in the same partition; read together
User → addressesBounded (~1–10)Embed / JSONB array is fine
Post → commentsUnbounded (0 – millions)Separate table/partition keyed by (post_id, created_at); paginate
User → followersUnbounded, skewed (celebrity: 100M+)Separate edge table; special-case hot keys
Device → metricsUnbounded over timeTime-bucketed partitions (device_id, day)

Embedding an unbounded collection is how you get 16 MB MongoDB document limits, 100 MB Cassandra partitions, and DynamoDB item-size errors (400 KB) — in production, for your biggest customer first.

Soft Deletes, History, and Temporal Data#

deleted_at columns look cheap and cost you forever: every query needs WHERE deleted_at IS NULL, unique constraints need partial indexes, and "deleted" PII is still stored (a GDPR problem). Prefer: hard delete + an append-only audit/event log for history; or move deleted rows to an archive table. For data that changes over time and must be queried "as of" (prices, entitlements, contract terms), model it explicitly: (entity_id, valid_from, valid_to) rows, never overwrite.

Money, Time, and Identifiers#

  • Money: integer minor units (total_cents bigint) + ISO currency code. Never floats.
  • Time: store UTC timestamptz; store the user's time zone separately when business logic needs local time (billing cycles, "deliver by 9am").
  • IDs: surrogate, opaque, time-ordered (UUIDv7/ULID/snowflake) for B-tree locality; never expose auto-increment externally; never use email or phone as a primary key.
  • Enums: store as text or small lookup tables; adding a value must not require a table rewrite, and consumers must tolerate unknown values.

Polymorphism Without Pain#

Three ways to store "a payment method is a card, bank account, or wallet":

ApproachShapeGood WhenPain
Single table + nullable columnspayment_methods(type, card_last4, iban, wallet_id, ...)≤ 3 types, few type-specific fieldsSparse columns, weak constraints
Table per type + shared parentpayment_methods(id, type) + cards(pm_id, ...)Strong per-type constraintsJoins for every read
Common columns + JSONB detailspayment_methods(id, type, details jsonb)Many types, evolving fieldsValidation moves to app; indexing needs expression/GIN indexes

Staff default: common columns + a validated JSONB details column, with a JSON schema per type version in code.

Multi-Tenancy in the Schema#

Put tenant_id as the leading column of every primary key and every index on tenant-scoped tables. It enables row-level security, tenant-scoped deletes, tenant-by-tenant migration to a dedicated database later, and sharding by tenant. Retrofitting tenant_id onto 200 tables is a multi-quarter project; adding it on day one costs one column.

Backfill Harness#

backfill(table, batch_size=5000, max_lag_s=3):
    cursor = load_checkpoint() or MIN_ID
    while cursor < MAX_ID:
        if replica_lag() > max_lag_s or primary_p99_ms() > 50:
            sleep(5); continue                             # adaptive throttle
        rows = update_batch(table, cursor, cursor + batch_size)   # idempotent: WHERE new_col IS NULL
        save_checkpoint(cursor + batch_size)                # resumable after crash/deploy
        emit("backfill.rows_per_sec", rows)
        cursor += batch_size

The Numbers in Context#

NumberValueWhat It Means for Your Design
Single Postgres/MySQL primary~1–10 TB, ~10–50K writes/sMost products never need to leave a normalized relational model
Join cost~0.1–1 ms per indexed join step in-memoryA 6-way join at 20K QPS is a CPU budget question, not a correctness one
DynamoDB item limit400 KBEmbedded lists must be bounded
MongoDB document limit16 MBUnbounded arrays hit it — usually for the largest customer
Cassandra partition guidanceKeep under ~100 MB and ~100K rowsTime-bucket unbounded partitions
Backfill rate~1–10K rows/s sustained on a busy primary1B rows ≈ 1–12 days; plan it like a launch
Replica lag budget during backfillPause at ~2–5 sBeyond this, read-your-writes breaks for users on replicas
Rolling deploy overlapMinutes to hours (canaries)Schema must support N and N+1 simultaneously
Mobile client overlap6–18 monthsClient-visible data shapes evolve additively only
Event retention/replay horizon7 days to foreverEvent schemas must remain readable for as long as they're replayable
Projection lag SLOTypically 1–5 s p99Sets the staleness users see in lists fed by CDC
Denormalized write fan-out1 + number of copiesEach copy is a potential drift incident; 3+ copies need a reconciliation job

How This Shows Up in Interviews#

Scenario 1: "Design the data model for X"#

Don't start drawing tables. "Let me list the access patterns first." Five lines with QPS, latency, and freshness. Then the aggregate boundaries ("an order and its line items change together; the product catalog does not"), the partition key if the data will be distributed, and one explicit denormalization with its maintenance path. Close with ownership: "The orders service is the only writer; everything else reads via API or CDC."

Scenario 2: "We need to split the users table — it's 300 columns and 8 teams write to it" (Full Walkthrough)#

Step 1 — Find the seams. "First I'd map which columns each team writes and reads — from query logs, not from asking. Usually it clusters: identity and auth, profile, billing, notification preferences, and a long tail of feature flags. Those clusters are the future tables, and the teams that write them are the future owners."

Step 2 — Establish ownership before moving bytes. "Each cluster gets an owning team and an API. In phase one, other teams stop writing columns they don't own — writes go through the owner's API, even though the data hasn't moved. That's the org change; the storage change is easier after it."

Step 3 — Expand/contract, one cluster at a time. "Start with the lowest-risk cluster — notification preferences. Create notification_prefs(user_id PK, ...), dual-write from the owning service, backfill at ~5K rows/s with lag-based throttling, verify with a continuous diff, switch reads behind a flag, stop writing the old columns, and only drop them after the warehouse and CDC consumers have migrated. Each cluster is 4–8 weeks."

Step 4 — Keep the join path cheap. "All new tables keep user_id as the leading key, so if we shard by user later, they co-locate. Hot read paths that need profile + prefs together get a cached composite from the profile service, not a cross-service join."

Step 5 — Metrics and owner. "migration.diff_rows must stay at 0 for a week before switching reads; db.replication_lag_seconds gates the backfill; users_table.unowned_writes counts writes from services outside the owner list and must reach 0. The identity team owns the program; each cluster's new owner owns its migration."

Why this is a Staff answer: it treats the problem as ownership first, uses log evidence to find seams, sequences migrations by risk, and defines exit criteria with metrics.

Scenario 3: "The order list and order detail show different statuses"#

Two copies of status diverged — a denormalized list item and the order record, updated in separate writes. Fix: make the list a projection from CDC with idempotent, version-guarded upserts (UPDATE ... WHERE version < $new), so reordering can't regress it; add a reconciliation job comparing samples and a projection.drift_count metric. Or, if the store allows, write both in one transaction. Name the owner of the projection.

Scenario 4: "Model this for DynamoDB"#

Access patterns first, then keys: partition key for the entity or aggregate that is queried together, sort key to encode hierarchy and time (ORDER#2026-09-28#id), GSIs only for patterns you've listed — and state that GSIs are eventually consistent. Call out hot partitions (a celebrity seller) and the 400 KB item limit. See DynamoDB.

What Interviewers Probe#

After You Say...They Will Ask...(What They're Evaluating)
"I'll denormalize the seller name onto orders""The seller renames their shop. What happens to 40M orders?"Snapshot vs copy semantics; update fan-out cost
"Embed comments in the post document""A post goes viral with 3M comments."Bounded vs unbounded reasoning
"We'll add a migration to rename it""Old pods are still running for 20 minutes. What breaks?"Expand/contract and N/N+1 compatibility
"Other services can read our tables""The analytics team's query just locked your table at peak."Ownership and contract boundaries
"Use event sourcing""Who rebuilds state when the fold logic has a bug, and how long does it take?"Whether you've priced the operational cost
"The projection is eventually consistent""The user just placed an order and it's missing from their list."Read-your-writes strategies on derived views

When NOT to Denormalize#

  • The write rate on the source is high and the copy count is large. A seller name copied onto 40M orders turns one rename into 40M writes — store a reference, or treat the name at order time as a snapshot and say so.
  • The read QPS doesn't justify it. A join at 200 QPS is free; a projection pipeline is not.
  • Correctness requires a single value. Balances, inventory counts, and entitlements have one source of truth; readers that need them fresh read the SoR.
  • No one will own the maintenance path. An unowned projection will drift, and nobody will notice until a customer does.

Advanced Patterns#

PatternHow It WorksWhen to Use
CQRS projectionsWrites to a normalized SoR; reads from projections built from change eventsHigh-QPS read shapes that differ from the write model
Event sourcingThe log of events is the SoR; state is a fold over itAudit-critical domains (ledgers); high operational cost — rarely the default
Single-table designMultiple entity types in one DynamoDB table via composite keysKnown, stable access patterns; minimal round trips
Time-bucketed partitionsPartition key includes a time bucketUnbounded append data (messages, metrics)
Outbox patternWrite the event row in the same transaction as the state change; relay publishesReliable event publication without dual-write races
Schema registryCentral compatibility enforcement for eventsAny topic with more than one consumer team
Online schema change toolsgh-ost, pt-online-schema-change, pg_repackLarge-table DDL without long locks
Tenant-leading keystenant_id first in every keyMulti-tenant SaaS; future per-tenant isolation

Failure Modes & Operational Reality#

FailureDetection SignalBlast RadiusMitigationOwner
Blocking DDL on a large tabledb.lock_wait_seconds ↑; write errors during migrationEvery writer to the tableOnline DDL, lock_timeout (e.g., 2–5s) on migrations, retry off-peakMigrating team + DB platform
Unthrottled backfilldb.replication_lag_seconds > 30s; primary p99 ↑All reads from replicas; possibly the primaryAdaptive throttle, checkpoints, pause switchMigrating team
Premature column dropErrors like column does not exist from a batch job or old deployUnknown readers — often analytics or a rollbackWait for longest reader; rename to _deprecated first; query logs for accessOwning team
Denormalized copy driftprojection.drift_count > 0 from reconciliationUser-visible inconsistencyVersion-guarded idempotent upserts; transactional outbox; reconcileProjection owner
Unbounded partition/documentpartition_size_bytes p99 ↑; item-size errorsLargest tenants firstBucket by time; split collectionsOwning team
Breaking event schemaConsumer deserialization errors; DLQ growthEvery consumer of the topicRegistry compatibility FULL; CI checksProducing team

Failure Scenario: The Enum That Broke Billing#

t=0       Orders team adds status 'PARTIALLY_REFUNDED' — additive, passes review, deploys.
t=+1h     First partial refund. CDC event carries the new value.
t=+1h     Billing consumer's exhaustive switch throws on unknown status; message retried, then DLQ'd.
t=+6h     DLQ holds 14K events. Nobody alerts on DLQ depth for this consumer.
t=+3d     Month-end invoicing runs from billing's projection; 14K orders missing. Revenue under-reported.
t=+3d+4h  Fix: consumer maps unknown statuses to a safe default + alert; DLQ replayed.

Detection: consumer.dlq_depth alert at > 0 for financial consumers; consumer.unknown_enum_count. Prevention: enum additions are treated as potentially breaking in the schema standard; consumers must tolerate unknown values; the producing team notifies registered consumers of new enum values one release ahead. Owner: the producer owns the announcement; each consumer owns tolerance.

The Principal Lens#

Why L7 Sees This Problem Differently#

At Staff level, data modeling is about getting one system's schema right and evolving it safely. At Principal level, the data model is the org chart made durable — and the company's ability to reorganize, split services, comply with regulations, and build analytics is capped by how data ownership was drawn years earlier. The Principal sees three portfolio-level risks a single team can't: shared tables that freeze multiple teams at once, the same entity (customer, account) modeled five incompatible ways across services, and PII scattered across dozens of stores without an owner who can answer a deletion request.

The Org-Level Fault Line#

One canonical enterprise data model vs bounded-context models with contracts between them. A single canonical Customer everyone shares sounds like consistency and becomes a coordination bottleneck and a lowest-common-denominator schema. Fully independent models per service create five different definitions of "active customer" and a warehouse full of reconciliation logic.

OptionWhat WorksWhat BreaksWho Pays
Canonical enterprise modelOne definition; easy analyticsEvery change is a cross-org negotiation; teams route around itEvery product team, in velocity
Independent models, no contractsLocal speedInconsistent definitions; brittle integrations; PII sprawlData/analytics teams and compliance
Bounded contexts + published contracts (events/APIs) + a small set of shared identifiersLocal autonomy with stable integration pointsRequires a registry, contract tooling, and governance for shared IDsData platform team (~3–6 FTE at scale)

Cost Model#

Assumptions: engineer ≈ $25K/month fully loaded; migrations measured in engineer-weeks; incident cost excluded unless noted.

ScaleSchemas / TeamsCost of Schema Change DisciplineCost Without ItOn-Call
Startup (1 DB, 3 teams)~50 tablesMigration linter + expand/contract habit ≈ 0.1 FTEOccasional migration outage (minutes)Shared
Growth (10 DBs, 20 teams)~800 tables, 100 event typesSchema registry, migration tooling, catalog ≈ 2 FTE ≈ $50K/moA shared-table split costs 2–4 eng × 2 quarters ≈ $300–600K; recurring migration incidentsDB platform on-call; ~1 schema-related Sev2/quarter
Large (200+ DBs, 150 teams)~20K tables, 2K event typesData platform + governance 6–10 FTE ≈ $150–250K/mo; storage for audit/historyPII deletion requests touching 60 unknown stores; regulatory fines exposure; analytics built on inconsistent definitionsData platform on-call; per-domain owners

The 3-Year Evolution Path#

Diagram: The 3-Year Evolution Path

One-Way Doors vs Two-Way Doors#

DecisionReversibilityCost to Reverse
Primary/partition key of a large tableOne-wayFull rewrite and re-shard; quarters
Identifier format exposed to clientsOne-wayEvery client and integration
Aggregate boundaries (what's transactional together)One-way-ishChanging requires distributed transactions or sagas
Adding a nullable columnTwo-wayFree
Dropping a columnOne-wayRestore from backup; data loss for rows written since
Adding a denormalized projectionTwo-wayStop maintaining it
Event schema published to many consumersOne-way per fieldFields live as long as the retention/replay horizon
Storing PII in a new storeOne-way-ishMust be found and purged; lineage required

The Standard I'd Write#

RFC-DATA-002: Data Ownership and Schema Evolution

Scope: All persistent stores and event topics holding production data.

Mandatory (MUST):

  1. Every table and topic MUST have exactly one owning team registered in the data catalog; only the owner's services may write it.
  2. Cross-team reads MUST go through a versioned API, event stream, or published view — never base tables.
  3. Schema changes MUST follow expand → migrate → contract; column/field removals MUST wait for all registered consumers to confirm and at least 30 days.
  4. Migrations MUST set lock_timeout and use online DDL on tables > 10 GB; backfills MUST be throttled on replica lag and resumable.
  5. Event schemas MUST be registered with FULL compatibility; consumers MUST tolerate unknown enum values.
  6. PII fields MUST be classified at creation.

Recommended (SHOULD): tenant-leading keys for multi-tenant data; outbox pattern for event publication; integer minor units for money.

Exceptions: data platform review within 5 business days; time-boxed.

Success metrics: tables with a single registered owner (100% in 3 quarters); schema-related Sev2+ incidents (−50% YoY); median time to fulfill a data-deletion request (< 7 days).

What I'd Tell the VP#

"Our biggest data risk isn't a bad schema — it's that several core tables are written by many teams, so every change needs a cross-team meeting and some changes can't be made at all. That's slowing roadmap work and making privacy requests hard to answer. I'm proposing clear ownership for every table, with other teams reading through stable interfaces, plus tooling that makes safe migrations the default. It costs about two engineers on the data platform and a few quarters of migration work spread across teams. The payoff is faster feature delivery on core entities, fewer migration outages, and a defensible answer when a regulator asks where customer data lives."

Principal Interview Signals#

SignalWhat It Sounds Like
Sees ownership as the root cause"The schema isn't the problem — eight writers are. Ownership first, then storage."
Prices migrations"Splitting this table is ~2 engineers for two quarters. Leaving it costs us every future change on users."
Identifies one-way doors"Partition key and ID format are forever. Column additions aren't — I won't spend review time there."
Governs the portfolio"We have five definitions of 'active customer.' I'd standardize the identifier and the contract, not the whole model."
Connects to compliance"If we can't list every store holding email, we can't honor a deletion request. Classification at creation is cheaper than discovery later."

Staff answers that L7 interviewers find insufficient:

  • "We'll do expand/contract for this migration" — correct, but silent on why the org keeps needing risky migrations on shared tables.
  • "Let's normalize it properly" — optimizes one schema while ignoring the five other services that model the same entity.
  • "Each team owns its data" — without contracts, a registry, or a shared-ID standard, this is fragmentation, not ownership.

🧭 Principal Move: "I'd make 'who is the one writer of this table?' a required field in the catalog and a CI check on migrations. Most of our data-model debt is ownership debt, and it's cheapest to pay down before the next reorg."

In the Wild#

Amazon: Single-Table Design in DynamoDB#

AWS's public DynamoDB guidance and re:Invent talks popularized access-pattern-first, single-table design: list every query, then design partition and sort keys (and a minimal set of GSIs) so each query is one Query call, co-locating heterogeneous entity types in item collections.

Staff insight: this is the extreme version of "model the queries, not the entities." In interviews, it's the clearest example of why access patterns must come before the schema in stores without joins.

GitHub: gh-ost for Online Schema Migrations#

GitHub built and open-sourced gh-ost, a triggerless online schema migration tool for MySQL that copies rows to a shadow table while tailing the binlog for ongoing changes, with throttling on replica lag and a controlled cut-over. It exists because ALTER TABLE on very large, hot MySQL tables was an operational risk.

Staff insight: at scale, schema changes are production operations with throttles, pause buttons, and cut-over plans. Mentioning lag-based throttling signals you've run a real migration.

Confluent Schema Registry and Event Contracts#

Kafka ecosystems commonly use a schema registry (Confluent's being the most widespread) to enforce Avro/Protobuf/JSON Schema compatibility modes — backward, forward, full — on every producer write, preventing a producer from publishing a schema that would break existing consumers.

Staff insight: events are schemas with unknown readers and long replay horizons. Enforcing compatibility at publish time turns a social contract into a mechanical one.


Staff Calibration#

What Staff Engineers Say (That Seniors Don't)#

ConceptSenior (L5)Staff (L6)Principal (L7)
Starting point"Here are the entities and relationships""Here are the top 5 access patterns with QPS and freshness; the schema follows""And here's which team owns each table and what contracts others read through"
Denormalization"Denormalize for performance""One projection from CDC with a 2s lag SLO, version-guarded upserts, and a drift metric""Projections are a paved-road pattern with a platform owner, not bespoke pipelines per team"
Migrations"Write a migration to rename the column""Seven-phase expand/contract, throttled backfill, drop only after all readers move""Migration tooling and lock timeouts are enforced in CI; schema incidents are tracked as an org metric"
Shared tables"Other services can query our DB""One writer; others use an API or events""Ownership is registered and enforced; I'd fund the split of our top 3 shared tables"
Evolution"We'll version it""Additive-only, registry-enforced compatibility, consumers tolerate unknown enums""Field removals have a deprecation process tied to consumer registration and replay horizons"
Why "Starting point" separates levels

An entity model is not wrong — it's the domain. But it doesn't say which query must be a single seek at 30K QPS, which list may be 5 seconds stale, or which data is analytics-only. Staff candidates let the access patterns pick the keys and duplications. Principal candidates also ask who will own each piece, because ownership decides how the model evolves.

Why "Migrations" separates levels

L5 treats a migration as a file. L6 treats it as a multi-deploy rollout that must be valid for old and new code at every step, with throttled backfills and verification. L7 recognizes that the organization runs hundreds of these a year and invests in tooling so the safe path is the default.

Staff Sentence Templates#

"The top access pattern is [query] at [QPS] with a [latency] budget, so the key is [partition key, sort key]."

"I'm duplicating [field] into [projection] because [read path] runs at [QPS]; it's maintained by [mechanism], lag SLO [seconds], and [team] owns the drift metric."

"This change is [N] deploys: expand, dual-write, backfill at [rows/s] gated on [lag threshold], verify, switch, stop-write, contract after [longest reader] migrates."

"[Team] is the only writer of [table]; everyone else reads through [API / event / view], versioned as [contract]."

"[Relationship] is [bounded at N / unbounded], so it's [embedded / its own partition keyed by (parent, time)]."

Common Interview Traps#

  • Starting with an ER diagram. Without access patterns, you can't justify keys or denormalization.
  • Embedding unbounded collections. Always ask "how many can there be?" — and plan for the largest customer.
  • Denormalizing without a maintenance path. Every copy needs an owner, an update mechanism, and a drift check.
  • One-step breaking migrations. Rename, drop, and NOT NULL additions break running code.
  • Using natural keys as primary keys. Emails and phone numbers change.
  • Floats for money, local time for timestamps. Both are classic, permanent data-quality bugs.
  • Shared write access across services. It's a distributed system with no protocol.
  • Forgetting analytics. Designing OLTP tables to also serve monthly reports ruins both.

Practice Drill#

Prompt: "A B2B SaaS app stores all tenant data in one Postgres database with no tenant_id on most tables — tenancy is derived through joins to accounts. Your largest customer wants data residency in the EU, and two others want dedicated databases. What do you do?"

Staff Answer

The root problem is that tenancy isn't a first-class key, so we can't move, isolate, or delete a tenant's data without join-walking the schema. Step 1: add a nullable tenant_id to every tenant-scoped table (expand), populate it on all new writes (dual-write from the app's data access layer), and backfill by walking the ownership graph in throttled, resumable batches — roughly 1–3 billion rows across tables, planned as a multi-week job gated on replica lag. Step 2: verify with per-table null counts and a join-vs-column consistency check, then make tenant_id NOT NULL (validate as a constraint in two steps) and lead every index and unique constraint with it. Step 3: enforce it — row-level security or a mandatory tenant filter in the data access layer, with a CI check that new tables include tenant_id. Step 4: with tenancy explicit, a tenant can be moved: logical replication filtered by tenant_id into an EU database, a routing directory mapping tenant_id → cluster, cut-over per tenant with a brief write freeze. Metrics: backfill.rows_remaining, tenant_id_null_count, cross-tenant query violations (must be 0). The platform team owns the tenancy framework and directory; each domain team owns its tables' backfill.

Why this is L6:

  • Identifies the modeling root cause (implicit tenancy) instead of jumping to "spin up another database."
  • Uses expand/contract with throttled, verifiable backfills and two-step constraint validation.
  • Designs the directory-based routing that makes per-tenant placement possible.

What L7 adds:

  • Prices it: ~3–4 engineers for 2 quarters vs the revenue of the EU customer and the enterprise tier it unlocks.
  • Makes tenant-leading keys and residency tags part of the data standard so no new table repeats the debt.
  • Decides the product policy with sales/legal: which tiers get dedicated or regional databases, at what price, and what the cell-per-region architecture looks like in 3 years.

Where This Appears#

Related Technologies: PostgreSQL · DynamoDB · Cassandra · Apache Kafka

  1. Loading the index…