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.
⏱ 21 min readTopics chapter readerLevel · Specialized & Applied
01 · Overview
Start here
⏱ 5 min · Topic 1 of 13
You can already design a correct model. Production asks harder questions. A senior interviewer asks five of them about any model:
How is it loaded, and in what order?
What breaks when data arrives late or twice?
How does it behave at one billion rows?
How do queries use its layout?
How do you know it is still correct next month?
Every size in this chapter, such as “one billion rows”, is an assumption. It shows the shape of a problem. It is not a measurement.
Recap: a surrogate key is a number the warehouse makes for each dimension row. A fact row stores it. SCD Type 2 (Slowly Changing Dimension, Type 2) gives every version of a customer its own key.
The shop data from the earlier chapters runs through this one. Asha has keys 1001 and 1006. Priya, a new customer, has key 1007.
02 · Loading
Load dimensions before facts
⏱ 9 min · Topic 2 of 13
A fact row stores a surrogate key, not a customer id. That key must already exist in the dimension. So the dimension loads first.
An inferred member is a placeholder row for a customer you do not know yet.
A load has three steps:
Load new and changed dimension rows.
Add an inferred member for every customer the dimension does not know.
Look up the surrogate key for each order and load the facts.
Staging is a table where raw rows land first. Today’s staging table has seven orders.
Order 107 is for customer 6, Priya. The dimension has no row for customer 6 yet.
Step 2 adds a placeholder row for customer 6. The new key comes from a sequence. A sequence is a counter in the database that gives the next number.
Order 101 is before 2026-01-08, so key 1001. Order 102 is after it, so key 1006. Order 107 gets the placeholder, 1007.
You met this placeholder in Slowly Changing Dimensions (SCD 0–7). When the real row arrives, you update row 1007 in place. Here step 2 covers every unknown customer in one batch.
Watch out
Step 2 must be safe to rerun. NOT EXISTS skips customer 6 once row 1007 exists. Without it, a retry makes a second key for Priya.
03 · Loading
Load facts with MERGE
⏱ 9 min · Topic 3 of 13
A load is idempotent when running it twice gives the same table. A plain INSERT is not. A retry would add order 107 a second time.
MERGE compares a batch with a table. It updates rows that match and inserts rows that do not.
Use MERGE on the business key. The business key is the id from the source system, here order_id.
A surrogate key made during the load cannot do this job. A retry makes a new number, so the row never matches.
The next batch has three orders. Order 105 is now paid for 22.00, a payment made up for this chapter.
Order 105 was updated. Order 107 was inserted. Order 106 matched but did not change, so it was left alone.
Now run the same batch again. Every order matches, and nothing differs. So nothing updates.
The table keeps the same seven rows.
IS DISTINCT FROM is a not-equal test that also works with NULL. NULL against 22.00 gives true. Two NULLs give false.
Here the status also changes, so the plain not-equal test, <>, would still catch order 105. But picture a batch where only the amount changes, from NULL to 22.00. With <> that test gives NULL, not true, and the update is skipped.
Watch out
The batch must have one row per order_id. If two rows match one target row, most engines raise an error, and some can be set to pick one row. Remove duplicates in staging.
04 · Loading
Late and duplicate facts
⏱ 8 min · Topic 4 of 13
A source can send the same order twice. This happens after a retry.
Keep one row per order_id before the MERGE. We assume the newest copy is the right one.
The staging table has two copies of order 104. This query keeps the newest copy:
The load date 2026-01-24 would find key 1006, which is DE. That is wrong, because Asha lived in IN on the day of the order.
The next three sections leave order 120 out, to keep the numbers small. The summary section loads it.
The row belongs to an old date, 2026-01-05. Only that day’s numbers change. Many teams also reload the last few days on every run, to catch rows that are only a little late.
Watch out
A late fact changes a number that people already saw. Keep a loaded_at column, so you can explain why 2026-01-05 changed. A row later than your reload window needs a backfill, a one-time reload of old dates.
05 · Scale
Updating rows at scale
⏱ 9 min · Topic 5 of 13
An accumulating snapshot has one row per order. Each step of the journey fills a date in that row. You know this from Fact Table Design.
That means rows are changed, not only added. Each row is inserted when the order is placed. Then it is updated about three times: paid, shipped, delivered.
Why is this costly at scale? A columnar engine stores each column apart, in files. Many engines never change a file in place.
To update one row, the engine rewrites the file that holds it. Some systems, such as Apache Iceberg and Delta Lake, can write a small delete file instead. They rewrite the data file later.
Imagine 1 billion orders. A partition is a part of the table stored apart, here one per order date. The next section shows more.
An order that ships three weeks later touches an old partition, and its files are rewritten.
The cost is the number of files touched, not the number of rows changed. Two small changes in two old partitions can cost two file rewrites.
Two habits keep this cost down. First, only update the open window, the orders that are not finished. Second, filter on the partition column in the MERGE, so the engine skips the old files:
Order 102 is delivered on 2026-01-15. 2026-01-15 minus 2026-01-10 is 5 days. Order 106 ships on 2026-01-22 and is not delivered yet.
COALESCE keeps the old date when the new batch has NULL. Date minus date gives a number of days in PostgreSQL. Other engines use a function such as DATEDIFF.
The filter has a cost. An order older than 2026-01-08 that changes later is skipped, so the rule must match your business. In a real load the date moves, for example the last 30 days.
If late steps are common, store one row per step instead. That table only gets new rows. Old rows never change.
06 · Scale
Partition and cluster a fact
⏱ 12 min · Topic 6 of 13
Imagine fact_orders has 1 billion rows. A query for one week must not read all of them.
The last section showed partitions. Here fact_orders has one partition for each order_date. Pruning means the engine skips the partitions your filter rules out.
Clustering sorts the rows inside each partition by another column. Here it is customer_key.
This statement uses BigQuery words. Other engines use other words for the same idea:
Partition by date, cluster by customerworked example
The engine first picks partitions by date. Then it skips files by customer_key. The order of filters in WHERE does not matter.
People ask for a customer by customer_id, not by key. Look up the keys first. Asha has two: filter with customer_key IN (1001, 1006).
A join to the dimension does not skip files in every engine.
Watch out
Do not partition by a column with millions of values, such as customer_key. You get millions of tiny partitions and many small files. Partition by date, and cluster by the next filter.
07 · Scale
SCD2 joins at scale
⏱ 7 min · Topic 7 of 13
There are two ways to find the dimension version for an order. You can search for it in every query. Or you can store the key on the fact at load time.
The first way is a range join. It matches customer_id, then tests that the order date falls between valid_from and valid_to. Here it gives revenue by country:
The answer is the same. The work moved from every query to the load.
At 1 billion orders, this matters. Many engines join fast on equal keys.
In the range join, the equal part matches every version of the customer. The date test then drops the wrong versions. You pay for this in every query.
Range join against stored key
Question
Range join
Key on the fact
When is the version chosen?
In every query.
Once, at load time.
Work in each query
Equal key, then a date test.
One equal key.
If the version rule changes
Queries change at once.
You must reload the facts.
Interview question
How do you join a huge fact to an SCD2 dimension? Say that you store the surrogate key on the fact at load time, and keep the range join for rare checks.
08 · Scale
Summary tables and refresh
⏱ 9 min · Topic 8 of 13
A summary table stores the answer to a common question, already added up. A dashboard then reads a few rows, not 1 billion.
Here is daily_revenue. It has one row per order date, built from the paid rows of fact_orders:
daily_revenue (6 rows)
order_date
paid_orders
paid_revenue
2026-01-03
1
40.00
2026-01-10
1
25.00
2026-01-15
1
15.50
2026-01-20
1
22.00
2026-01-21
1
80.00
2026-01-22
1
30.00
The table holds counts and sums. You can add counts and sums across days. You cannot add averages, so store the parts of an average, not the average.
A dashboard query for 2026-01-10 up to 2026-01-15 reads two rows. It returns the same 40.50 as the fact table did:
One new row, for 2026-01-05. The other six rows were not touched. The total is now 224.50.
Run the DELETE and the INSERT in one transaction. A transaction is a group of steps that succeed or fail together. Then a dashboard never sees the day missing.
A MERGE into daily_revenue, without the DELETE, has a gap. If the last paid order of a day is refunded, the GROUP BY returns no row for that day. The old summary row stays.
Watch out
A summary table copies numbers from the fact table. After each refresh, compare its total with the paid total of the fact table. A difference means a refresh was missed.
09 · Change
Changes and deletes
⏱ 12 min · Topic 9 of 13
Change data capture (CDC) is a feed of every insert, update and delete in the source. You know it from the pipeline chapters.
Inserts load like the MERGE you saw before. Deletes need their own rule in the MERGE.
The op column says the kind of change: I is insert, U is update, D is delete.
This batch deletes order 105 and changes the amount of order 106 to 75.00. The new amount is made up for this chapter. Here is the batch:
Order 105 is gone, and order 106 now has 75.00. Eight rows became seven. The batch has no new order, so the insert rule did nothing.
One batch can hold several changes for one order, for example an insert and then an update. Keep only the last change per order_id, by its sequence number in the feed. Otherwise the MERGE fails, or it applies an old change.
The last change of a new order can be U. The insert rule still adds it, because it checks only that op is not D.
You can also keep the deleted row and mark it with an is_deleted flag. That is a soft delete. It keeps history, but every query must filter the flag.
A soft-deleted row still holds the data, so it is not an erasure.
Watch out
A delete or an update changes the summary table too. Refresh 2026-01-20 and 2026-01-21, the dates of orders 105 and 106. The D row has no order_date, so read the date from fact_orders before the MERGE deletes it.
Erase one person
A privacy law may require you to erase one person’s data. The rules differ by country and company, so ask your privacy team. This part shows only the modeling side.
Keep personal data in the dimension, not in the fact. Then you change one table, not every fact. Facts keep only the surrogate key and the amounts.
Ben is customer 2, key 1002. Erase the personal columns of customer 2. Use customer_id, so that every version of an SCD2 customer is covered:
Order 103 still points at key 1002 and still shows 60.00 refunded. Totals do not change.
If the source still links customer 2 to Ben, the data is only hidden, not anonymous. Your privacy team decides.
The warehouse table is only one place. Check the other places too: staging and raw files, summary tables that hold customer-level rows, backups, and time-travel history. Time travel keeps old versions until they expire.
10 · Operate
Monitor the model
⏱ 7 min · Topic 10 of 13
A load can succeed and still leave bad data. Run three checks after every load. Each one finds a different fault.
This fact table came from a bad load. It has nine rows. Order 104 is there twice, and order 121 points at key 1099:
Key 1099 is not in the dimension. This happens when facts load before their dimension rows, or when a dimension row is deleted. Loading dimensions first, with inferred members, stops the first cause.
Check 3 looks for duplicate business keys. It finds the extra row that check 1 counted:
Your model is correct, and it works at large size. The next reader is a BI (business intelligence) tool. It writes queries for people who never read your model’s rules.
⏱ 3 min · Topic 13 of 13
Next chapter
Modeling For Consumption: The BI Handoff
Your model is correct, and it works at large size. The next reader is a BI (business intelligence) tool. It writes queries for people who never read your model’s rules.
The next chapter shows what such a tool does with your model. It shows where to keep a metric so two dashboards agree, and how to retire a table that many dashboards use.