Mastering Slowly Changing Dimensions

Mastering Slowly Changing Dimensions

What are slowly changing dimensions?

Slowly Changing Dimensions (SCDs) are dimension tables in a data warehouse where attribute values change over time, but at a relatively slow pace compared to fact table updates. Unlike rapidly changing transactional data, SCDs typically involve customer information, product details, organizational structures, or geographical data that evolves gradually.

The challenge lies in deciding how to handle these changes while preserving data integrity and historical context. Do you overwrite the old value, keep both versions, or create a new record? The answer depends on business requirements and compliance needs. This guide covers SCD Types 1, 2, 3, 4, and 6, with implementation strategies across dbt, Snowflake, Databricks Delta Lake, and Airflow.

SCD Type 1: Overwrite (no history preservation)

When to use: When historical values aren't needed for analysis, or when corrections must be applied retroactively.

Implementation: Update the existing record with new values.

-- Example: Updating customer email address
UPDATE dim_customer
SET email = 'newemail@company.com',
    last_updated = CURRENT_TIMESTAMP
WHERE customer_id = 12345;

Advantages: simple implementation, minimal storage, no join complexity.

Disadvantages: complete loss of history, no trend analysis on changed attributes, potential compliance issues.

Typical use: correcting data entry errors, updating contact information where history isn't business-critical.

SCD Type 2: Create new records (full history preservation)

When to use: When you need complete historical tracking and the ability to analyze trends over time.

Implementation: Create a new record for each change while preserving the original.

-- Create new record for changed customer
INSERT INTO dim_customer (
    customer_id,
    customer_key, -- Business key remains same
    first_name,
    last_name,
    email,
    address,
    effective_date,
    expiration_date,
    is_current
)
SELECT
    NEXT_VALUE_FOR(customer_id_seq),
    customer_key,
    first_name,
    last_name,
    'newemail@company.com', -- New email
    'New Address, City, State', -- New address
    CURRENT_DATE,
    '9999-12-31',
    TRUE
FROM dim_customer
WHERE customer_key = 'CUST_12345' AND is_current = TRUE;

-- Update previous record
UPDATE dim_customer
SET expiration_date = CURRENT_DATE - 1,
    is_current = FALSE
WHERE customer_key = 'CUST_12345' AND is_current = TRUE;

Advanced SCD Type 2 with dbt:

-- models/dim_customer_scd2.sql
{{ config(
    materialized='incremental',
    unique_key='customer_key',
    on_schema_change='sync_all_columns'
) }}

WITH source_data AS (
    SELECT * FROM {{ ref('stg_customers') }}
    {% if is_incremental() %}
        WHERE updated_at > (SELECT MAX(effective_date) FROM {{ this }})
    {% endif %}
),

scd2_logic AS (
    SELECT
        *,
        LAG(customer_key) OVER (PARTITION BY customer_key ORDER BY updated_at) as prev_record,
        LEAD(updated_at, 1, '9999-12-31'::date) OVER (PARTITION BY customer_key ORDER BY updated_at) as expiration_date
    FROM source_data
)

SELECT
    {{ dbt_utils.generate_surrogate_key(['customer_key', 'updated_at']) }} as customer_id,
    customer_key,
    first_name,
    last_name,
    email,
    address,
    updated_at as effective_date,
    expiration_date,
    CASE WHEN expiration_date = '9999-12-31'::date THEN TRUE ELSE FALSE END as is_current
FROM scd2_logic

Advantages: complete historical preservation, trend analysis and point-in-time reporting, audit trail, regulatory compliance support.

Disadvantages: increased storage, more complex queries and joins, potential performance impacts.

SCD Type 3: Add new columns (limited history)

When to use: When you need to track only the previous value alongside the current value.

-- Add columns for previous values
ALTER TABLE dim_customer
ADD COLUMN previous_address VARCHAR(255),
ADD COLUMN previous_address_date DATE;

-- Update with new address while preserving previous
UPDATE dim_customer
SET previous_address = address,
    previous_address_date = address_change_date,
    address = 'New Address, City, State',
    address_change_date = CURRENT_DATE
WHERE customer_id = 12345;

Advantages: simple before/after comparisons, moderate storage increase, easy to implement.

Disadvantages: limited to one previous value, schema changes needed for new tracked attributes, not scalable for multiple changes.

SCD Type 4: History tables (separate current and historical)

When to use: When you need to optimize current-data queries while maintaining complete history in a separate table.

-- Current dimension table
CREATE TABLE dim_customer_current (
    customer_id INT PRIMARY KEY,
    customer_key VARCHAR(50),
    first_name VARCHAR(100),
    last_name VARCHAR(100),
    email VARCHAR(255),
    address VARCHAR(500),
    last_updated TIMESTAMP
);

-- Historical dimension table
CREATE TABLE dim_customer_history (
    customer_id INT,
    customer_key VARCHAR(50),
    first_name VARCHAR(100),
    last_name VARCHAR(100),
    email VARCHAR(255),
    address VARCHAR(500),
    effective_date DATE,
    expiration_date DATE,
    change_reason VARCHAR(255)
);

