SQL · Guides · October 29, 2026 · 14 min read

Slowly changing dimension type 2 in SQL: as-of queries and checks

A horizontal timeline of coloured bars for loyalty card tiers, each bar starting where the previous one ends

A slowly changing dimension of type 2 (SCD2) is a table that keeps the full history of an attribute instead of overwriting it. When a customer’s tier, a product’s price or an employee’s department changes, the current row is closed and a new row is opened. Each row carries the period when it was true: valid_from (when the version started) and valid_to (when it stopped; NULL or a far-future date for the current version).

With that structure you can answer two kinds of questions that a plain “current value” table cannot:

The rest of this guide covers both, plus the checks every SCD2 table needs: overlapping versions, gaps and missing ends. The examples use two real SCD2 tables from a training airline database: loyalty_tier_history (a member’s tier: basic, silver, gold, platinum) and fare_price_history (the fare for a route and cabin class). Every query was run on that data and the tables show the real output, so you can run each one yourself in the Nomto SQL room.

What an SCD2 table looks like

One member’s tier history:

SELECT member_id, tier, valid_from, valid_to
FROM loyalty_tier_history
WHERE member_id = 143
ORDER BY valid_from;
member_id tier valid_from valid_to
143 basic 2026-07-01 00:00:00 2026-07-24 00:00:00
143 silver 2026-07-24 00:00:00 2026-08-06 00:00:00
143 gold 2026-08-06 00:00:00 null

Three rules make this a well-formed history:

  1. Half-open intervals. A row is valid from valid_from inclusive to valid_to exclusive. The basic row ends at exactly the moment the silver row starts, and 24 July 00:00 belongs only to silver.
  2. No overlaps, no gaps. For each key, every moment is covered by exactly one row, from the first version on.
  3. One current row. The latest version has an open end (valid_to IS NULL).

Some warehouses use 9999-12-31 instead of NULL for the open end, and sometimes an is_current flag. The logic below is the same; only the open-end condition changes.

Compared with the other common types: type 1 overwrites the value and keeps no history; type 2 adds a row per change (this guide); type 3 keeps a “previous value” column, so only one step of history.

The value at a point in time

To get every member’s tier as of 1 August, keep the rows whose period contains that moment:

SELECT tier, COUNT(*) AS members
FROM loyalty_tier_history
WHERE valid_from <= TIMESTAMP '2026-08-01 00:00'
  AND (valid_to > TIMESTAMP '2026-08-01 00:00' OR valid_to IS NULL)
GROUP BY tier
ORDER BY members DESC;
tier members
basic 48422
silver 1475
gold 179
platinum 6

The two comparisons mirror the half-open rule: <= on the start, strict > on the end. Writing the end condition as OR valid_to IS NULL keeps current rows; without it every open version would be silently lost, because a comparison with NULL is never true.

Why BETWEEN is the wrong tool

BETWEEN includes both ends. On a half-open history that means a moment exactly on a boundary matches two rows:

SELECT member_id, tier, valid_from, valid_to
FROM loyalty_tier_history
WHERE member_id = 143
  AND TIMESTAMP '2026-07-24 00:00' BETWEEN valid_from
      AND COALESCE(valid_to, TIMESTAMP '9999-12-31');
member_id tier valid_from valid_to
143 basic 2026-07-01 00:00:00 2026-07-24 00:00:00
143 silver 2026-07-24 00:00:00 2026-08-06 00:00:00

Tier upgrades here take effect at midnight, so boundaries land exactly on midnight. Asking “who held which tier on 24 July at 00:00” with BETWEEN returns 47,770 rows for 47,648 members; the half-open condition returns 47,658. The remaining ten extra rows are not a query problem but a data problem, which brings us to checks.

Checking an SCD2 table: overlaps, gaps, missing ends

Before trusting a history, test it. LEAD() puts the start of the next version next to each row, and comparing it with the row’s own end classifies every row:

