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.
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 keycustomer_iddim_customer: a dimension table tracking customer information over time (SCD Type 2). It has multiple entries percustomer_idwith validity ranges.
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 onvalid_from and valid_to:
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 perorder_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.
dim_date or dim_point_in_time table is used to filter everything as of a specific point.
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.
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.- Join on customer key and validity ranges
- Direction: Many to one (from
fact_salestodim_customer) - Cross-filtering is as needed (one-to-many or bi-directional), unless the domain holds more than one versioned fact - see cross-filtering below
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).
-
fact_salestodim_point_in_time: -
Many to one (from
fact_salesto point in time) -
Cross-filtering is one-to-many (
dim_point_in_timecan filter the fact, but not vice versa)
-
dim_customertodim_point_in_time: -
Many to one (from
dim_customerto point in time) -
Cross-filtering is one-to-many (
dim_point_in_timecan filterdim_customer, but not vice versa)
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. 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:
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.
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.
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.
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.
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:
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.
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 order5001, 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.
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.
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 of2021-06-15
Status of all orders given reference point of
- Only one valid version per
order_idandcustomer_idis active per point-in-time- Any rows not yet valid are excluded (e.g. 5002 is not visible on 2021-06-15)
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.