Modern implementation with Databricks Delta Lake:

from delta.tables import DeltaTable
from pyspark.sql import functions as F

# Read source data
new_customer_data = spark.read.table("bronze.customers")

# Current table operations
current_table = DeltaTable.forName(spark, "silver.dim_customer_current")

# Merge new data into current table
current_table.alias("current").merge(
    new_customer_data.alias("updates"),
    "current.customer_key = updates.customer_key"
).whenMatchedUpdate(
    set = {
        "first_name": "updates.first_name",
        "last_name": "updates.last_name",
        "email": "updates.email",
        "address": "updates.address",
        "last_updated": F.current_timestamp()
    }
).whenNotMatchedInsert(
    values = {
        "customer_key": "updates.customer_key",
        "first_name": "updates.first_name",
        "last_name": "updates.last_name",
        "email": "updates.email",
        "address": "updates.address",
        "last_updated": F.current_timestamp()
    }
).execute()

# Archive changed records to history table
changed_records = current_table.toDF().join(
    new_customer_data, ["customer_key"], "inner"
).where("current.last_updated = new.update_timestamp")

changed_records.write.mode("append").saveAsTable("silver.dim_customer_history")

SCD Type 6: Hybrid approach (combination of Types 1, 2, and 3)

When to use: When you need the benefits of multiple SCD types for different attributes. Type 6 combines versioning (Type 2), current-value updates (Type 1), and previous-value tracking (Type 3).

