Track dimension history correctly, from overwrite to history rows to hybrids, the number-one modeling interview topic.
⏱ 33 min readTopics chapter readerLevel · Dimensional & Warehouse
01 · Orientation
What You'll Master Here
overwrite (lose history), add a row (keep full history), or add a column (keep limited history).
⏱ 4 min · Topic 1 of 13
Dimensions describe the world, and the world changes: a customer moves state, a store is re-districted, a subscriber switches tariff. Slowly changing dimensions (SCDs) are the named strategies for one question — when an attribute changes, what happens to history?
Apply each type to the same move below and watch two things move with it: the rows in dim_customer, and the answer to a Q1 question that has only one true answer. The selector opens on Type 1, the one you get by accident.
Core mental model
When a dimension attribute changes, you choose: overwrite (lose history), add a row (keep full history), or add a column (keep limited history).
Why it matters
Whether history is preserved decides if you can ever answer "what was true then?". Choosing the wrong SCD type means either losing history forever or bloating dimensions needlessly, and it is very expensive to change after data accumulates.
slowly changing dimension
A dimension whose attributes change over time, requiring a history strategy.
Type 1
Overwrite the attribute; no history is kept.
Type 2
Add a new row with effective dates; full history is preserved.
effective dating
valid_from / valid_to columns that mark when a row's values were true.
One customer, one move, six different answersCustomer C-7 bought twice in Q1 while living in NY, then moved to CA on 1 June 2026.
Type 1 · overwrite in place1 row
dim_customer after the move
customer_key
customer_id
state
501
C-7
CA
fact_sales — unchanged by any of this
sale_id
customer_id
sale_date
amount
s1
C-7
2026-02-14
40.00
s2
C-7
2026-03-10
25.00
s3
C-7
2026-07-02
60.00
The question the business asks
“What were Q1 2026 sales by state?”
The two Q1 sales total 65.00 and were both earned while C-7 lived in New York. Any answer other than NY 65.00 is wrong.What the report returns
state
q1_revenue
CA
65.00
Q1 as it was · wrong, and silently so
Where C-7 lives now · answered correctly
Q1 revenue moved to a state it was never earned inOne UPDATE replaced NY with CA, and because there is now no record that NY ever existed, both Q1 sales re-attribute to California. Nothing errored. The Q1 report that was published in April now returns a different answer, and the row that would have proved it wrong has been overwritten.
Common mistake
Overwriting dimension attributes everywhere by default. History is destroyed: past facts silently re-attribute to current attribute values, corrupting trends.
Better habit
Decide a history strategy per attribute, not per table.
Default important descriptive attributes to Type 2.
Always store the surrogate key on facts so history is queryable.
Why this one comes up so often
SCD is the most asked dimensional-modeling interview topic because it is the cheapest way to tell whether a candidate has ever had to defend a historical number. The follow-up is always the same: which type, and for which attribute?
Remember this
SCDs decide what happens to history when a dimension attribute changes; the choice (overwrite, add row, add column) determines whether you can ever report the past accurately.
02 · The problem
The Problem: When A Dimension Changes
A past fact + a changed attribute = a choice. Report it as it was (preserve history) or as it is now (overwrite)? The SCD type encodes that choice.
⏱ 4 min · Topic 2 of 13
The move itself is not the problem. The problem is that four different teams will ask about C-7 this week, and they do not all want the same answer — so there is no single correct handling of the change, only a correct handling per question.
Decide each of the four requests below before reading its verdict. Two of them legitimately want the current value; getting those wrong is as damaging as losing history, and far less discussed.
Core mental model
A past fact + a changed attribute = a choice. Report it as it was (preserve history) or as it is now (overwrite)? The SCD type encodes that choice.
Why it matters
This one scenario, a past fact tied to an attribute that later changed, underlies a huge share of "the historical numbers changed" incidents. Naming the strategy turns an accident into a controlled decision.
as-was vs as-is
Reporting by the attribute value at event time vs its current value.
history preservation
Keeping prior attribute values so the past can be reported accurately.
silent re-attribution
Past facts shifting to new attribute values because an attribute was overwritten.
Common mistake
Assuming there is one "correct" way to handle a changed attribute. You apply it everywhere; some attributes need history and some do not, the type is per-attribute.
Updating an attribute in place without realizing it rewrites history. Past reports change retroactively; the March sale moves from NY to CA with no audit trail.
Better habit
Ask "do we need to report this as-was?" for each attribute.
Make the history choice explicit before any update runs.
Treat in-place updates of descriptive attributes as a decision.
Interview note
The NY-to-CA scenario is the canonical SCD interview setup. Leading with "it depends whether we need history, which SCD type do you want?" immediately shows you understand the real question.
Remember this
A past fact whose dimension attribute later changes forces a choice, report as-was or as-is, and the SCD types are the deliberate strategies for making that choice on purpose.
03 · Types 0 & 1
Type 0 (Retain) & Type 1 (Overwrite)
Type 0 = never change it. Type 1 = overwrite, keep only the current value, accept that history re-attributes.
⏱ 4 min · Topic 3 of 13
Neither of the two simplest strategies keeps row-level history. Type 0 declares the attribute immutable and ignores any new value that arrives; Type 1 overwrites in place, so only the current value survives. Type 1 is also what a plain UPDATE does, which makes it the policy you end up with when you never chose one.
Run the zoo registry feed below under each policy. Three changes arrive together — a corrected identification, a real move, and a corrupted date — and no single table-wide answer handles all three.
Core mental model
Type 0 = never change it. Type 1 = overwrite, keep only the current value, accept that history re-attributes.
Why it matters
Type 1 is the default people reach for without thinking, and it quietly destroys history. Knowing when overwrite is appropriate (corrections, current-only attributes) and when it is dangerous is essential.
Type 0 (retain)
The attribute is fixed; new values are ignored.
Type 1 (overwrite)
Replace the value in place; no history is kept.
correction
Fixing a wrong value (a typo) where overwriting is the intended behavior.
Type 0 vs Type 1
Type 0 (Retain)
Type 1 (Overwrite)
On change
Ignore the new value
Replace with the new value
History kept
Original only (fixed)
None (current only)
Good for
Immutable facts (birth date)
Current-only attrs; corrections
Risk
Misses real updates
Silently rewrites historical reports
Common mistake
Using Type 1 for an attribute whose history matters. Past facts re-attribute to the new value and trends become wrong; use Type 2 for historical attributes.
Using Type 2 (history rows) for genuine error corrections. You preserve a wrong value as if it were real history; corrections should overwrite (Type 1).
Better habit
Use Type 0 for truly immutable attributes.
Use Type 1 only when history is irrelevant or you are correcting an error.
Confirm that re-attribution is acceptable before overwriting.
Watch out
Type 1 is the implicit behavior of a normal UPDATE. If you never chose an SCD strategy, you are silently doing Type 1, and silently losing history.
Remember this
Type 0 keeps an attribute fixed; Type 1 overwrites it, keeping only the current value, fine for corrections and current-only attributes, but it silently rewrites history.
04 · The workhorse
Type 2: Full History With New Rows
close the old row (valid_to, is_current=false) and insert a new row with a NEW surrogate key. Same business key, many versions.
⏱ 4 min · Topic 4 of 13
Type 2 preserves history by adding a row rather than changing one: the same business key gains a second version, and three columns say which version was the truth when — valid_from, valid_to, and is_current. The surrogate key differs per version, which is what lets a fact point at the version it was loaded against and stay there forever.
The insert is not the hard part. Work the three switches below one at a time: each one is a piece of bookkeeping the load has to do itself, and each missing one breaks a different query without raising an error.
Core mental model
On change: close the old row (valid_to, is_current=false) and insert a new row with a NEW surrogate key. Same business key, many versions.
Why it matters
Type 2 is how warehouses keep accurate history, the backbone of trustworthy trend reporting. It is also the most asked SCD interview topic, so knowing its exact mechanics matters.
version row
One row per historical state of an entity in a Type 2 dimension.
valid_from / valid_to
The effective date range marking when a version was the truth.
is_current
A boolean flag marking the latest version, for fast current-state queries.
Type 2 dimension: surrogate key + effective dates + current flagworked example
SQL
createtabledim_customer(customer_keybigintgeneratedalwaysasidentityprimarykey,-- surrogate, per versioncustomer_idtextnotnull,-- business key, stable across versionsstatetextnotnull,valid_fromdatenotnull,valid_todatenotnulldefaultdate'9999-12-31',is_currentbooleannotnulldefaulttrue);
Each version is its own row with its own surrogate key. customer_id ties the versions together; the date range and is_current flag mark which version was valid when.
Common mistake
Reusing the same surrogate key for the new version. Facts can no longer distinguish versions; the whole point of Type 2 (history) is lost. Each version needs a new key.
Forgetting to close the old row's valid_to / is_current. Overlapping date ranges make point-in-time joins return multiple rows and double-count.
Better habit
Mint a new surrogate key for every new version.
Close the prior row (valid_to, is_current=false) on every change.
Keep date ranges contiguous and non-overlapping per business key.
Production reality
dbt snapshots and most ELT tools implement Type 2 for you, managing valid_from/valid_to and current flags. Knowing the mechanics lets you configure and debug them instead of trusting magic.
Practice in the studio
Model dim_customer with surrogate key, valid_from/valid_to, and is_current alongside fact_sales referencing the surrogate. Confirm the fact points to the version key, not the business key.
Type 2 preserves full history by adding a new row with a new surrogate key and effective dates on each change; facts hold the key current at event time, so the past never moves.
05 · Types 3, 4, 5, 6 & 7
Types 3–7: Variations & Hybrids
Type 3 = one prior column. Type 4 = current table + history table. Type 6 = Type 2 rows plus a current (Type 1) column. Type 5 = mini-dim with a Type 1 outrigger key. Type 7 = historical surrogate + durable key on the fact.
⏱ 6 min · Topic 5 of 13
The remaining types rearrange the same information rather than adding new ideas. Type 3 adds a previous-value column; Type 4 keeps the current version in the main table and pushes history to a side table; Type 6 is a Type 2 dimension that also carries a Type 1 current-value column, and optionally the Type 3 one — hence the arithmetic in the name, 1 + 2 + 3.
Build that hybrid a column at a time below, and leave the third switch off first: a Type 1 column inside a Type 2 dimension has to be rewritten on every version, and a stale one answers rather than errors.
Types 5 and 7 move information across tables instead. Type 5 adds a mini-dimension reached by a Type 1 outrigger key (the next section covers it in production depth). Type 7 puts both the Type 2 surrogate and a stable durable key on the fact: as-was joins the surrogate, as-is joins a current-only view on the durable key, and no fact row is ever updated.
Core mental model
Type 3 = one prior column. Type 4 = current table + history table. Type 6 = Type 2 rows plus a current (Type 1) column. Type 5 = mini-dim with a Type 1 outrigger key. Type 7 = historical surrogate + durable key on the fact.
Why it matters
Real requirements are often "keep history AND let me re-state everything by the current value." Type 6 delivers exactly that, and knowing Types 3 and 4 lets you pick the lightest tool when full Type 2 is more than you need. Types 5 and 7 solve specific performance problems that Type 2 alone creates at scale.
Type 6
Hybrid (1+2+3): Type 2 rows plus an overwritten current-value column.
outrigger key
A Type 1 pointer on the main dimension that always references the current mini-dimension row.
durable key
A stable identifier shared by every version of an entity — the as-is join key in Type 7.
Type 5: mini-dim outrigger key on the main dimensionworked example
SQL
-- dim_customer_segment is a mini-dim with its own surrogate key.-- dim_customer carries a Type 1 outrigger key pointing to the CURRENT mini-dim row.-- The fact joins to the main dim (historical) AND to the mini-dim (current segment).createtabledim_customer_segment(segment_keyintprimarykey,income_bandtextnotnull,-- e.g. 'Low', 'Mid', 'High'risk_tiertextnotnull);createtabledim_customer(customer_keybigintprimarykey,-- per-version (Type 2)customer_idtextnotnull,statetextnotnull,current_seg_keyintreferencesdim_customer_segment,-- Type 1 outriggervalid_fromdatenotnull,valid_todatenotnull,is_currentbooleannotnull);
Type 5 separates fast-changing attributes into dim_customer_segment. The main dim holds a Type 1 pointer (current_seg_key) for current-segment queries; the fact joins the mini-dim directly for historical-segment queries.
Type 7: dual keys on the fact enable both query patternsworked example
SQL
-- The fact stores TWO keys, and neither is ever updated:-- customer_hist_key = the Type 2 surrogate that was-- current when the event happened (as-was).-- customer_durable_key = the stable durable key, identical-- on every version of the customer (as-is).createtablefact_sales(sale_keybigintprimarykey,customer_hist_keybigintnotnull-- as-was joinsreferencesdim_customer(customer_key),customer_durable_keybigintnotnull,-- as-is joinsamountnumeric(10,2)notnull);-- As-was: join dim on customer_hist_key.-- As-is: join a CURRENT-ONLY view (or is_current = true)-- on customer_durable_key.
Type 7 keeps both keys on the fact — and because the durable key is stable, no fact row is ever touched when the dimension changes. The as-is join resolves to one row because the current-only view (or is_current filter) carries the constraint, so queries need no per-fact date predicate.
Types 3, 4, 5, 6 and 7
Type
Mechanism
History kept
Use when
Type 3
Add a previous-value column
One prior value
Compare current vs prior
Type 4
Current table + history table
Full, in a side table
Current reads dominate
Type 5
Mini-dim + Type 1 outrigger key
Full (main) + current (mini)
High-churn attributes
Type 6
Type 2 rows + current column (1+2+3)
Full + current view
Need both as-was and as-is
Type 7
Hist surrogate + durable key
Full (no date predicate)
Both views at large scale
Common mistake
Using Type 3 when you actually need a full timeline. You can only see one prior value; older history is overwritten and lost. Use Type 2 for full history.
Adding a current-value column (Type 6) but forgetting to overwrite it on ALL versions. Old rows show a stale "current" value and as-is reports become wrong; the current column must update across every version.
Better habit
Use Type 3 only for a known single prior-value comparison.
Use Type 4 when current-state reads dominate but audit is required.
Use Type 5 to isolate fast-changing attributes rather than letting them explode the main Type 2 dim.
Use Type 6 when you need both historical and current-value reporting.
Use Type 7 only when serving both as-was and as-is at a scale where per-fact date predicates hurt — the current-only view carries the constraint instead.
Why "6"
Type 6 literally combines Types 1, 2, and 3 (1+2+3=6): overwrite a current column, keep history rows, and optionally a previous-value column. The name is the recipe.
Types 5 and 7 in interviews
Interviewers at staff level ask: "How would you avoid Type 2 version explosion on a rapidly-changing attribute?" Type 5 (mini-dim) is the answer. Type 7 ("dual keys on the fact") signals familiarity with query-pattern-driven key design.
Remember this
Type 3 keeps one prior column; Type 4 splits current from a history table; Type 6 combines Type 2 history with a Type 1 current column. Type 5 separates fast-changing attributes into a mini-dim; Type 7 adds the durable key to the fact for date-predicate-free current and historical queries.
06 · Scale edge
Mini-Dimensions & Type-2 Explosion
Slow attributes (state, name) live in the main Type 2 dim. Fast attributes (segment, tier) live in a mini-dim. The fact joins both when it needs history on either.
⏱ 4 min · Topic 6 of 13
Type 2 costs one row per change, so the size of a dimension is its population multiplied by the total churn of everything inside it. That is fine for a home address and ruinous for a score the model rewrites every week — and the two attributes sit in the same table, so the fast one sets the price.
The fix is a mini-dimension: move the fast-churning attributes into their own small lookup of distinct combinations, store its key on the fact at load time, and keep a Type 1 outrigger key on the main dimension for current-state reads. Move the attributes below one at a time and watch which one was actually paying for the explosion.
Core mental model
Slow attributes (state, name) live in the main Type 2 dim. Fast attributes (segment, tier) live in a mini-dim. The fact joins both when it needs history on either.
Why it matters
Unchecked Type-2 explosion on high-churn attributes is a production performance problem that is expensive to fix after history accumulates. Recognizing which attributes are rapidly-changing and separating them early keeps the main dimension lean and queries fast.
version explosion
Rapid growth of Type 2 dimension rows caused by high-churn attributes.
mini-dimension
A small dimension holding rapidly-changing attributes, versioned independently of the main dim.
outrigger key
A Type 1 foreign key on the main dimension pointing to the current mini-dim row.
The mini-dim split: main dim holds state only, mini-dim holds segmentworked example
SQL
Input data
dim_customer (with mini-dim) — customer 7, year 20262 rows
customer_key
customer_id
state
curr_seg_key
valid_from
valid_to
501
7
NY
3
2026-01-01
2026-05-31
777
7
CA
4
2026-06-01
9999-12-31
Only 2 rows: one per state change. curr_seg_key is a Type 1 outrigger updated on every segment change.
dim_customer_segment (shared mini-dim)4 rows
segment_key
segment
income_band
1
Silver
Low
2
Silver
Mid
3
Gold
Mid
4
Gold
High
Small set of distinct combinations. Many customers share these rows.
fact_sales2 rows
customer_key
segment_key
amount
501
2
40.00
777
4
60.00
Each fact stores the mini-dim key AS OF the sale, so segment history rides on the fact — the Kimball mini-dimension pattern.
-- dim_customer_segment: small table of distinct attribute combinations.-- dim_customer: only slow-changing attributes (state).-- fact_sales: joins dim_customer (historical state) AND dim_customer_segment-- (segment at sale time, stored as segment_key on the fact).-- dim_customer now: 2 rows for customer 7 (one state change)-- dim_customer_segment: shared lookup with a handful of distinct rowsselectdc.state,dcs.income_band,sum(f.amount)asrevenuefromfact_salesfjoindim_customerdcondc.customer_key=f.customer_keyjoindim_customer_segmentdcsondcs.segment_key=f.segment_keygroupbydc.state,dcs.income_band;
Result · 2 rows
state
income_band
revenue
NY
Mid
40.00
CA
High
60.00
Historical state from dim_customer; segment at sale time from the mini-dim key stored on the fact.
The main dimension versions on state alone, and the segment the customer held at sale time is carried by the mini-dim key on the fact rather than by a version row.
Main dim vs mini-dim: which attributes belong where
Attribute type
Change frequency
Put in
Why
Home state, birth year
Rare (0–2/year)
Main dim (Type 2)
Low churn; version rows are few
Segment, income band
Frequent (monthly)
Mini-dim
High churn; avoid explosion
Name, email
Occasional
Main dim (Type 1)
Correction; no history needed
Common mistake
Adding high-churn attributes directly to the main Type 2 dimension. Dimension rows multiply with every attribute change; a 10M-customer dim can grow to hundreds of millions of rows, slowing every query.
Forgetting to store the mini-dim key on the fact at load time. Historical segment queries must fall back to expensive range joins; capturing the key at event time is the whole point.
Better habit
Identify high-churn attributes before building a Type 2 dim.
Route them into a mini-dim and store the mini-dim key on the fact at load time.
Keep the outrigger key on the main dim as a Type 1 pointer for current-state lookups.
Real-world signal
Retail and financial services warehouses routinely separate customer segment, risk tier, and loyalty level into mini-dims precisely because these attributes drive campaigns and change frequently. The main customer dim stays at one or two rows per customer per year.
Senior interview probe
A strong answer to "how do you handle a Type 2 dim where segment changes weekly?" names the mini-dim pattern, explains the outrigger key, and confirms that the fact stores the segment key at event time.
Remember this
When high-churn attributes are added to a Type 2 dim they cause version explosion; extract them into a mini-dimension, store the mini-dim key on the fact at load time, and keep a Type 1 outrigger key on the main dim for current-state lookups.
07 · Inferred members
Late-Arriving Dimensions
Dim row missing → insert a placeholder with the business key + is_inferred flag. Fact points to the placeholder surrogate. When the real dim row arrives, backfill attributes in place. The surrogate never changes.
⏱ 4 min · Topic 7 of 13
A fact can arrive before its dimension row exists — a real-time event stream against a source that exports its master data nightly. Waiting is not an option and neither is dropping the row, so the load has to invent something to point at.
The answer is an inferred member: a placeholder row carrying only the business key and is_inferred = true, backfilled in place when the real data lands. Judge the four handlings below on both days, because two of them look identical tonight and diverge tomorrow. (Chapter 10 owns the mirror case, a late fact against a dimension whose history already exists.)
Core mental model
Dim row missing → insert a placeholder with the business key + is_inferred flag. Fact points to the placeholder surrogate. When the real dim row arrives, backfill attributes in place. The surrogate never changes.
Why it matters
Without an inferred member strategy, late dimension rows either drop facts (NULL FK joins silently exclude rows) or delay the entire fact load. Either outcome corrupts completeness. The placeholder pattern keeps fact loads unblocked while the dimension catches up.
inferred member
A placeholder dimension row created when a fact arrives before its dimension data, identified by is_inferred = true.
retroactive SK resolution
Backfilling dimension attributes into an inferred placeholder; the surrogate key is unchanged so facts resolve automatically.
late-arriving dimension
A case where the dimension row itself has not arrived when the referencing fact is loaded.
Inferred member: insert placeholder, load fact, backfill laterworked example
SQL
Input data
dim_customer: placeholder inserted (Step 1)1 row
customer_key
customer_id
state
is_current
is_inferred
999
42
Pending
true
true
Placeholder row: business key known, attributes unknown. is_inferred flags it for monitoring.
dim_customer: after backfill (Step 3)1 row
customer_key
customer_id
state
is_current
is_inferred
999
42
TX
true
false
Same surrogate key (999). Attributes updated in place. The fact row already points here and now resolves correctly.
-- Step 1: fact arrives; customer 42 is not in dim_customer yet.-- Insert an inferred placeholder to unblock the fact load.insertintodim_customer(customer_id,state,valid_from,valid_to,is_current,is_inferred)values('42','Pending',current_date,date'9999-12-31',true,true);-- Returns surrogate key 999 (auto-generated).-- Step 2: load the fact pointing to the placeholder key 999.insertintofact_sales(customer_key,amount,sale_date)values(999,79.99,'2026-06-15');-- Step 3: real customer row arrives in the nightly batch.-- Backfill attributes in place (Type 1 — it was always true, we just did not know).updatedim_customersetstate='TX',is_inferred=falsewherecustomer_id='42'andis_inferred;-- Surrogate 999 is unchanged. The fact now resolves to real attributes.
The surrogate key never changes across the three steps. The fact loads immediately against the placeholder, and the retroactive backfill requires no changes to the fact table.
Common mistake
Leaving the dimension FK as NULL when the dim row is missing. NULL FKs are silently dropped from grouped queries; revenue totals shrink without warning (the Unknown row rule from Chapter 10 prevents this).
Inserting a new Type 2 version row during the backfill instead of updating in place. You create a spurious second version (the placeholder becomes a historical "Unknown" state); the fact's original surrogate still points to the placeholder, not the backfilled row.
Better habit
Add an is_inferred column to every Type 2 dim that can have late-arriving rows.
Monitor inferred member count; a growing backlog signals a pipeline timing issue.
Backfill placeholders with a Type 1 update, not a new Type 2 version row.
Inferred vs Unknown
The Unknown row (Chapter 10) handles facts whose dimension entity is genuinely unresolvable. The inferred member handles a known entity whose attributes have not arrived yet. Use the Unknown row for permanents; use is_inferred for temporaries.
Interview pairing
Chapter 10 owns late-arriving facts (event is late, dim history exists). This chapter owns late-arriving dimensions (dim row has not arrived yet). Distinguishing the two in an interview answer shows you understand load-order dependencies.
When a fact arrives before its dimension row, insert an inferred placeholder with the business key and is_inferred = true; load the fact against that surrogate; backfill attributes in place when the real data arrives. The surrogate key never changes, so no fact-table re-processing is needed.
08 · Querying
Querying SCDs: Current vs Point-In-Time
Current = is_current. As-of = date between valid_from and valid_to. Fact join = use the surrogate key the fact already stored (no date logic needed).
⏱ 4 min · Topic 8 of 13
Reading a Type 2 dimension on its own is easy: filter is_current for now, or filter a date between valid_from and valid_to for then. The example below shows both against the same two rows.
Joining facts to it is where the money is lost. Four predicates are offered below against a dimension that is perfectly correct — two of them return wrong numbers, and one of the two wrong ones is the fix people reach for first.
Core mental model
Current = is_current. As-of = date between valid_from and valid_to. Fact join = use the surrogate key the fact already stored (no date logic needed).
Why it matters
Storing the right key on the fact and knowing the three read patterns (current, as-of, key-join) is what makes Type 2 history actually usable. Get this wrong and you either report the past as the present or write fragile date logic everywhere.
current query
Filtering to is_current (or the open valid_to) for the present state.
point-in-time query
Filtering where a date falls in [valid_from, valid_to] for a past state.
surrogate key join
Joining a fact to the dimension version current at event time, via the stored key.
Current vs point-in-time reads of the same dimensionworked example
SQL
-- CURRENT: what is true now?selectcustomer_id,statefromdim_customerwherecustomer_id='7'andis_current;-- CA-- POINT-IN-TIME: what was true on a past date?selectcustomer_id,statefromdim_customerwherecustomer_id='7'anddate'2026-03-10'betweenvalid_fromandvalid_to;-- NY
Result
query
state
current (is_current)
CA
as-of 2026-03-10
NY
Same dimension, two truths: CA now, NY in March. The date-range filter reconstructs the historical version.
The current flag answers "now"; the valid_from/valid_to range answers "then". Both read the same Type 2 table.
Common mistake
Joining facts to the dimension on the business key (customer_id). You match all versions and double-count; facts must join on the version-specific surrogate key.
Re-deriving history with date logic when the fact already holds the right key. Fragile, slow queries; the stored surrogate key gives correct history with a plain join.
Better habit
Filter is_current for "now", date-range for "as of then".
Always join facts to dimensions on the surrogate key.
Capture the current surrogate key on the fact at load time.
Interview note
The killer detail: "the fact stores the surrogate key that was current at event time, so a normal join gives correct history." Saying that proves you understand why Type 2 uses surrogate keys at all.
The shift
History is not reconstructed at query time, it is captured at load time by storing the right key on the fact. The query then stays simple.
Remember this
Read Type 2 three ways, is_current for now, date-range for as-of, and (best) a surrogate-key join from facts that captured the version current at event time, which gives correct history with a plain join.
09 · Invariants
Integrity Invariants & dbt Snapshots
Gap-free + overlap-free + consistent boundary convention = a correct Type 2 dim. Two current rows = the single most dangerous silent bug. Retroactive changes require interval splitting, not a new append.
⏱ 6 min · Topic 9 of 13
Three invariants make a Type 2 dimension trustworthy, and each needs its own assertion: gap-free (no period is unaccounted for), overlap-free (no two versions of a key share a day), and at most one is_current row per key. Audit the bus fleet below — it holds one instance of each defect, and every one of them passes the other two checks.
The fourth rule is a convention: pick one interval style and never mix. This chapter uses closed intervals [valid_from, valid_to], so valid_to is the last day the version applies, adjacent versions read ...03-14 / 03-15..., and point-in-time queries use BETWEEN. Chapter 15 uses the half-open alternative where valid_to equals the next valid_from and queries use >= and <.
The last hazard is timing: a retroactive correction cannot be appended, because the true value changed inside an interval that already exists. It has to split that interval, as the worked example below does.
Core mental model
Gap-free + overlap-free + consistent boundary convention = a correct Type 2 dim. Two current rows = the single most dangerous silent bug. Retroactive changes require interval splitting, not a new append.
Why it matters
The three invariants are the correctness contract for any Type 2 dimension. Violating gap-free causes missing history; violating overlap-free causes double-counts; violating the boundary convention causes silent wrong results. Naming these invariants is what separates a senior SCD design from an accidental one.
gap-free invariant
No un-accounted time period exists between the first valid_from and the present for a given business key.
overlap-free invariant
No two version rows for the same business key have overlapping date ranges.
two current rows
The failure mode where two rows share is_current = true for the same business key, causing duplicates.
Retroactive change: split an existing intervalworked example
SQL
Input data
dim_customer BEFORE split2 rows
customer_key
customer_id
state
valid_from
valid_to
is_current
501
7
NY
2026-01-01
2026-05-31
false
777
7
CA
2026-06-01
9999-12-31
true
Original record: one NY version, then CA. The retroactive TX correction falls inside the NY interval.
dim_customer AFTER split3 rows
customer_key
customer_id
state
valid_from
valid_to
is_current
501
7
NY
2026-01-01
2026-03-14
false
802
7
TX
2026-03-15
2026-05-31
false
777
7
CA
2026-06-01
9999-12-31
true
Three rows, no gaps, no overlaps. Sales between Mar 15–May 31 now correctly resolve to TX.
-- A correction arrives: customer 7 moved to TX on 2026-03-15,-- not on 2026-06-01 as recorded. The NY row must be split.-- Before: one NY row for customer 7.-- customer_key=501, state='NY', valid_from='2026-01-01', valid_to='2026-05-31'-- Step 1: Shrink the existing NY row to end just before the correction date.updatedim_customersetvalid_to=date'2026-03-14'wherecustomer_key=501;-- Step 2: Insert the TX intermediate version.insertintodim_customer(customer_id,state,valid_from,valid_to,is_current)values('7','TX',date'2026-03-15',date'2026-05-31',false);
Interval splitting requires updating the boundary of the existing row and inserting a new one. The gap-free and overlap-free invariants must be verified after every split.
dbt snapshot strategies: check vs timestampworked example
The check strategy missed-columns bug is the most common dbt snapshot mistake: any column omitted from check_cols can change without triggering a new snapshot version. Audit your check_cols list whenever the source schema changes.
dbt snapshot: check vs timestamp
timestamp strategy
check strategy
Requires
updated_at in source
No updated_at needed
Detects
Any row with newer updated_at
Changes in check_cols only
Silent miss
Changes not bumping updated_at
Columns not in check_cols
Best for
Sources with reliable updated_at
Sources without updated_at
NULL end-date vs 9999-12-31 sentinel
NULL end-date
9999-12-31 sentinel
Semantics
"No end" — self-documenting
"Far future" — inferred
BETWEEN works?
No — NULL fails comparison
Yes — uniform predicate
IS NULL test
Required for current filter
Not needed
Choose when
Team prefers semantic clarity
Team wants uniform SQL
Common mistake
Omitting a column from dbt snapshot check_cols. That column changes silently and the change is never captured; add all slowly-changing columns or switch to timestamp strategy.
Applying a dbt snapshot to get Type 3, 4, or 6 behavior. dbt snapshots only implement Type 2 (untracked columns are not versioned; their latest value rides along when a tracked change occurs). Type 3, 4, and 6 require custom SQL on top of the snapshot output.
Handling a retroactive change as a new append instead of an interval split. Gaps appear in the timeline; the gap-free invariant is violated and facts from the gap period resolve to the wrong version or nothing.
Better habit
Add a uniqueness constraint or dbt test on (business_key) WHERE is_current to prevent two current rows.
Verify gap-free and overlap-free invariants after every batch load with a lightweight assertion query.
Audit dbt snapshot check_cols every time the source schema changes.
Document the boundary convention (NULL vs 9999-12-31) in the table's data contract.
Two current rows: the silent killer
A defective SCD2 load that creates two is_current = true rows for the same customer doubles every metric for that customer. The query succeeds; the number looks plausible. A dbt test on uniqueness of customer_id among current rows (a partial unique index WHERE is_current) catches this in CI.
dbt snapshot limits
dbt snapshots give you Type 2 cheaply, but they have no built-in support for Type 3, Type 4, or Type 6. Teams that need those patterns build them on top of the snapshot output with additional dbt models. For load mechanics and write strategies, see the data-pipeline loading and write patterns chapter.
A correct Type 2 dimension is gap-free, overlap-free, and uses a consistent boundary convention. The two-current-rows failure mode is the most dangerous silent bug and must be caught by a constraint or test. Retroactive corrections require interval splitting, not new appends. dbt snapshots implement Type 2 but miss columns not listed in check_cols and have no built-in Types 3, 4, or 6.
10 · Applied method
Choosing The Right SCD Type
never changes → 0; history irrelevant/correction → 1; need history → 2; one prior value → 3; both views → 6; fast-churn → mini-dim (5). When unsure, choose 2.
⏱ 4 min · Topic 10 of 13
The applied skill is choosing per attribute, not per table. Work through the six attributes of dim_customer below: the readout on the right prices each decision in version rows, and the panel under each attribute says what the wrong choice costs.
It opens with everything on Type 1, because that is what a table with no declared policy actually is. Four different policies end up in this one dimension.
Core mental model
Per attribute: never changes → 0; history irrelevant/correction → 1; need history → 2; one prior value → 3; both views → 6; fast-churn → mini-dim (5). When unsure, choose 2.
Why it matters
Choosing per attribute, and defaulting important ones to Type 2, is what separates a dimension that can answer future historical questions from one that quietly threw the answers away. This decision is hard to reverse after data accumulates.
per-attribute policy
Choosing an SCD type for each attribute, not one type for the whole table.
default to Type 2
Preferring full history for meaningful descriptive attributes when unsure.
irreversibility
Once overwritten, history cannot be recovered, which biases toward keeping it.
Common mistake
Applying one SCD type to an entire dimension. You either over-version trivial fields or lose history on important ones; choose per attribute.
Defaulting important descriptive attributes to Type 1 to "keep it simple". You discover later you needed history and it is gone forever; default meaningful attributes to Type 2.
Better habit
Assign an SCD type to each attribute deliberately.
Default meaningful descriptive attributes to Type 2.
Bias toward keeping history; it cannot be recovered once dropped.
Flag high-churn attributes for a mini-dim review before the first load.
Interview note
Strong close to any SCD question: "I would set the type per attribute, name as Type 1, state as Type 2, birth date as Type 0, and default anything historically meaningful to Type 2 since lost history is unrecoverable."
Practice the full dim in the studio
Model dim_customer with surrogate key, valid_from/valid_to, is_current, and a FK to dim_customer_segment as a mini-dim. Confirm fact_sales references the surrogate, not the business key.
Choose the SCD type per attribute, never changes → 0, correction/current-only → 1, history → 2, one prior → 3, both views → 6, fast-churn → mini-dim (5), and default meaningful attributes to Type 2 because lost history is gone for good.
11 · Recap
SCD Type Reference & Recap
Type → row behavior → invariant check → load guard. That four-step mental checklist covers design, implementation, and monitoring of any SCD strategy.
⏱ 4 min · Topic 11 of 13
Every type leaves a different fingerprint in the rows, which is why an interviewer can hand you a table and ask what it is. Run the chapter backwards below: five dimensions after a change, named from the rows alone.
The tables underneath are the reference to come back to — mechanism, history kept, and the mistakes that cost the most.
Core mental model
Type → row behavior → invariant check → load guard. That four-step mental checklist covers design, implementation, and monitoring of any SCD strategy.
Why it matters
SCDs are the single most asked dimensional modeling interview topic and the source of most "the historical numbers changed" production incidents. Mastering the full range from selection to integrity invariants is the staff-level bar.
version explosion
Rapid growth of Type 2 rows caused by high-churn attributes added to the main dim.
inferred member
A placeholder dim row for a fact whose dimension data has not yet arrived.
interval splitting
Splitting an existing version row to accommodate a retroactive backdated correction.
Core types (0–3)
Type
Mechanism
History kept
0
Retain original, ignore updates
Original only
1
Overwrite in place
None (current only)
2
New row per change + effective dates
Full history
3
Add a previous-value column
One prior value
Advanced types (4–7)
Type
Mechanism
History kept
4
Current table + history table
Full (side table)
5
Mini-dim + Type 1 outrigger key
Full (no explosion)
6
Type 2 rows + Type 1 current column
Full + current view
7
Dual hist + curr keys on fact
Full (no predicate)
Top SCD mistakes and consequences
Mistake
Consequence
Type 1 on a historical attr
Irreversible history loss
Same surrogate on new version
Facts cannot distinguish versions
Overlapping date ranges
Double-count on point-in-time joins
Two is_current = true rows
Silent metric doubling
Missing is_inferred column
Placeholders never get backfilled
check_cols missing a column
dbt snapshot silently misses changes
Common mistake
Treating SCD as a single table-level choice. Over-versioning trivial fields bloats the dim; under-versioning important ones destroys history. Choose per attribute.
Shipping a Type 2 dim without a gap-free / overlap-free assertion. A defective load silently produces wrong query results; add the assertion to CI.
Better habit
Document SCD type per attribute in the data contract.
Add a uniqueness constraint on (business_key) WHERE is_current.
Run gap-free and overlap-free assertions as part of the daily pipeline.
Review dbt snapshot check_cols on every source schema change.
Final interview checklist
Cover these six points for a complete SCD answer: (1) per-attribute policy; (2) Type 2 mechanics (close old row, new surrogate, new key); (3) three invariants; (4) mini-dim for high-churn; (5) inferred member for late dims; (6) dbt snapshot limits.
Remember this
SCD mastery is per-attribute policy selection backed by structural invariants and load-time guards. Types 0–7 are all available; Type 2 is the default for anything that matters historically.
12 · 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 12 of 13
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.
13 · Next Chapter
Next Chapter
You now have the full Kimball dimensional toolkit: stars, snowflakes, fact design, and slowly changing dimensions. The next question is architectural: how should the whole warehouse be organized?
⏱ 3 min · Topic 13 of 13
Next chapter
Inmon vs Kimball vs Data Vault
You now have the full Kimball dimensional toolkit: stars, snowflakes, fact design, and slowly changing dimensions. The next question is architectural: how should the whole warehouse be organized?
Chapter 12 compares the three enterprise philosophies, Inmon's normalized corporate warehouse, Kimball's dimensional bus, and Data Vault, and gives you a clear map of when each one fits.