Skip to the page
Tecyfy

Slowly changing dimension type 2 in SQL: valid_from, valid_to and the join

A slowly changing dimension type 2 keeps 1 row per version of a member, with the time it became true (valid_from), the time it stopped (valid_to) and a flag on the current one. Build it from a change log with lead() over each member's changes, and join facts with valid_from <= the event's time < valid_to, so each event meets exactly 1 version. Start each member's first version early enough for its oldest event, or that event matches nothing and an inner join drops it.

Module 1: SQL and data modelling for analytics

At the end of this part you can:

  • Say when a dimension needs type 1 and when type 2
  • Build a type 2 dimension from a change log with lead(), and join facts to the version true at the time
  • Find the facts that match no version or more than 1, and stop the build with assert_true
  • Give each version a key that stays the same when the table is rebuilt

Take this module into your AI

Open in ChatGPT (new tab)Open in Claude (new tab)

Part 2 built a customer dimension that keeps only the newest row, and its report put Ana's Portuguese orders in Spain. That table is a slowly changing dimensionSlowly Changing DimensionsThe ways a warehouse handles a change to a descriptive record, such as a customer's address: type 1 overwrites it, type 2 adds a new dated version, and type 3 keeps the previous value in an extra column. of type 1. This part builds type 2, which keeps the past, and goes through the 3 ways it goes wrong. Add the queries to scd.sql and run python run_sql.py setup.sql star.sql scd.sql.

Type 1 or type 2

Customers change slowly: a new address, a new name, a new tier. Kimball names the ways a dimension can deal with that.

  • Type 1 overwrites. The row always holds the current value and history is gone. Right for corrections, such as a misspelt name, and for columns no report looks at over time.
  • Type 2 adds a row. Each change becomes a new version with the time it started and ended, and each fact joins to the version true when it happened. Right for anything a report groups by over time, such as the country revenue is booked to.

Type 1 by country, from part 2's table:

SELECT c.country, sum(f.amount) AS revenue
FROM fact_orders f JOIN dim_customer_now c ON f.customer_id = c.customer_id
GROUP BY c.country ORDER BY c.country;
+-------+-------+
|country|revenue|
+-------+-------+
|DE     |75.00  |
|ES     |95.50  |
|IE     |53.00  |
+-------+-------+

Portugal is missing. Ana's orders of 1 and 2 October, 65.50 in all, were placed while she lived there.

Give each version its dates

Each change starts a version, and the customer's next change ends it. lead() reads the next row in a window, so it finds the end:

CREATE OR REPLACE TEMP VIEW dim_customer_v1 AS
SELECT customer_id, name, country,
       changed_at AS valid_from,
       lead(changed_at) OVER (PARTITION BY customer_id ORDER BY changed_at) AS valid_to
FROM customer_changes;

SELECT * FROM dim_customer_v1 ORDER BY customer_id, valid_from;
+-----------+----+-------+-------------------+-------------------+
|customer_id|name|country|valid_from         |valid_to           |
+-----------+----+-------+-------------------+-------------------+
|C01        |Ana |PT     |2026-09-01 08:00:00|2026-10-03 09:30:00|
|C01        |Ana |ES     |2026-10-03 09:30:00|NULL               |
|C02        |Ben |IE     |2026-09-15 10:00:00|NULL               |
|C03        |Chen|DE     |2026-10-02 12:00:00|NULL               |
+-----------+----+-------+-------------------+-------------------+

Now join each order to the version true when it was placed: the same customer, and an order time from the version's start up to, but not including, its end.

SELECT c.country, sum(f.amount) AS revenue
FROM fact_orders f
JOIN dim_customer_v1 c
  ON f.customer_id = c.customer_id
 AND f.ordered_at >= c.valid_from
 AND (f.ordered_at < c.valid_to OR c.valid_to IS NULL)
GROUP BY c.country ORDER BY c.country;
+-------+-------+
|country|revenue|
+-------+-------+
|DE     |15.00  |
|ES     |30.00  |
|IE     |53.00  |
|PT     |65.50  |
+-------+-------+

Portugal is back, and Spain has only the order Ana placed after she moved. But Germany has 15.00, not 75.00.

The first version starts too late

Chen's order 1003 was placed at 11:30 on 2 October, and the shop's system saved Chen's record at 12:00. It happens all the time: a record is saved after the checkout, loaded late, or the history starts on the day the system was switched on. No version is true at 11:30, so the inner join drops the order without a word. A left join that counts the matches finds it:

SELECT f.order_id, f.customer_id, f.ordered_at, count(c.customer_id) AS versions
FROM fact_orders f
LEFT JOIN dim_customer_v1 c
  ON f.customer_id = c.customer_id
 AND f.ordered_at >= c.valid_from
 AND (f.ordered_at < c.valid_to OR c.valid_to IS NULL)
GROUP BY f.order_id, f.customer_id, f.ordered_at
HAVING count(c.customer_id) <> 1;
+--------+-----------+-------------------+--------+
|order_id|customer_id|ordered_at         |versions|
+--------+-----------+-------------------+--------+
|1003    |C03        |2026-10-02 11:30:00|0       |
+--------+-----------+-------------------+--------+

The usual fix is to start each customer's first version long ago, at 1900-01-01. It assumes the first values you know were also true before you knew them, which is nearly always a better guess than losing the order. The same table also ends the current version at 9999-12-31 rather than NULL, so every version has an end and the join is 1 plain range, and it marks the current version, so a query for today's state needs no dates:

CREATE OR REPLACE TEMP VIEW dim_customer AS
SELECT xxhash64(customer_id, changed_at) AS customer_key,
       customer_id, name, country,
       CASE WHEN row_number() OVER w = 1 THEN TIMESTAMP'1900-01-01 00:00:00'
            ELSE changed_at END AS valid_from,
       coalesce(lead(changed_at) OVER w, TIMESTAMP'9999-12-31 00:00:00') AS valid_to,
       lead(changed_at) OVER w IS NULL AS is_current
FROM customer_changes
WINDOW w AS (PARTITION BY customer_id ORDER BY changed_at);

SELECT * FROM dim_customer ORDER BY customer_id, valid_from;
+--------------------+-----------+----+-------+-------------------+-------------------+----------+
|customer_key        |customer_id|name|country|valid_from         |valid_to           |is_current|
+--------------------+-----------+----+-------+-------------------+-------------------+----------+
|-5876299405855916264|C01        |Ana |PT     |1900-01-01 00:00:00|2026-10-03 09:30:00|false     |
|-6129443583856869281|C01        |Ana |ES     |2026-10-03 09:30:00|9999-12-31 00:00:00|true      |
|1907290492647614057 |C02        |Ben |IE     |1900-01-01 00:00:00|9999-12-31 00:00:00|true      |
|-921504631688345634 |C03        |Chen|DE     |1900-01-01 00:00:00|9999-12-31 00:00:00|true      |
+--------------------+-----------+----+-------+-------------------+-------------------+----------+

WINDOW w AS (...) names the window once for the 3 functions that use it. The same join, now without the NULL case:

SELECT c.country, sum(f.amount) AS revenue
FROM fact_orders f
JOIN dim_customer c
  ON f.customer_id = c.customer_id
 AND f.ordered_at >= c.valid_from
 AND f.ordered_at < c.valid_to
GROUP BY c.country ORDER BY c.country;
+-------+-------+
|country|revenue|
+-------+-------+
|DE     |75.00  |
|ES     |30.00  |
|IE     |53.00  |
|PT     |65.50  |
+-------+-------+

Every order is counted once, in the country its customer lived in at the time. Joining with c.is_current instead gives back the type 1 answer from the same table, so 1 table serves both kinds of report. Try both tables and both starts in the demo.

Try itJoin orders to the customer as they were

dim_customer

customercountryvalid_fromvalid_to
C01PT2026-09-01 08:002026-10-03 09:30
C01ES2026-10-03 09:309999-12-31
C02IE2026-09-15 10:009999-12-31
C03DE2026-10-02 12:009999-12-31

orders, joined

order_idordered_atamountcountry
10012026-10-01 09:1525.50PT
10022026-10-01 10:0212.00IE
10032026-10-02 11:3060.00Dropped
10042026-10-02 12:4540.00PT
10052026-10-02 16:2018.25IE
10062026-10-04 08:0530.00ES
10072026-10-04 13:4015.00DE
10082026-10-05 19:1022.75IE

Revenue by the country at the time of the order

  • DE15.00Should be 75.00
  • ES30.00Right
  • IE53.00Right
  • PT65.50Right