CREATE TABLE dim_customer_hybrid (
    customer_id INT IDENTITY(1,1) PRIMARY KEY,
    customer_key VARCHAR(50), -- Business key
    version_number INT,
    first_name VARCHAR(100),
    last_name VARCHAR(100),
    email VARCHAR(255), -- Type 1: Always current
    current_address VARCHAR(500), -- Type 3: Current value
    previous_address VARCHAR(500), -- Type 3: Previous value
    original_address VARCHAR(500), -- Type 6: Original value
    effective_date DATE,
    expiration_date DATE,
    is_current BIT,
    created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Implementation strategies

Snowflake Streams and Tasks

-- Create stream to capture changes
CREATE STREAM customer_changes_stream ON TABLE source.customers;

-- Create task for automated SCD processing
CREATE TASK process_customer_scd
    WAREHOUSE = 'SCD_WH'
    SCHEDULE = '5 MINUTE'
AS
INSERT INTO dim_customer (
    customer_key, first_name, last_name, email, address,
    effective_date, expiration_date, is_current
)
SELECT
    customer_key,
    first_name,
    last_name,
    email,
    address,
    CURRENT_DATE,
    '9999-12-31'::DATE,
    TRUE
FROM customer_changes_stream
WHERE METADATA$ACTION = 'INSERT' OR METADATA$ACTION = 'UPDATE';

Apache Airflow for SCD orchestration

from airflow import DAG
from airflow.operators.python_operator import PythonOperator
from datetime import datetime, timedelta

def process_scd_type2(**context):
    """
    Process SCD Type 2 updates using pandas and SQLAlchemy
    """
    import pandas as pd
    from sqlalchemy import create_engine

    engine = create_engine('postgresql://user:pass@host:port/db')

    # Read source data
    source_df = pd.read_sql("""
        SELECT customer_key, first_name, last_name, email, address, updated_at
        FROM source.customers
        WHERE updated_at >= %s
    """, engine, params=[context['ds']])

    # Read current dimension data
    current_df = pd.read_sql("""
        SELECT customer_key, first_name, last_name, email, address
        FROM dim_customer
        WHERE is_current = true
    """, engine)

    # Identify changes
    merged_df = source_df.merge(
        current_df,
        on='customer_key',
        how='left',
        suffixes=('_new', '_current')
    )

    changes_df = merged_df[
        (merged_df['email_new'] != merged_df['email_current']) |
        (merged_df['address_new'] != merged_df['address_current'])
    ]

    if not changes_df.empty:
        # Expire current records
        expire_query = """
            UPDATE dim_customer
            SET expiration_date = %s, is_current = false
            WHERE customer_key IN %s AND is_current = true
        """

        # Insert new records
        new_records = changes_df[[
            'customer_key', 'first_name_new', 'last_name_new',
            'email_new', 'address_new'
        ]].copy()

        new_records.columns = [
            'customer_key', 'first_name', 'last_name', 'email', 'address'
        ]
        new_records['effective_date'] = context['ds']
        new_records['expiration_date'] = '9999-12-31'
        new_records['is_current'] = True

        new_records.to_sql('dim_customer', engine, if_exists='append', index=False)

dag = DAG(
    'scd_processing',
    default_args={
        'owner': 'data-team',
        'depends_on_past': False,
        'start_date': datetime(2024, 1, 1),
        'email_on_failure': True,
        'email_on_retry': False,
        'retries': 2,
        'retry_delay': timedelta(minutes=5)
    },
    description='Process slowly changing dimensions',
    schedule_interval='@daily',
    catchup=False
)

process_scd = PythonOperator(
    task_id='process_customer_scd',
    python_callable=process_scd_type2,
    dag=dag
)

Performance optimization

Indexing for SCD tables

-- Composite index for SCD Type 2 queries
CREATE INDEX idx_customer_business_effective
ON dim_customer (customer_key, effective_date, expiration_date);

-- Index for current record queries
CREATE INDEX idx_customer_current
ON dim_customer (customer_key)
WHERE is_current = true;

-- Partitioning by date for large SCD tables
CREATE TABLE dim_customer_partitioned (
    customer_id BIGINT,
    customer_key VARCHAR(50),
    first_name VARCHAR(100),
    last_name VARCHAR(100),
    email VARCHAR(255),
    address VARCHAR(500),
    effective_date DATE,
    expiration_date DATE,
    is_current BOOLEAN
) PARTITION BY RANGE (effective_date);

Query optimization

-- Efficient point-in-time query
WITH customer_snapshot AS (
    SELECT
        customer_key,
        first_name,
        last_name,
        email,
        address,
        ROW_NUMBER() OVER (
            PARTITION BY customer_key
            ORDER BY effective_date DESC
        ) as rn
    FROM dim_customer
    WHERE effective_date <= '2024-06-01'
      AND expiration_date > '2024-06-01'
)
SELECT * FROM customer_snapshot WHERE rn = 1;

-- Materialized view for current records
CREATE MATERIALIZED VIEW dim_customer_current AS
SELECT * FROM dim_customer WHERE is_current = true;

Testing and data quality

dbt tests for SCD implementation

# models/schema.yml
version: 2

models:
  - name: dim_customer_scd2
    tests:
      - dbt_utils.expression_is_true:
          expression: "effective_date <= expiration_date"
      - dbt_utils.unique_combination_of_columns:
          combination_of_columns:
            - customer_key
            - effective_date
    columns:
      - name: customer_key
        tests:
          - not_null
      - name: effective_date
        tests:
          - not_null
      - name: is_current
        tests:
          - not_null

Integrity monitoring

def validate_scd_integrity(connection):
    """
    Validate SCD Type 2 implementation integrity
    """
    checks = {
        'overlapping_dates': """
            SELECT customer_key, COUNT(*) as overlaps
            FROM (
                SELECT d1.customer_key
                FROM dim_customer d1
                JOIN dim_customer d2 ON d1.customer_key = d2.customer_key
                WHERE d1.customer_id != d2.customer_id
                  AND d1.effective_date < d2.expiration_date
                  AND d1.expiration_date > d2.effective_date
            ) overlaps
            GROUP BY customer_key
            HAVING COUNT(*) > 0
        """,

        'multiple_current_records': """
            SELECT customer_key, COUNT(*) as current_count
            FROM dim_customer
            WHERE is_current = true
            GROUP BY customer_key
            HAVING COUNT(*) > 1
        """,

        'gaps_in_timeline': """
            SELECT
                customer_key,
                effective_date,
                LAG(expiration_date) OVER (
                    PARTITION BY customer_key
                    ORDER BY effective_date
                ) as prev_expiration
            FROM dim_customer
            WHERE effective_date > LAG(expiration_date) OVER (
                    PARTITION BY customer_key
                    ORDER BY effective_date
                )
        """
    }

    results = {}
    for check_name, query in checks.items():
        result = pd.read_sql(query, connection)
        results[check_name] = len(result) == 0  # True if no issues found

    return results

Choosing the right SCD type

CriteriaType 1Type 2Type 3Type 4Type 6
Storage RequirementsLowHighMediumHighHigh
Query ComplexitySimpleComplexSimpleMediumComplex
Historical AnalysisNoneCompleteLimitedCompleteComplete
Performance ImpactMinimalModerateMinimalLowModerate
Compliance SupportPoorExcellentPoorGoodExcellent
Implementation EffortLowHighMediumHighVery High

Choose Type 1 when history isn't required, storage and performance are primary concerns, corrections are applied retroactively, and regulations don't mandate history preservation.

Choose Type 2 when complete historical tracking is essential, trend analysis and point-in-time reporting are required, compliance demands audit trails, and storage cost isn't a primary constraint.

Choose Type 3 when only recent changes need tracking, before/after comparisons are sufficient, schema flexibility exists for new columns, and storage optimization matters.

Choose Type 4 when current-data queries need optimization, complete history must be maintained separately, access patterns are mixed (frequent current, occasional historical), and the architecture supports multiple tables.

Key takeaways

  • SCD choice should align with business requirements and compliance needs; weigh storage cost, query performance, and maintenance overhead.
  • Use appropriate indexing for your chosen SCD type and partition large tables by date range.
  • Implement automated data quality checks (overlapping dates, multiple current records, timeline gaps) to keep SCD integrity.
  • Design the SCD strategy before implementation, plan for migration when changing types, and document the approach for the team.
  • Modern tools like dbt, Airflow, Snowflake Streams/Tasks, and Delta Lake MERGE simplify SCD automation.

Further reading

Leave a Comment