Production Data Modeling: Scale, Loading & Behavior
Partitioning, scalability under traffic, insert/update/history load mechanics, CDC as a load pattern, late-arriving data, and tradeoffs with sample queries.
⏱ 25 min readTopics chapter readerLevel · Specialized & Applied
01 · Orientation
From Correct To Production-Grade
A production model is judged by behavior — bytes read, tasks skewed, rows double-counted, partitions rewritten — not by how the diagram looks.
⏱ 4 min · Topic 1 of 11
A model can be perfectly correct on a whiteboard and still fall over in production. The earlier chapters made this model right; this chapter runs it — one retail sales star, under load, retries, backfills and a budget.
Step through the six questions below. Each is a topic in this chapter, and each has a failure attached to skipping it.
Core mental model
A production model is judged by behavior — bytes read, tasks skewed, rows double-counted, partitions rewritten — not by how the diagram looks.
Why it matters
Production is where models earn their keep. The gap between a junior and a senior modeler is usually here: not in drawing tables, but in knowing what the drawing does at volume, on a retry, and at 3am.
partitioning
Splitting a table by a key (usually date) so queries scan only relevant slices.
append-only fact
A fact table written by inserts only, never in-place updates, the key to fast ingest.
idempotent load
A load that can be re-run without double-counting, via natural-key dedupe.
Six questions a review never asksThe model below has already passed design review: right grain, right keys, right history. These are the questions production asks next. Each one is a topic in this chapter.
What happens if the load runs twice?Choose a write mode that can be re-run: MERGE on the natural key, or an overwrite scoped to a partition the batch fully owns. Plain INSERT is never retry-safe.
Ship without an answer and you getA timeout at 03:00 is retried at 03:05, the day doubles, nothing raises an error, and finance finds it a week later.
Common mistake
Validating a model only by its diagram, never by its production behavior. It looks right but is slow at volume, double-counts on reloads, or loses late data; behavior is the real test.
Better habit
Design partitioning and load behavior alongside the schema.
Make every fact load append-only and idempotent.
Plan for late facts and late dimensions from day one.
The big idea
Correctness is necessary and not sufficient. Everything in this chapter is a question the diagram cannot answer.
Remember this
Earlier chapters made the model correct; this one makes it production-grade — partitioned, retry-safe, backfillable, and sized for the scale you actually have.
02 · The model
A Typical Model: Star And Snowflake
by field ID (metadata) or by name and path (rewrite).
⏱ 5 min · Topic 2 of 11
Chapters 8 and 9 cover both shapes in full. Here is the model this chapter loads, partitions and scales: a retail sales star at the grain of one product line per sale, plus the snowflake variant.
In production it is 10.8bn rows and about 1.04 TB. Price a few edits to it below: the same ALTER is free or a 69-minute rewrite, depending on a decision taken at CREATE TABLE time.
Core mental model
Star = fact + wide denormalized dimensions (one join). Snowflake = the same, normalized into sub-tables. At a terabyte, the cost of a change is set by how the table finds a column: by field ID (metadata) or by name and path (rewrite).
Why it matters
At this size a schema change is something you schedule, not something you type.
star schema
Fact + wide denormalized dimensions; one join per attribute.
snowflake schema
Dimensions normalized into linked sub-tables; more joins, less redundancy.
metadata-only change
A schema change applied by editing table metadata, leaving every data file untouched.
field ID
A numeric identifier a format assigns to a column so files are read by ID rather than by name.
STAR: wide denormalized dimensions
fact_sales
ColumnTypeKeys
sale_idbigintPK
date_keyintFK
customer_keybigintFK
product_keybigintFK
store_keybigintFK
order_numberstring
quantityinteger
amountdecimal
Grain: one row per product per sale. Measures additive; order_number degenerate.
dim_date
ColumnTypeKeys
date_keyintPK
full_datedate
monthstring
quarterstring
yearinteger
is_weekendboolean
dim_customer
ColumnTypeKeys
customer_keybigintPK
customer_idstring
namestring
citystring
segmentstring
valid_fromdate
valid_todate
is_currentboolean
SCD2: versioned city and segment.
dim_product
ColumnTypeKeys
product_keybigintPK
product_idstring
namestring
categorystring
brandstring
category and brand inline.
dim_store
ColumnTypeKeys
store_keybigintPK
store_idstring
namestring
regionstring
countrystring
region and country inline.
One fact at a declared grain plus five wide dimensions: any attribute is one join away.
SNOWFLAKE: dim_product and dim_store normalized
dim_product
ColumnTypeKeys
product_keybigintPK
product_idstring
namestring
category_keybigintFK
brand_keybigintFK
normalized out.
dim_category
ColumnTypeKeys
category_keybigintPK
category_namestring
departmentstring
dim_brand
ColumnTypeKeys
brand_keybigintPK
brand_namestring
manufacturerstring
dim_store
ColumnTypeKeys
store_keybigintPK
store_idstring
namestring
region_keybigintFK
normalized out.
dim_region
ColumnTypeKeys
region_keybigintPK
region_namestring
countrystring
Same fact and grain; dim_product and dim_store are normalized out. Less redundancy, one more hop to "revenue by category".
Common mistake
Assuming a rename is free because it is one word in the DDL. On a table resolved by name the files keep the old name, so the column reads NULL for all history — and no error is raised. The repair is a full rewrite.
Snowflaking every dimension by reflex. Every query gains joins for storage savings that compression already handles; default to a star.
Better habit
Default to a star; snowflake only with a reason.
Choose a table format with field IDs before the table gets big — the property is set at create time.
Price a schema change in bytes moved before promising a date.
Compatible vs affordable
Chapter 19 decides whether a change is compatible for consumers. This section asks the other question: what does applying it cost, and can readers see a half-changed table while it runs?
The star and snowflake hold the same data and are the target for every production question here — and once the fact holds a terabyte, whether a change is metadata-only or a rewrite is a property of the table format, not of the change.
03 · Scale
Partitioning & Pruning Huge Tables
Partition at the granularity the hot query filters at; cluster on a column queries actually filter; and keep the predicate on the raw partition key, or none of it fires.
⏱ 4 min · Topic 3 of 11
fact_sales grows without bound. Partitioning splits it physically by a key — almost always date — so a date-filtered query reads only the relevant partitions and skips the rest. Clustering sorts rows within a partition so the engine can skip blocks that cannot match.
A scheme is only right or wrong relative to a workload, so pick one below and watch three real queries run against it. The right granularity is set by the query that runs 400 times a day, not by the one that looks tidiest.
Core mental model
Partition at the granularity the hot query filters at; cluster on a column queries actually filter; and keep the predicate on the raw partition key, or none of it fires.
Why it matters
Without pruning, cost tracks the size of the table instead of the size of the question, and gets worse every day the table grows. Chapter 14 chooses partitioning as a warehouse-layer decision; here it meets a real fact load.
partition pruning
Skipping partitions that cannot match a query's partition-key filter.
clustering
Sorting data within a partition so non-matching blocks can be skipped.
partition key
The column a table is physically split on, usually date for facts.
A query that prunes to a few partitionsworked example
SQL
-- fact_sales is partitioned by date_key (one partition per month).-- Filtering on the partition key lets the engine scan ONLY Q2 partitions.selectd.month,sum(f.amount)asrevenuefromfact_salesfjoindim_datedond.date_key=f.date_keywheref.date_keybetween20260401and20260630-- prunes to 3 partitionsgroupbyd.month;
The filter is on the raw partition key, so the engine opens three partitions instead of thirty-six. Wrap date_key in a function and the same query reads everything.
Common mistake
Not filtering on the partition key (e.g. filtering a derived date expression). Pruning is defeated; the engine scans every partition. Filter the raw partition key directly.
Over-partitioning into millions of tiny partitions. Metadata overhead and the small-file problem make it slower; pick a partition size that matches query patterns. Compact with Iceberg's rewrite_data_files or Delta's OPTIMIZE to merge small files back into right-sized segments.
Better habit
Choose the partition granularity from the highest-frequency query, not the largest one.
Cluster only on columns the workload actually filters; an unused clustering key is a sort you pay for and never read.
Always filter on the raw partition key to enable pruning.
Run compaction (rewrite_data_files or OPTIMIZE) regularly on over-partitioned tables.
When pruning has done all it can
Once the scheme is right, the bill is dominated by the query that filters no date at all. No partitioning scheme reaches that one — it needs a pre-aggregated table, which is the next section.
Compaction for lakehouse formats
Over-partitioning creates many small files. Iceberg's rewrite_data_files and Delta's OPTIMIZE merge them into right-sized segments. The data-pipeline KB covers these formats in depth.
Partition the fact by date and cluster within partitions; a query that filters the partition key prunes to a few partitions, so cost scales with the slice, not the whole table.
04 · Scale
Scaling Under Heavy Traffic
Scale reads with pruning, fewer joins and pre-aggregation; scale writes with append-only batches. A distributed stage finishes when its slowest task does, so watch the distribution, not the total.
⏱ 4 min · Topic 4 of 11
Reads and writes scale differently. Reads are served by columnar storage, pruning, fewer joins and pre-aggregation — the ladder in the table below. Writes are served by append-only facts and bulk loading.
Neither helps with the failure that actually stops distributed work: one key holding a disproportionate share of the rows. Run the skewed join below, then fix it three ways — two in the engine, one in the model.
Core mental model
Scale reads with pruning, fewer joins and pre-aggregation; scale writes with append-only batches. A distributed stage finishes when its slowest task does, so watch the distribution, not the total.
Why it matters
Traffic is where naive models collapse: dashboards time out, ingestion lags, and a cluster twice the size finishes at exactly the same time because seven of its eight tasks were already idle.
pre-aggregation
Storing rolled-up totals (daily/monthly) so hot metrics avoid scanning raw facts.
data skew
One key holding a disproportionate share of rows, so one task does most of the work.
append-only ingest
Loading facts by inserts only, the cheapest, most scalable write path.
When a hot metric is still too slow
Technique
What it does
Cost
Partition pruning
Scan only relevant slices
Free, needs a partition filter
Rollup / aggregate table
Pre-compute daily/monthly totals
Extra table to maintain
Materialized view
Cache a query result, auto-refreshed
Storage + refresh cost
One Big Table
Remove joins entirely
Redundancy, harder history
Common mistake
Answering a skewed join by adding executors. The extra executors receive no rows: the stage still waits for the one task holding the hot key. Change the distribution or the model, not the cluster size.
Serving every dashboard from raw facts. Hot metrics re-scan billions of rows; pre-aggregate or materialize the heavy ones.
Better habit
Pre-aggregate or materialize the hottest metrics.
Check the row count per key before choosing a join key.
Ask whether the hot key needs the join at all.
Interview note
Asked "how would this scale?", split it three ways: reads, writes, and distribution. The third is the one most candidates miss.
Where customer_key = -1 comes from
Chapter 8's Unknown member, which keeps inner joins from dropping unmatched rows. It is also, by construction, the most common key in the table.
Scale reads with pruning and pre-aggregation, writes with append-only batches — and watch the distribution: a stage costs as much as its slowest task, and the cheapest fix for a hot key is usually to take it out of the join.
05 · Behavior
Insert Behavior & Loading Facts
MERGE on the natural key, or overwrite a partition the batch fully owns.
⏱ 5 min · Topic 5 of 11
A new sale is a two-step path: resolve the surrogate keys of the dimensions involved (the current customer, product, store, and date), then append one row to fact_sales. No fact is ever updated in place.
At scale that becomes a batch — and batches get retried. Run the same batch twice below under each write mode. Only one of the three is safe in both, and the mode that looks most obviously idempotent is not it.
Core mental model
New record = resolve surrogate keys against current dimensions, then append one fact row. A re-run must be a no-op: MERGE on the natural key, or overwrite a partition the batch fully owns.
Why it matters
Ingestion correctness and speed live here. A retry-safe load is what lets you reprocess after a failure without corrupting a day of revenue.
surrogate key lookup
Resolving a fact's dimension references to the current surrogate keys at load time.
append-only
Facts are inserted, never updated in place; corrections are new rows.
idempotent load
A load that dedupes on the natural key so re-runs do not double-count.
bounded dedup window
Limiting the deduplication scan to a recent range (the replay window) to avoid scanning all history.
Insert one sale: resolve surrogate keys, then appendworked example
SQL
-- Resolve the CURRENT dimension surrogate keys, then insert one fact row.insertintofact_sales(date_key,customer_key,product_key,store_key,order_number,quantity,amount)selectd.date_key,c.customer_key,p.product_key,s.store_key,'SO-5001',1,40.00fromdim_datedjoindim_customerconc.customer_id='C-7'andc.is_currentjoindim_productponp.product_id='P-A1'joindim_storesons.store_id='ST-9'whered.full_date=date'2026-06-20';-- Append-only: there is no UPDATE of an existing fact, ever.
The insert looks up the surrogate key of the version current at sale time (c.is_current), so the fact is pinned to the right history. Facts are appended, never updated.
Window deduplication for at-least-once replayworked example
SQL
-- At-least-once delivery can replay duplicate events in staging_sales.-- row_number picks one copy per natural key, ordered by load_ts (most recent).-- BOUND the window to the replay period so the CTE never scans all history.withdedupedas(select*,row_number()over(partitionbyorder_number,skuorderbyload_tsdesc)asrnfromstaging_saleswhereload_ts>=current_date-interval'3'day-- bounded to replay window)insertintofact_sales(order_number,sku,/* keys... */amount)selectd.order_number,d.sku,/* resolved keys... */d.amountfromdedupeddleftjoinfact_salesfonf.order_number=d.order_numberandf.sku=d.skuwhered.rn=1andf.order_numberisnull;-- only rows absent from fact_sales
Bounding the window to 3 days (the replay window) keeps the CTE scan cheap. Without the bound, the dedup grows linearly with the full staging table and eventually misses SLA windows.
Common mistake
Updating fact rows in place to "fix" a value. It breaks append-only ingest and auditability; issue a reversing/corrective row instead.
Using an unbounded dedup window on a multi-year fact table. The dedup scan grows linearly with table size and eventually misses SLA windows; bound the window to your replay period.
Better habit
Resolve surrogate keys against current dimensions, then append.
Make every fact load idempotent via natural-key dedupe.
Correct facts with new rows, never in-place updates.
Production reality
Pipelines retry. If a fact load is not idempotent, a single retry silently doubles a day's revenue. Natural-key dedupe (or a MERGE keyed on it) is non-negotiable.
Idempotency mechanics: the pipeline KB
The data-pipeline KB covers exactly-once semantics, retry strategies, and delivery guarantees in full. This chapter focuses on the modeling consequence: a clean natural key is what makes any of those strategies work.
A new record resolves surrogate keys then appends one fact row; loads stay idempotent by deduping on the natural key within a bounded window; MERGE requires a deduplicated source or all major platforms error.
06 · Load patterns
CDC as a Load Pattern
CDC stream = ordered change log per key. Each update event closes the current SCD2 row and opens a new one. A delete closes without opening. Apply in log-position order per key.
⏱ 4 min · Topic 6 of 11
Change Data Capture (CDC) captures row-level changes from an operational database as an ordered stream of events, each carrying an operation type (insert, update, delete), before and after images of the row, and a log position (LSN or offset) that establishes order. The data-pipeline KB covers the capture mechanics — binlog/WAL readers, Debezium-style tooling — in depth. This section owns the modeling side: what you do with a CDC stream once it arrives.
The one constraint that matters is apply in log-position order per key. Run the stream below both ways: arrival order does not merely land on the wrong current value, it leaves two overlapping open intervals, so one sale resolves to two dimension rows and is counted twice.
Core mental model
CDC stream = ordered change log per key. Each update event closes the current SCD2 row and opens a new one. A delete closes without opening. Apply in log-position order per key.
Why it matters
Most production operational databases emit CDC rather than full snapshots. Knowing how to derive a correct SCD2 dimension from a CDC stream is the production load pattern that separates a data modeler who works with live systems from one who works with overnight dumps.
CDC (Change Data Capture)
A stream of row-level change events (insert/update/delete) with log positions from an operational database.
log position
A monotonically increasing offset (LSN, offset) that establishes the true order of changes, regardless of arrival time.
before/after image
The row state immediately before and after a change event; the after image is the new dimension attribute value.
MERGE/UPSERT with source deduplication (ANSI-style)worked example
SQL
-- Deduplicate the source FIRST; then merge to avoid nondeterminism.-- As of current documentation: Snowflake raises an error by default when-- multiple source rows match one target row (ERROR_ON_NONDETERMINISTIC_MERGE).-- BigQuery and Delta Lake MERGE INTO likewise fail-- on multi-source-row matches.-- The fix is always: deduplicate the source before merging.withdeduped_srcas(select*,row_number()over(partitionbyorder_number,skuorderbyload_tsdesc)asrnfromstaging_saleswhereload_ts>=current_date-interval'3'day)mergeintofact_salestgtusing(select*fromdeduped_srcwherern=1)srcontgt.order_number=src.order_numberandtgt.sku=src.skuwhenmatchedthenupdatesetamount=src.amountwhennotmatchedtheninsert(order_number,sku,amount)values(src.order_number,src.sku,src.amount);
MERGE combines insert and update in one statement, but every major platform fails when multiple source rows match one target row for the matched-update clause. Always deduplicate the source first. The data-pipeline KB covers MERGE pipeline mechanics; here the lesson is at the modeling layer: a clean natural key is the prerequisite.
Snapshot load vs CDC load
Aspect
Full snapshot
CDC stream
Data volume per load
Full table every run
Only changed rows
Ordering requirement
None (last write wins)
Apply in log-position order per key
Handles deletes
Infer from absence
Explicit delete event
SCD2 derivation
Diff snapshots
Each update = close + open
Latency
Batch (minutes-hours)
Near real-time
Common mistake
Running MERGE with duplicate source rows and no pre-deduplication. Snowflake, BigQuery, and Delta all error or produce nondeterministic results; always deduplicate the source before merging.
Applying CDC events in arrival order instead of log-position order. Out-of-order updates leave overlapping intervals — one of them with valid_to before valid_from — so an as-of join fans out and double-counts. Sort by log position per key before applying, and assert non-overlap after (Chapter 11).
Ignoring delete events and leaving a stale is_current = true row. Queries see a customer as active after deletion; close the row on delete and do not open a new version.
Better habit
Deduplicate the source before any MERGE; bound the window to the replay period.
Sort CDC events by log position per key before applying to any dimension.
Treat each update event as a close-current-row + open-new-row pair for SCD2.
Handle delete events explicitly: close without opening.
CDC capture mechanics: the pipeline KB
Binlog/WAL reading, Debezium connectors, and at-least-once delivery semantics are covered in the data-pipeline CDC chapter. This chapter owns the modeling consequence: how a change stream becomes SCD2 history.
A CDC stream is an ordered change log per key; derive SCD2 by treating each update as close-then-open and each delete as close-only, always applied in log-position order.
07 · Over time
History (SCD) & Late-Arriving Data
Load dimensions before facts; place late facts by event time within a replay window; and give every partition exactly one writer at a time.
⏱ 5 min · Topic 7 of 11
Dimensions change and data arrives late. Chapter 11 covers SCD types 0 through 7 in full; here the focus is the load pattern — close the old row, insert a new version with a new surrogate key, load dimensions before facts, and stamp every timestamp in UTC.
At scale the hard part is not the SCD, it is that rewriting history and loading today happen at the same time. Run the collision below: a March restatement against an hourly loader, judged on whether a reader sees a gap, whether the late row survives, and what it costs.
Core mental model
Load dimensions before facts; place late facts by event time within a replay window; and give every partition exactly one writer at a time.
Why it matters
Pipelines are never perfectly ordered, and backfills are never run on an idle warehouse. Without a single-writer rule, the rows a restatement destroys are the late ones — the ones nobody is watching.
SCD2 load
Close the current dimension row and insert a new version with a new surrogate key.
replay window
Reprocessing a recent range of partitions to absorb late or corrected data.
event date
The timestamp of when the business event happened, as opposed to when data arrived in the pipeline.
SCD2 dimension load: a customer moves cityworked example
SQL
-- 1) Close the current version.updatedim_customersetvalid_to=date'2026-06-19',is_current=falsewherecustomer_id='C-7'andis_current;-- 2) Insert the new version with a NEW surrogate key.insertintodim_customer(customer_id,name,city,segment,valid_from,valid_to,is_current)values('C-7','Mara','San Francisco','gold',date'2026-06-20',date'9999-12-31',true);-- Facts before Jun 20 keep the old customer_key, so they still report the old city.
SCD2 preserves history by versioning, not overwriting. Because facts stored the surrogate key current at sale time, a May sale still rolls up under the old city, never re-attributed.
Late-arriving fact: stamp to event date, reprocess the partitionworked example
SQL
-- A sale from 2026-03-10 arrives on 2026-03-14.-- WRONG: stamp it to today (load date) -> March totals are understated forever.-- RIGHT: stamp it to the EVENT date and reprocess that partition idempotently.insertintofact_sales(date_key,/* keys... */amount)select20260310,/* resolved keys... */stg.amount-- event date, not load datefromstaging_salesstgwherestg.event_date=date'2026-03-10'andnotexists(select1fromfact_salesfwheref.order_number=stg.order_numberandf.sku=stg.sku);
Late facts go to their event date with a replay-window reprocess and natural-key dedupe, so the correct historical period is fixed without double-counting.
Common mistake
Stamping a late fact to its load date instead of its event date. The original period stays understated and "today" is overstated; always use the event timestamp.
Backfilling a range while the incremental loader still owns those partitions. Whatever the loader wrote after the backfill read its source is deleted or swapped away — and unless the commit is validated against the read snapshot, nothing reports it.
Storing timestamps in local time instead of UTC. Replay windows and event-date comparisons break across daylight saving transitions; store UTC, convert at the presentation layer.
Better habit
Load SCD2 dimensions before facts.
Give every partition one writer at a time; pause the loader or scope the backfill.
Store all timestamps in UTC; convert at presentation only.
Interview note
A replay window for late facts shows you have run pipelines rather than drawn them. Saying what happens when a backfill meets the loader shows you have been paged by one. For inferred members and full SCD mechanics, Chapter 11 is canonical.
History is captured, not computed
Because the fact stored the surrogate key current at event time, correct history is a plain key join, no as-of date logic needed at query time.
Remember this
Load SCD2 dimensions before facts so history is preserved by versioning; place late facts by event time with a replay window; store all timestamps in UTC.
08 · Validate
Tradeoffs & Sample Queries
Prove the model with its own access patterns, then justify every physical technique with a number — volume, freshness target, bytes read. What no number justifies, do not build.
⏱ 5 min · Topic 8 of 11
Validate the model the way Chapter 22 insisted: run the access patterns as sample queries and confirm they answer correctly and cheaply. The two below also make the star-vs-snowflake tradeoff concrete — same answer, one more join.
Then size the design against the constraints you actually have. Everything in this chapter costs something, and the ledger below is willing to tell you that the answer is none of it.
Core mental model
Prove the model with its own access patterns, then justify every physical technique with a number — volume, freshness target, bytes read. What no number justifies, do not build.
Why it matters
A model is proven when its real queries run correctly and within budget. The other half of that sentence is the budget: a technique warranted at 500M rows a day is a liability at 50k, and it is much harder to argue against, because it looks like diligence.
access-pattern validation
Running the real queries to confirm the model answers them correctly and cheaply.
small-file problem
Partitions far below the compaction target, so metadata and file listing cost more than the data.
key-join history
Correct historical attribution via the stored surrogate key, no as-of date logic.
Same question, star (1 join) vs snowflake (2 joins)worked example
SQL
-- STAR: category is a column on dim_product -> one join.selectp.category,sum(f.amount)asrevenuefromfact_salesfjoindim_productponp.product_key=f.product_keygroupbyp.category;-- SNOWFLAKE: category lives in dim_category -> an extra hop.selectc.category_name,sum(f.amount)asrevenuefromfact_salesfjoindim_productponp.product_key=f.product_keyjoindim_categoryconc.category_key=p.category_keygroupbyc.category_name;
Result (identical either way)
category
revenue
Apparel
120.00
Footwear
40.00
Same numbers; the snowflake just paid one more join. That is the tradeoff, made concrete.
Same answer, one join versus two. That is why the star is the analytics default.
Correct history via the SCD2 key join (no date logic)worked example
SQL
-- The fact stored the customer_key current at sale time, so a plain-- join reports each sale under the city the customer had THEN.selectcu.city,sum(f.amount)asrevenuefromfact_salesfjoindim_customercuoncu.customer_key=f.customer_key-- version-specific keygroupbycu.city;-- A pre-move sale rolls up under the old city; a post-move sale under the new one.
No as-of filter: history was captured in the surrogate key at load time, so correct attribution is an ordinary join.
What this model optimizes for, and gives up
Optimized for
Traded away
Fast reads (pruning, few joins)
Some redundancy in dimensions
Cheap, idempotent ingest (append-only)
No in-place fact edits (corrections are rows)
Correct history (SCD2 + key join)
More dimension rows over time
Self-service (simple star)
Rollups may be eventually consistent
Common mistake
Declaring the model done without running its real queries. A missed access pattern or a fan-out double-count surfaces in production instead of in review.
Adopting the techniques in this chapter because they are in this chapter. Hourly partitions holding 200 kB, a rollup of a table you could scan whole, streaming for a daily report: slower than the simple design, and harder to undo.
Better habit
Validate the model by running its access patterns as queries.
Attach a number to every physical technique before adopting it.
State what the model optimizes for and what it gives up.
Interview note
Close a design with a sample query, a tradeoff, and a number: "one join, prunes by date, 13 TB a month at this volume — and at a tenth of it I would not partition at all."
Take this to the studio
Name what you optimized for and what you traded away; the Defend phase evaluates exactly that reasoning.
Validate with the real access patterns, then justify every physical technique with a number — and be willing to conclude that at your volume the answer is none of them.
09 · Recap
What You Now Know
Production = a write mode you can re-run + a layout the hot query can prune to + one writer per partition, applying changes in the order the source produced them.
⏱ 4 min · Topic 9 of 11
This chapter asked every production question of one retail sales model: what it reads, how it distributes, what a retry does to it, how a change stream lands, what a backfill costs, and whether the scale is real. Three of those answers are decisions taken before the first row loads.
So take them. Build the spec below, then work five nights on it — the same five this chapter ran one at a time.
Core mental model
Production = a write mode you can re-run + a layout the hot query can prune to + one writer per partition, applying changes in the order the source produced them.
Why it matters
A production-grade model is not drawn, it is operated. Every failure in this chapter was silent: no exception, no failed job, just a number that was wrong. That is what makes production behavior a design question rather than an operations one.
Common mistake
Designing the schema without designing the load behavior. The model looks right but double-counts on retry, misses late data, or runs full scans at query time.
Using an unbounded dedup window or skipping the dedup before MERGE. Query time grows linearly with table size, or the MERGE errors on nondeterministic multi-row matches.
Better habit
Design partition key, load pattern, and dedup strategy at schema time, not after.
Test idempotency by running the load twice against a test fixture.
State optimized-for and traded-away before any design review.
Hands-on task (20-30 min)
Take the fact_sales model. Write an end-to-end load script: resolve surrogate keys, dedupe with a bounded window, insert idempotently, then apply one SCD2 update and verify that a pre-change sale still resolves to the old city in a plain key join.
Remember this
Every production model must answer: what does one query read, what happens on a retry, what happens out of order, what happens when history is rewritten, and is this scale real? Answer them at design time and the model is one you can be woken up for.
10 · Practice
Practice Lab
Say what one row means before you draw the second table. Everything else in a review follows from that sentence.
⏱ 3 min · Topic 10 of 11
Six design scenarios for this chapter, on the ERD canvas. Each one is graded against a real rubric, seeded with real rows, and each has one fault planted in the data rather than in the diagram.
Build them with the chapter closed. If one goes wrong, come back to the section it belongs to rather than re-reading the whole thing.
Core mental model
Say what one row means before you draw the second table. Everything else in a review follows from that sentence.
Why it matters
Reading about a grain mistake and watching one inflate a number you produced are different memories. The second is the one still there under interview pressure.
Common mistake
Revealing the reference model before your own review comes back. You see what correct looks like without finding out what your version got wrong, and your version is the one you will draw again under pressure.
Better habit
Write the three questions the model must answer before drawing a single table.
State the grain of every table out loud. If the sentence needs an "and", you have found a composite key.
Run the review, fix what it finds, and run it again. The second score is the one that means something.
Each scenario compiles your canvas to DDL, seeds correlated rows, and runs acceptance and anomaly checks against them. A finding comes with the query that produced it and the rows it returned.
Remember this
A model you have defended against seeded data is worth more than three you have only drawn.
11 · Next Chapter
Next Chapter
The model is correct and it survives production load. That does not mean it is done: the next reader is a BI tool’s query generator, and the next consumer is someone who never read your contract and never will.
⏱ 3 min · Topic 11 of 11
Next chapter
Modeling For Consumption: The BI Handoff
The model is correct and it survives production load. That does not mean it is done: the next reader is a BI tool’s query generator, and the next consumer is someone who never read your contract and never will.
Chapter 24 covers what a drag-and-drop query generator actually does with your model, where a metric definition should live so two dashboards can’t disagree about it, the aggregations that silently double-count across joins, and how to deprecate a table forty dashboards depend on without breaking all of them at once.