Type 2 keeps each version with the time it started and ended, and the join picks the version that was current when the order was placed. But Chen's first version starts at 12:00 on 2 October, after order 1003 at 11:30, so the order matches no version and the inner join drops it: DE shows 15.00, not 75.00.

The module's 8 orders and 4 customer changes. The revenue for each choice is what Spark 4.2 returned.

Half-open ranges, so nothing matches twice

The join uses >= at the start and < at the end. Ana's second version starts at 09:30 on 3 October, the instant her first one ends. With <= at the end as well, an order placed at exactly 09:30 would match both versions and be counted twice. With the end left open, it matches only the new one. Write every range of times this way.

A key that survives a rebuild

A fact table could store the customer's version key when the order is loaded, so reports join on 1 key, as Kimball's type 2 does. The key needs a property that is easy to miss: the same version must get the same key every time the table is built. Numbering the rows with row_number() fails that. A new change for C01 shifts the numbers of every row after it, and facts that stored the old numbers now point at other customers.

xxhash64(customer_id, changed_at) gives a 64-bit number computed from the version's natural key and start, so a rebuild gives each version the same key. 2 different versions could in principle hash to the same number, which is why the build in part 4 checks that the keys are unique. Delta tables can also hand out lasting numbers with an identity column; module 4 covers it.

Stop the build when a join is wrong

A missing or doubled match is silent, so make it loud. assert_true returns NULL when its condition holds and stops the query with your message when it does not:

SELECT assert_true(count(*) = 0, 'an order matches no customer version, or more than 1') AS ok
FROM (
  SELECT f.order_id
  FROM fact_orders f
  LEFT JOIN dim_customer c
    ON f.customer_id = c.customer_id
   AND f.ordered_at >= c.valid_from
   AND f.ordered_at < c.valid_to
  GROUP BY f.order_id
  HAVING count(c.customer_key) <> 1
);
+----+
|ok  |
+----+
|NULL|
+----+

Point it at dim_customer_v1, with that table's OR c.valid_to IS NULL and count(c.customer_id), and Spark stops with [USER_RAISED_EXCEPTION] an order matches no customer version, or more than 1 SQLSTATE: P0001, which in a scheduled job fails the run before a wrong report reaches anyone.

You will not always write this table by hand. Databricks pipelines can build type 2 from a stream of changes with AUTO CDC, which module 7 uses. It names the dates __START_AT and __END_AT and leaves the current version's end NULL, so the join needs the OR ... IS NULL case again. Knowing what such a table holds, and the 3 traps above, is what lets you trust it. Part 4 puts the model together as 1 script.

Check yourself

1 questions, each in a new situation

  1. 1. A type 2 customer dimension starts each version at the time the change was saved. A customer's record was saved at 12:00, and their first order was placed at 11:30 the same day. What happens to that order in a report that inner joins orders to the version true at the time?

Questions

What is the difference between SCD type 1 and type 2?
Type 1 overwrites a changed value, so the dimension holds only the current state and every report, old ones included, shows today's values. Type 2 adds a new row for each change with the dates it was true, so a fact joins to the values that held when it happened. Use type 1 for corrections and for columns no report groups history by, type 2 where the past must stay as it was.
How do I join a fact table to a type 2 dimension?
Either store the version's surrogate key in the fact when it is loaded and join on that key, or join on the natural key plus the time, f.customer_id = c.customer_id AND f.ordered_at >= c.valid_from AND f.ordered_at < c.valid_to. A half-open range, closed at the start and open at the end, gives a time that equals a change exactly 1 version.
Why use 9999-12-31 instead of NULL for valid_to?
With a date far in the future on the current version, every version has an end, so the join is 1 plain range condition, and a filter such as valid_to > now() works without OR valid_to IS NULL. Keep an is_current column as well, so queries for the current state need no date.
How do I make surrogate keys for a type 2 dimension in Spark SQL?
A key from row_number() over the whole table renumbers rows when a new version arrives in the middle, so facts that stored the old key point at another customer. A hash of the natural key and the version's start, xxhash64(customer_id, changed_at), gives the same key every time the table is rebuilt; check that the keys are unique. On Delta tables, an identity column assigns lasting numbers as rows are added, which module 4 covers.

Sources

Where each fact comes from

  1. 1Type 1: Overwrite
  2. 2Type 2: Add New Row
  3. 3lead function
  4. 4xxhash64 function
  5. 5assert_true function
  6. 6The AUTO CDC APIs: Simplify change data capture with pipelines