Skip to main content

Introduction

Slowly Changing Dimensions (SCDs) are a common data modeling technique used to manage historical changes in dimension data over time. This enables more accurate time-based analysis and reporting, such as understanding how KPIs were affected under previous attribute values. SCDs are categorized into different types based on how they handle changes to dimension data:

SCD Type 0 - Fixed Dimensions

  • No changes allowed. The data remains as it was when first inserted.
  • Useful when historical accuracy is critical and the value should never change.
  • Example: A product’s original launch date.

SCD Type 1 - Overwrite

  • Changes overwrite existing data. No history is preserved.
  • Simple to implement but loses historical context.
  • Example: If a customer changes their email, the old one is replaced.

SCD Type 2 - Historical Tracking

  • Each change creates a new record, often with start/end timestamps or versioning.
  • Preserves full history of changes.
  • Example: Tracking changes to a customer’s loyalty tier over time.

SCD Type 3 – Previous Value

  • Stores only the previous value alongside the current one.
  • Limited history, useful when only one change needs to be tracked.
  • Example: Keeping a “current region” and a “previous region” field for a customer.
SCD0 and SCD3 are rarely used.

Modeling SCDs in Honeydew

In Honeydew, the modeling of SCDs of Types 0, 1, and 3 is straightforward. Joins between entities are defined using the standard foreign key relationships. For SCD Type 2, Honeydew supports modeling SCDs using a combination of foreign keys and date ranges. Joins between entities are defined using a custom SQL expression that includes the date range logic.

Example: Fact and a Slowly Changing Dimension (SCD2)

Given two tables:
  • fact_sales: a fact table tracking order transactions that has a foreign key customer_id
  • dim_customer: a dimension table tracking customer information over time (SCD Type 2). It has multiple entries per customer_id with validity ranges.
Would want to connect the sales data with the correct historical customer information as it was at the time of the order.

Sample data

fact_sales (Fact Table) dim_customer (SCD2 Dimension Table)
The key for dim_customer is not customer_id (which is repeating across ranges), but rather a surrogate key (customer_sk) valid_from / valid_to define the row’s effective period.Also note that valid_to here is an infinity date (9999-12-31). In some settings it is used as NULL instead, in which case can adjust the join condition accordingly.

Relations

To associate each order with the correct customer version at that point in time, use a custom SQL expression on valid_from and valid_to:
And set a “many-to-one” relationship from fact_sales to dim_customer

Example query

Result of a query on both:

Advanced: Multiple SCD2 (Fact and Dimension) + Point-in-Time Reference point

Advanced use cases for slowly changing dimensions allow to inspect the state of the world at any point in time (including “now”), while every data table has slowly changing dimension fields. Here, the previous example is extended to support of consistent point-in-time queries on historical data where:
  • fact_sales: a fact table with changing business logic over time (e.g. updated amount, revised status). It has multiple versions per order_id, each valid over a time range.
  • dim_customer: a dimension table with customer history over time (e.g. changed region), also with validity ranges.
A central dim_date or dim_point_in_time table is used to filter everything as of a specific point.
Since data is duplicated in multiple versions, users must filter on dim_point_in_time to get correct results (whether for “today” or for any historical point of reference).See Conditional Filtering on how to set an automatic filter in a domain, and an example below.You can also ensure a filter is always applied by configuring it directly within user-facing tools, such as BI dashboards.
This structure is used in auditable data models, financial snapshots, and analytics platforms.

Sample data

fact_sales sample data: dim_customer sample data: dim_point_in_time: A joint reference point for all data This is used to filter time centrally, so other joins respect that single reference point. The three rows above are an excerpt. The table holds one row per date, and must contain every date a user may select as a reference point.
Same approach can be extended for any type of data versioning - not only for point in time.

Relations

Fact to customers This relation decides which version of a customer an order version is attached to. There are two useful answers, and they need different relations - see which version of a dimension a fact sees. The relation below attaches the customer as they were when the order version was written.
  1. Join on customer key and validity ranges
  2. Direction: Many to one (from fact_sales to dim_customer)
  3. Cross-filtering is as needed (one-to-many or bi-directional), unless the domain holds more than one versioned fact - see cross-filtering below
Relation:
The result of that relation is that customers are resolved to the right appropriate customer to the time of the order, while keeping multiple versions of the order.
If a customer has multiple versions within the validity time of an order, it will not be resolved (i.e. will be resolved to NULL).
Entities to point in time reference
  1. fact_sales to dim_point_in_time:
  2. Many to one (from fact_sales to point in time)
  3. Cross-filtering is one-to-many (dim_point_in_time can filter the fact, but not vice versa)
Relation:
  1. dim_customer to dim_point_in_time:
  2. Many to one (from dim_customer to point in time)
  3. Cross-filtering is one-to-many (dim_point_in_time can filter dim_customer, but not vice versa)
Relation:
The dim_point_in_time is a shared dimension that can filter all associated entities.Using cross-filtering one-to-many ensures that it will filter the entities, but will not be filtered by them.

Ensuring Filtering for a Point in Time