WITH h AS (
  SELECT member_id, tier, valid_from, valid_to,
    LEAD(valid_from) OVER (PARTITION BY member_id ORDER BY valid_from) AS next_from
  FROM loyalty_tier_history
)
SELECT
  CASE WHEN next_from IS NULL AND valid_to IS NULL THEN 'current, ok'
       WHEN next_from IS NULL THEN 'last row closed'
       WHEN valid_to IS NULL THEN 'open but has successor'
       WHEN valid_to = next_from THEN 'touches next'
       WHEN valid_to > next_from THEN 'overlaps next'
       ELSE 'gap before next' END AS status,
  COUNT(*) AS row_count
FROM h
GROUP BY 1
ORDER BY 2 DESC;
status row_count
current, ok 53000
touches next 4989
open but has successor 110
overlaps next 95

What each status means:

The same check on the fare table, keyed by route and class, comes back clean:

WITH h AS (
  SELECT departure_airport, arrival_airport, fare_conditions, price, valid_from, valid_to,
    LEAD(valid_from) OVER (
      PARTITION BY departure_airport, arrival_airport, fare_conditions
      ORDER BY valid_from) AS next_from
  FROM fare_price_history
)
SELECT
  CASE WHEN next_from IS NULL AND valid_to IS NULL THEN 'current, ok'
       WHEN next_from IS NULL THEN 'last row closed'
       WHEN valid_to IS NULL THEN 'open but has successor'
       WHEN valid_to = next_from THEN 'touches next'
       WHEN valid_to > next_from THEN 'overlaps next'
       ELSE 'gap before next' END AS status,
  COUNT(*) AS row_count
FROM h
GROUP BY 1
ORDER BY 2 DESC;
status row_count
touches next 1609
current, ok 698

Two smaller checks are worth adding: no row with valid_to <= valid_from (zero-length or inverted periods; there are none in either table), and no two rows with the same key and the same valid_from (also none).

Listing the overlapping pairs

The LEAD check only compares neighbours. To list every pair of versions that overlap, including a long-open row that overlaps several later ones, self-join the table:

SELECT a.member_id,
  a.tier AS tier_a, a.valid_from AS from_a, a.valid_to AS to_a,
  b.tier AS tier_b, b.valid_from AS from_b, b.valid_to AS to_b
FROM loyalty_tier_history a
JOIN loyalty_tier_history b
  ON b.member_id = a.member_id
 AND b.valid_from > a.valid_from
 AND b.valid_from < COALESCE(a.valid_to, TIMESTAMP '9999-12-31')
ORDER BY a.member_id
LIMIT 4;
member_id tier_a from_a to_a tier_b from_b to_b
150 basic 2026-07-01 00:00:00 2026-08-08 00:00:00 silver 2026-08-05 00:00:00 null
920 basic 2026-07-01 00:00:00 null silver 2026-07-17 00:00:00 null
1787 basic 2026-07-01 00:00:00 null silver 2026-08-10 00:00:00 null
1950 basic 2026-07-01 00:00:00 null silver 2026-07-30 00:00:00 null

The condition reads: “version b starts after version a starts and before a ends”. Because no two versions of one member share a start, this finds each overlapping pair exactly once. Across the table there are 214 such pairs for 205 members, and in 119 of them the older row was never closed. The general overlap test for two half-open intervals is a.start < b.end AND b.start < a.end; the version above is that test with the pair ordered by start to avoid listing it twice.

Repairing a history with LEAD

If the rule is “a version lasts until the next one starts”, you can rebuild every end from the starts alone. That is the standard repair for overlaps and unclosed rows:

SELECT member_id, tier, valid_from, valid_to,
  LEAD(valid_from) OVER (PARTITION BY member_id ORDER BY valid_from) AS valid_to_fixed
FROM loyalty_tier_history
WHERE member_id IN (150, 920)
ORDER BY member_id, valid_from;
member_id tier valid_from valid_to valid_to_fixed
150 basic 2026-07-01 00:00:00 2026-08-08 00:00:00 2026-08-05 00:00:00
150 silver 2026-08-05 00:00:00 null null
920 basic 2026-07-01 00:00:00 null 2026-07-17 00:00:00
920 silver 2026-07-17 00:00:00 null null

After the repair the point-in-time query on 1 August returns 50,040 rows for 50,040 members, exactly one per member, instead of 50,082 rows. Use this only when “next start ends the previous version” is really the business rule. If gaps are legitimate (a membership was paused, a product was off sale), deriving ends with LEAD would erase them.

As-of joins: the value when the event happened

The most common use of SCD2 is attaching the historical value to facts. Each member ticket was booked at book_date; which tier did the member hold at that moment? Compare that with the naive version that just takes the current tier:

WITH mt AS (
  SELECT tm.ticket_no, tm.member_id, b.book_date
  FROM ticket_members tm
  JOIN tickets t ON t.ticket_no = tm.ticket_no
  JOIN bookings b ON b.book_ref = t.book_ref
  WHERE b.book_date >= '2026-07-01'
),
matched AS (
  SELECT mt.ticket_no, h.tier,
    ROW_NUMBER() OVER (PARTITION BY mt.ticket_no ORDER BY h.valid_from DESC) AS rn
  FROM mt
  JOIN loyalty_tier_history h
    ON h.member_id = mt.member_id
   AND mt.book_date >= h.valid_from
   AND (mt.book_date < h.valid_to OR h.valid_to IS NULL)
),
at_booking AS (
  SELECT ticket_no, tier FROM matched WHERE rn = 1
),
cur_m AS (
  SELECT mt.ticket_no, h.tier,
    ROW_NUMBER() OVER (PARTITION BY mt.ticket_no ORDER BY h.valid_from DESC) AS rn
  FROM mt
  JOIN loyalty_tier_history h ON h.member_id = mt.member_id AND h.valid_to IS NULL
),
cur AS (
  SELECT ticket_no, tier FROM cur_m WHERE rn = 1
)
SELECT 'tier at booking' AS version, COUNT(*) AS tickets,
  COUNT(*) FILTER (WHERE tier = 'basic') AS basic,
  COUNT(*) FILTER (WHERE tier = 'silver') AS silver,
  COUNT(*) FILTER (WHERE tier = 'gold') AS gold,
  COUNT(*) FILTER (WHERE tier = 'platinum') AS platinum
FROM at_booking
UNION ALL
SELECT 'current tier', COUNT(*),
  COUNT(*) FILTER (WHERE tier = 'basic'),
  COUNT(*) FILTER (WHERE tier = 'silver'),
  COUNT(*) FILTER (WHERE tier = 'gold'),
  COUNT(*) FILTER (WHERE tier = 'platinum')
FROM cur;
version tickets basic silver gold platinum
tier at booking 70205 67656 2110 353 86
current tier 70205 53227 10356 4851 1771

The difference is dramatic. Tiers were introduced on 1 July and members climb them by flying, so most tickets were bought while their owners were still basic. Credit those sales to the current tier and gold members appear to buy about 14 times and platinum members about 20 times more tickets than they actually bought while holding those tiers. Any analysis of “how does tier affect behaviour” has to use the as-of value.

Two details in the query:

Which moment to join on

An as-of join is only as good as the timestamp you choose. Fares are a good illustration: the price on a ticket was fixed when it was booked, not when the plane departed. Join the fare history to segments on September flights both ways and count how often the fare matches the amount paid:

SELECT tf.fare_conditions, COUNT(*) AS segments,
  COUNT(*) FILTER (WHERE tf.amount = p.price) AS at_booking_fare,
  COUNT(*) FILTER (WHERE tf.amount = c.price) AS at_departure_fare
FROM ticket_flights tf
JOIN tickets t ON t.ticket_no = tf.ticket_no
JOIN bookings b ON b.book_ref = t.book_ref
JOIN flights f ON f.flight_id = tf.flight_id
LEFT JOIN fare_price_history p
  ON p.departure_airport = f.departure_airport
 AND p.arrival_airport = f.arrival_airport
 AND p.fare_conditions = tf.fare_conditions
 AND b.book_date >= p.valid_from
 AND (b.book_date < p.valid_to OR p.valid_to IS NULL)
LEFT JOIN fare_price_history c
  ON c.departure_airport = f.departure_airport
 AND c.arrival_airport = f.arrival_airport
 AND c.fare_conditions = tf.fare_conditions
 AND f.scheduled_departure >= c.valid_from
 AND (f.scheduled_departure < c.valid_to OR c.valid_to IS NULL)