When using SCD with multiple versions, data is duplicated for each snapshot. The semantic modeler must ensure that only one snapshot is selected to prevent double-counting.
Automatic filtering can be done at the dashboard or BI report level. However, a semantic layer allows to enforce automatic filtering across all tools using the same semantics.
To ensure consistency at the semantic layer, model the reference point as two entities over the same table: Both read the same table. Neither holds the pin itself. Then pin the reference point with a source filter - a domain filter applied to a source table as it is read - on each versioned entity’s own validity columns. This is the same idiom as domain-level deduplication for multi-grain data, applied to a validity range instead of a grain column:
The COALESCE chain resolves the reference point in three tiers: a direct filter on dim_point_in_time.snapshot_date wins; otherwise the value picked on dim_point_in_time_choice; otherwise today. Having no relations, dim_point_in_time_choice cannot filter anything through a join. GET_FIELD_SELECTION inspects the query’s filters rather than following a join, which is how a choice made on a disconnected entity reaches the pin at all, and how the first tier detects dim_point_in_time even when that entity is not part of the query. Detection does not replace a filter’s ordinary effect. A filter on dim_point_in_time.snapshot_date is read by the first tier and still filters that dimension as any filter would, joining it into the query.
Write the pin on the fact’s own validity columns, not on dim_point_in_time.A source filter applies wherever its entity’s data is read, so a filter on fact_sales.valid_from covers every query that reads fact_sales. A filter written on dim_point_in_time.snapshot_date instead is skipped entirely by a query that does not reach that dimension - and a query on fact_sales alone then returns every version, double-counting with nothing to indicate it.
A pin covers only the entity whose columns it names - these filters do not propagate. Above, dim_customer carries no pin because the join resolves it per order version, which is the as-written reading described below.
A query on dim_customer alone is not covered by the fact’s pin, and sees every version - three rows for two customers in the sample data. Pin each versioned entity that users query on its own, and use the as-of reading when you do, since a pinned dimension contradicts a join anchored on the fact.
Source filters are supported on attributes that come from an entity source table. Validity columns read from the table qualify; a validity range built as a calculated attribute does not.
The last tier is a policy choice: CURRENT_DATE makes an unfiltered query mean “as of today”, while no default returns nothing until a reference point is chosen. With no default, a user who forgot to filter gets an empty report rather than an error. The default is compared directly against the validity columns, so dim_point_in_time needs no row for it. A date a user filters on does need to be there, since that filter joins the dimension.
GET_FIELD_SELECTION detects equality filters only. A query filtering the reference point to several dates or to a range is not detected, and resolves to the default - indistinguishable from an unfiltered query.So a series grouped by dim_point_in_time.snapshot_date over three dates is pinned to today, and returns only the dates at which today’s versions were already valid. Earlier dates disappear entirely: no error, and no zero row.
To let a query widen the pin, add a two-value scope selector as a disconnected entity and branch on it, as in performance filtering. The second argument to GET_FIELD_SELECTION is the value to assume when the user picked nothing:
A query that sets dim_scope.scope = 'all_data' leaves every version in place. Grouped by dim_point_in_time.snapshot_date, the range relation then resolves each date on its own and the series is correct at every point.
Point BI selectors at dim_point_in_time_choice, not at dim_point_in_time. A BI tool listing the values of a connected dimension receives only what the query left of it, while the disconnected copy always offers every date.

Which version of a dimension a fact sees

When both a fact and its dimension are versioned, “as of 2022-05-01” has two readings, and both are worth having. Take order 5001, whose current version was written on 2021-07-01, for a customer who moved from East to West on 2022-03-01: Pick per relation, by what the question means. An order’s own history reads naturally as written - the address a parcel actually went to. A current-state report reads as of the reference point - where that customer is now.
The relation and the pins must express the same reading. Anchoring the join on the fact’s valid_from while also resolving the dimension to the reference point asks for two different versions at once: the join looks for the version valid when the fact was written, the dimension no longer offers it, and the dimension’s attributes come back as NULL - or the fact row disappears altogether.For the as-written reading, pin only the fact, and do not let dim_point_in_time cross-filter the dimension. For the as-of reading, pin every versioned entity and join on the business key alone.
An SCD2 dimension’s key is its surrogate key, so a business-key join is written as a custom expression (fact_sales.customer_id = dim_customer.customer_id) rather than a field connection. Cardinality is not validated for expression joins - the pin is what leaves one version per key, so the pin is doing the work that makes the join safe.

Cross-filtering between versioned entities

dim_point_in_time is a shared dimension that filters data, so set its relations to cross-filter one-to-many: the reference dimension filters the versioned entities and is never filtered back by them. Set this explicitly - the default is both - and set it on every relation into the dimension. That holds for any dimension used to filter data. dim_point_in_time_choice has no relations, so cross-filtering does not apply to it.
With bi-directional cross-filtering, one versioned entity’s filters reach dim_point_in_time, and through it every other entity joined to it. A filter on one fact then drops rows from another that are valid at the reference point, and the range of dates it leaves behind duplicates the rows that remain - the double-counting this section exists to prevent.It surfaces only for particular filter combinations, so a domain can test clean and still break later.
The same applies to any other dimension shared between versioned entities. Facts pinned on their own columns do not filter each other. A shared dimension whose cross-filtering is bi-directional does connect them: a filter on one fact then drops rows from the other wherever that dimension is part of the query.

Example query

Status of all orders given reference point of 2021-06-15
  • Only one valid version per order_id and customer_id is active per point-in-time
  • Any rows not yet valid are excluded (e.g. 5002 is not visible on 2021-06-15)
Status of all orders given reference point of 2022-05-01 Summing amount over the same data: In a domain with no pin at all, every version is counted and the sum is 850 - the double-counting the pin prevents. Under the domain above, a user who filters nothing still gets a pinned result, not 850.
  • Use dim_point_in_time to anchor the reference date
  • Join facts and dimensions based on SCD2 validity ranges
  • Works seamlessly for time travel, reproducible snapshots, or data backfills