WHERE f.scheduled_departure >= '2026-09-01'
GROUP BY tf.fare_conditions
ORDER BY tf.fare_conditions;
fare_conditions segments at_booking_fare at_departure_fare
Business 6274 6274 4443
Comfort 857 857 690
Economy 52317 49232 36574

Joined on the booking time, every Business and Comfort segment matches its fare. Joined on the departure time, between a fifth and a third do not, because many routes got a new fare on 1 September, after these tickets had been sold. (The Economy segments that do not match even at booking time are seats in certain cabin rows sold above the fare; the dataset documents this.) The SQL is identical apart from one column; the right column is a business question, not a technical one.

Building an SCD2 table from snapshots

Histories are often built from daily snapshots: a table with one row per key per day and the value on that day. Collapsing snapshots into versions is a gaps-and-islands problem: flag the days where the value changes, number the versions with a running sum, and take the first and last day of each. The query below creates daily snapshots for member 1243 from the history and collapses them back, which shows both directions:

WITH snapshots AS (
  SELECT CAST(d.dt AS DATE) AS snapshot_day, h.member_id, h.tier
  FROM generate_series(DATE '2026-07-01', DATE '2026-08-11', INTERVAL '1 day') AS d(dt)
  JOIN loyalty_tier_history h
    ON h.member_id = 1243
   AND h.valid_from <= d.dt
   AND (h.valid_to > d.dt OR h.valid_to IS NULL)
),
marked AS (
  SELECT snapshot_day, member_id, tier,
    CASE WHEN tier = LAG(tier) OVER (PARTITION BY member_id ORDER BY snapshot_day)
         THEN 0 ELSE 1 END AS changed
  FROM snapshots
),
versioned AS (
  SELECT snapshot_day, member_id, tier,
    SUM(changed) OVER (PARTITION BY member_id ORDER BY snapshot_day) AS version
  FROM marked
)
SELECT member_id, tier,
  MIN(snapshot_day) AS valid_from,
  CASE WHEN MAX(snapshot_day) = DATE '2026-08-11' THEN NULL
       ELSE MAX(snapshot_day) + 1 END AS valid_to
FROM versioned
GROUP BY member_id, tier, version
ORDER BY valid_from;
member_id tier valid_from valid_to
1243 basic 2026-07-01 2026-07-17
1243 silver 2026-07-17 2026-07-28
1243 gold 2026-07-28 2026-08-06
1243 platinum 2026-08-06 null

Grouping by version rather than by tier alone matters: if a member goes gold → silver → gold, the two gold periods must stay separate rows. The end is the day after the last snapshot (half-open again), and the version still present in the latest snapshot gets an open end. Snapshot-based histories have day precision: a change at 14:00 shows up at the next snapshot. For more on the flag-and-sum technique, see our guide to gaps and islands in SQL.

Checklist for working with SCD2

Both history tables in this guide are part of the Nomto SQL course dataset, so every check and join here can be run and modified in the SQL room.

FAQ

What is a slowly changing dimension type 2?

A table that stores each change of an attribute as a new row with a validity period (valid_from, valid_to) instead of overwriting the old value, so you can reconstruct the value at any moment in the past.

How do I query the current row in an SCD2 table?

Filter on the open end: WHERE valid_to IS NULL (or = '9999-12-31', or is_current = true, depending on the convention). If that returns more than one row per key, the history has unclosed versions.

How do I join facts to an SCD2 dimension?

Join on the business key and on the event timestamp falling inside the version’s period: ON d.key = f.key AND f.event_time >= d.valid_from AND (f.event_time < d.valid_to OR d.valid_to IS NULL).

How do I find overlapping records in an SCD2 table?

Use LEAD(valid_from) OVER (PARTITION BY key ORDER BY valid_from) and flag rows where valid_to is NULL or greater than the next start, or self-join the table on a.valid_from < b.valid_to AND b.valid_from < a.valid_to for the same key.

Should valid_to be inclusive or exclusive?

Exclusive (half-open intervals) is the safer convention: the end of one version equals the start of the next, there is no need for “one second before” arithmetic, and the point-in-time condition never matches two rows of a correct history.