Wicked Smart Data
LearnArticlesAbout
Sign InSign Up
LearnArticlesAboutContact
Sign InSign Up
Wicked Smart Data

The go-to platform for professionals who want to master data, automation, and AI — from Excel fundamentals to cutting-edge machine learning.

Platform

  • Learning Paths
  • Articles
  • About
  • Contact

Connect

  • Contact Us
  • RSS Feed

© 2026 Wicked Smart Data. All rights reserved.

Privacy PolicyTerms of Service
All Articles
Slowly Changing Dimensions in Data Pipelines: Implementing SCD Type 1, 2, and 3 with Python and SQL

Slowly Changing Dimensions in Data Pipelines: Implementing SCD Type 1, 2, and 3 with Python and SQL

Data Engineering⚡ Practitioner24 min readJul 26, 2026Updated Jul 26, 2026
Table of Contents
  • Introduction
  • Prerequisites
  • The Core Problem: Dimensions Aren't Static
  • Setting Up the Scenario
  • SCD Type 1: Overwrite Without Mercy
  • SQL Implementation
  • Python Implementation
  • SCD Type 2: The Full History Strategy

On this page

  • Introduction
  • Prerequisites
  • The Core Problem: Dimensions Aren't Static
  • Setting Up the Scenario
  • SCD Type 1: Overwrite Without Mercy
  • SQL Implementation
  • Python Implementation
  • SCD Type 2: The Full History Strategy
  • The Table Structure
  • SQL Implementation
  • Python Implementation
  • The Table Structure
  • SQL Implementation
  • Python Implementation
  • Querying a Type 2 Dimension
  • Handling Late-Arriving Data
  • SCD Type 3: Remembering One Version Back
  • The Table Structure
  • SQL Implementation
  • Python Implementation
  • Choosing the Right Strategy: A Decision Framework
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Mistake 1: Forgetting the Transaction Boundary in Type 2
  • Mistake 2: Using Natural Keys in Fact Tables
  • Mistake 3: Comparing NULLs Incorrectly
  • Mistake 4: Processing the Full Snapshot on Every Run
  • Mistake 5: Not Handling the "Same Day, Multiple Changes" Case
  • Mistake 6: The is_current Flag Gets Out of Sync
  • Summary & Next Steps
  • Slowly Changing Dimensions in Data Pipelines: Implementing SCD Type 1, 2, and 3 with Python and SQL

    Introduction

    Your analytics team just discovered that sales reports for the last quarter are wrong. Not subtly wrong — catastrophically wrong. A customer was reclassified from "Enterprise" to "SMB" three months ago, and because your pipeline simply overwrote the old value, every historical order that customer placed now looks like it came from an SMB account. The revenue attribution is off by hundreds of thousands of dollars, and now someone has to explain to the board why the numbers changed overnight.

    This is exactly the problem that Slowly Changing Dimensions (SCDs) exist to solve. In any real-world data warehouse, the world changes: customers move, products get repriced, salespeople switch territories, and companies restructure. How your pipeline handles those changes determines whether your historical reporting is trustworthy. Get it wrong, and you either silently corrupt the past or build a system so bloated with history that it becomes unmaintainable.

    By the end of this lesson, you'll know how to implement the three most common SCD strategies — Type 1 (overwrite), Type 2 (full history with surrogate keys), and Type 3 (limited history with prior-value columns) — using both Python and SQL. You'll understand when each strategy is appropriate, how to handle the edge cases that trip up most pipelines, and how to build a production-ready implementation you can actually maintain.

    What you'll learn:

    • How SCD Type 1, 2, and 3 differ structurally and when each is the right choice
    • How to implement all three types in SQL using upsert patterns and window functions
    • How to build a Python-based SCD pipeline using pandas that processes incremental loads
    • How to handle late-arriving data and multi-attribute changes in Type 2
    • How to avoid the most common production mistakes, including surrogate key collisions and transaction-boundary issues

    Prerequisites

    You should be comfortable with:

    • SQL at a practitioner level (JOINs, window functions, CTEs)
    • Python with pandas (merging DataFrames, applying functions)
    • Basic data warehousing concepts — what a dimension table is, what a fact table is, and why surrogate keys exist
    • Familiarity with a relational database (PostgreSQL examples are used here, but the patterns translate to Snowflake, BigQuery, and Redshift with minor syntax changes)

    The Core Problem: Dimensions Aren't Static

    Before writing any code, let's be precise about what we're solving. In a data warehouse, a dimension table describes the context of your facts — who, what, where. A dim_customer table tells you that order #8842 was placed by Acme Corp, an Enterprise customer in the Northeast region.

    The problem is that "Enterprise" and "Northeast" aren't permanent facts. Acme Corp might get reclassified, move offices, or get acquired. When that happens, you have three fundamental choices:

    1. Forget the past entirely — just update the record with the new value (SCD Type 1)
    2. Preserve the full history — keep the old record and add a new one (SCD Type 2)
    3. Remember one version back — store both the current and the immediately prior value (SCD Type 3)

    Each choice has real consequences for storage, query complexity, and analytical fidelity. Let's build all three.


    Setting Up the Scenario

    Throughout this lesson, we'll work with a customers dimension. Here's our source system schema — the data that arrives daily from a CRM:

    -- Source system table (CRM export)
    CREATE TABLE crm_customers (
        customer_id     INTEGER PRIMARY KEY,
        company_name    VARCHAR(200),
        segment         VARCHAR(50),   -- 'Enterprise', 'SMB', 'Startup'
        region          VARCHAR(50),
        account_owner   VARCHAR(100),
        updated_at      TIMESTAMP
    );
    

    And here's some representative data arriving in today's batch:

    import pandas as pd
    from datetime import datetime, timezone
    
    # Simulated CRM extract - today's snapshot
    crm_data = pd.DataFrame([
        {"customer_id": 1001, "company_name": "Acme Corp",      "segment": "Enterprise", "region": "Northeast", "account_owner": "Sarah Chen",    "updated_at": "2024-03-15 09:00:00"},
        {"customer_id": 1002, "company_name": "Bright Ideas LLC","segment": "SMB",        "region": "Southeast", "account_owner": "Marcus Webb",   "updated_at": "2024-03-15 08:30:00"},
        {"customer_id": 1003, "company_name": "Pinnacle Health", "segment": "Enterprise", "region": "Midwest",   "account_owner": "Sarah Chen",    "updated_at": "2024-03-14 17:00:00"},
        {"customer_id": 1004, "company_name": "Dune Analytics",  "segment": "Startup",    "region": "West",      "account_owner": "Priya Nair",    "updated_at": "2024-03-15 10:15:00"},
    ])
    

    Now let's say a few days later, the next CRM extract shows changes:

    # Next batch - several records have changed
    crm_data_updated = pd.DataFrame([
        {"customer_id": 1001, "company_name": "Acme Corp",      "segment": "SMB",        "region": "Northeast", "account_owner": "Marcus Webb",   "updated_at": "2024-03-18 14:00:00"},  # segment changed, owner changed
        {"customer_id": 1002, "company_name": "Bright Ideas LLC","segment": "SMB",        "region": "Southeast", "account_owner": "Marcus Webb",   "updated_at": "2024-03-15 08:30:00"},  # no change
        {"customer_id": 1003, "company_name": "Pinnacle Health", "segment": "Enterprise", "region": "West",      "account_owner": "Priya Nair",    "updated_at": "2024-03-18 11:00:00"},  # region changed, owner changed
        {"customer_id": 1004, "company_name": "Dune Analytics",  "segment": "SMB",        "region": "West",      "account_owner": "Priya Nair",    "updated_at": "2024-03-18 09:45:00"},  # segment changed
    ])
    

    This is the data we'll process with each SCD strategy.


    SCD Type 1: Overwrite Without Mercy

    Type 1 is the simplest strategy: when a value changes, you update the row and throw away the old value. No history, no versioning — just the current state of the world.

    When to use it: For attributes where history genuinely doesn't matter. If a customer's phone number changes, you probably don't need to know what it used to be. Type 1 is also appropriate when you're correcting data quality errors — if "Acme Crp" was a typo for "Acme Corp," you want to overwrite that everywhere.

    When to avoid it: Any time business users will ask "what was this value at the time of the transaction?" Type 1 will give them the wrong answer.

    SQL Implementation

    The SQL pattern is a standard upsert. In PostgreSQL:

    -- Create the Type 1 dimension table
    CREATE TABLE dim_customer_t1 (
        customer_id     INTEGER PRIMARY KEY,
        company_name    VARCHAR(200),
        segment         VARCHAR(50),
        region          VARCHAR(50),
        account_owner   VARCHAR(100),
        updated_at      TIMESTAMP,
        dw_updated_at   TIMESTAMP DEFAULT NOW()  -- when WE updated it
    );
    
    -- The upsert: insert new rows, update existing ones
    INSERT INTO dim_customer_t1 (
        customer_id, company_name, segment, region, account_owner, updated_at, dw_updated_at
    )
    SELECT
        customer_id,
        company_name,
        segment,
        region,
        account_owner,
        updated_at,
        NOW() AS dw_updated_at
    FROM staging_crm_customers
    
    ON CONFLICT (customer_id) DO UPDATE SET
        company_name    = EXCLUDED.company_name,
        segment         = EXCLUDED.segment,
        region          = EXCLUDED.region,
        account_owner   = EXCLUDED.account_owner,
        updated_at      = EXCLUDED.updated_at,
        dw_updated_at   = NOW()
    
    -- Only update if something actually changed (avoid unnecessary writes)
    WHERE (
        dim_customer_t1.segment         != EXCLUDED.segment     OR
        dim_customer_t1.region          != EXCLUDED.region      OR
        dim_customer_t1.account_owner   != EXCLUDED.account_owner OR
        dim_customer_t1.company_name    != EXCLUDED.company_name
    );
    

    That WHERE clause on the update is important. Without it, you'd rewrite every row on every pipeline run, which murders performance and makes your dw_updated_at timestamp useless as an audit field.

    Python Implementation

    import pandas as pd
    import sqlalchemy as sa
    from sqlalchemy import text
    
    def apply_scd_type1(engine, new_data: pd.DataFrame, target_table: str, natural_key: str):
        """
        Apply SCD Type 1 logic: upsert into target table.
        new_data: DataFrame with incoming records
        natural_key: the business key column (e.g., 'customer_id')
        """
        with engine.begin() as conn:
            # Load current dimension state
            existing = pd.read_sql(f"SELECT * FROM {target_table}", conn)
            
            # Identify new records vs. updates
            merged = new_data.merge(
                existing[[natural_key]],
                on=natural_key,
                how='left',
                indicator=True
            )
            
            new_records = merged[merged['_merge'] == 'left_only'].drop(columns=['_merge'])
            updates = merged[merged['_merge'] == 'both'].drop(columns=['_merge'])
            
            print(f"New records: {len(new_records)}, Potential updates: {len(updates)}")
            
            # For new records, just insert
            if not new_records.empty:
                new_records['dw_updated_at'] = pd.Timestamp.now()
                new_records.to_sql(target_table, conn, if_exists='append', index=False)
            
            # For updates, check if anything actually changed
            if not updates.empty:
                existing_subset = existing[existing[natural_key].isin(updates[natural_key])]
                
                cols_to_compare = ['company_name', 'segment', 'region', 'account_owner']
                
                comparison = updates.merge(
                    existing_subset[[natural_key] + cols_to_compare],
                    on=natural_key,
                    suffixes=('_new', '_old')
                )
                
                # Find rows where at least one column changed
                changed_mask = False
                for col in cols_to_compare:
                    changed_mask = changed_mask | (comparison[f'{col}_new'] != comparison[f'{col}_old'])
                
                changed_records = updates[updates[natural_key].isin(
                    comparison[changed_mask][natural_key]
                )]
                
                print(f"Actually changed: {len(changed_records)}")
                
                # Execute updates
                for _, row in changed_records.iterrows():
                    conn.execute(text(f"""
                        UPDATE {target_table}
                        SET company_name = :company_name,
                            segment = :segment,
                            region = :region,
                            account_owner = :account_owner,
                            updated_at = :updated_at,
                            dw_updated_at = NOW()
                        WHERE {natural_key} = :{natural_key}
                    """), row.to_dict())
    

    Performance note: The row-by-row update loop above is fine for thousands of records, but it'll crawl at millions. For large volumes, use a staging table + UPDATE ... FROM join pattern instead. We'll cover that in the Type 2 section.


    SCD Type 2: The Full History Strategy

    Type 2 is the heavyweight. Instead of overwriting, you "close out" the old record and insert a new one. Every version of a dimension member lives in the table, and each version is valid for a specific time range.

    This is the strategy you want when someone asks: "What segment was Acme Corp in when they placed that order in February?" With Type 2, you can answer that precisely.

    The tradeoff is complexity. Your dimension table grows over time, queries need to filter on the effective date range, and your fact tables must join on a surrogate key (not the natural business key) to link to the correct version.

    The Table Structure

    CREATE TABLE dim_customer_t2 (
        -- Surrogate key: unique per row, not per customer
        customer_sk         BIGSERIAL PRIMARY KEY,
        
        -- Natural key: the business identifier, shared across versions
        customer_id         INTEGER NOT NULL,
        
        -- The actual attributes
        company_name        VARCHAR(200),
        segment             VARCHAR(50),
        region              VARCHAR(50),
        account_owner       VARCHAR(100),
        
        -- Version control columns
        effective_from      DATE NOT NULL,
        effective_to        DATE,           -- NULL means "currently active"
        is_current          BOOLEAN NOT NULL DEFAULT TRUE,
        
        -- Audit columns
        source_updated_at   TIMESTAMP,
        dw_created_at       TIMESTAMP DEFAULT NOW()
    );
    
    -- Critical indexes for query performance
    CREATE INDEX idx_dim_customer_t2_natural_key ON dim_customer_t2 (customer_id);
    CREATE INDEX idx_dim_customer_t2_current ON dim_customer_t2 (customer_id, is_current);
    CREATE INDEX idx_dim_customer_t2_dates ON dim_customer_t2 (customer_id, effective_from, effective_to);
    

    A few design choices here worth explaining:

    Surrogate key (customer_sk): This is what your fact table's foreign key references. Every order placed by Acme Corp while they were "Enterprise" in "Northeast" links to the surrogate key that represents that specific version. That's what makes the history queries work.

    effective_to = NULL: There are two schools of thought here. Some teams use a sentinel date like 9999-12-31 to represent "still current," which simplifies some queries by avoiding IS NULL checks. I prefer NULL because it's semantically honest and avoids the Y10K problem, but either works — just be consistent.

    is_current: A redundant but useful flag. It makes "give me the current state of all customers" a simple filter instead of a MAX(effective_from) aggregation.

    SQL Implementation

    Here's a complete Type 2 load pattern using a staging table:

    -- Step 1: Load new/changed data into a staging table
    -- (Assume staging_crm_customers is already populated)
    
    -- Step 2: Close out records that have changed
    UPDATE dim_customer_t2 AS dim
    SET
        effective_to = CURRENT_DATE - INTERVAL '1 day',
        is_current = FALSE
    FROM staging_crm_customers AS stg
    WHERE
        dim.customer_id = stg.customer_id
        AND dim.is_current = TRUE
        AND (
            dim.segment       != stg.segment       OR
            dim.region        != stg.region        OR
            dim.account_owner != stg.account_owner OR
            dim.company_name  != stg.company_name
        );
    
    -- Step 3: Insert new versions for changed records + brand-new records
    INSERT INTO dim_customer_t2 (
        customer_id, company_name, segment, region, account_owner,
        effective_from, effective_to, is_current, source_updated_at
    )
    SELECT
        stg.customer_id,
        stg.company_name,
        stg.segment,
        stg.region,
        stg.account_owner,
        CURRENT_DATE AS effective_from,
        NULL AS effective_to,
        TRUE AS is_current,
        stg.updated_at AS source_updated_at
    FROM staging_crm_customers stg
    LEFT JOIN dim_customer_t2 dim
        ON stg.customer_id = dim.customer_id
        AND dim.is_current = TRUE
    WHERE
        -- New customer (no current record exists)
        dim.customer_id IS NULL
        OR
        -- Changed customer (current record was just closed out above)
        (
            dim.customer_id IS NOT NULL AND (
                dim.segment       != stg.segment       OR
                dim.region        != stg.region        OR
                dim.account_owner != stg.account_owner OR
                dim.company_name  != stg.company_name
            )
        );
    

    Warning: These two steps must execute in the same transaction. If step 2 succeeds and step 3 fails, you've closed out active records without inserting replacements — your dimension is now broken. Wrap them in BEGIN; ... COMMIT; or use your orchestration layer's transaction handling.

    Python Implementation

    The Python version is more explicit about the logic, which makes it easier to test and debug:

    import pandas as pd
    import numpy as np
    from datetime import date, timedelta
    import sqlalchemy as sa
    from sqlalchemy import text
    
    def apply_scd_type2(
        engine,
        new_data: pd.DataFrame,
        target_table: str,
        natural_key: str,
        tracked_columns: list,
        effective_date: date = None
    ):
        """
        Apply SCD Type 2 logic.
        
        tracked_columns: list of column names that, when changed, trigger a new version
        effective_date: the date the new version becomes effective (defaults to today)
        """
        if effective_date is None:
            effective_date = date.today()
        
        close_date = effective_date - timedelta(days=1)
        
        with engine.begin() as conn:
            # Load only current records from the dimension
            existing_current = pd.read_sql(
                f"SELECT * FROM {target_table} WHERE is_current = TRUE",
                conn
            )
            
            if existing_current.empty:
                # First load — insert everything as-is
                print("First load detected. Inserting all records as initial versions.")
                new_data = new_data.copy()
                new_data['effective_from'] = effective_date
                new_data['effective_to'] = None
                new_data['is_current'] = True
                new_data['dw_created_at'] = pd.Timestamp.now()
                new_data.to_sql(target_table, conn, if_exists='append', index=False)
                return
            
            # Merge new data with existing current records
            merged = new_data.merge(
                existing_current,
                on=natural_key,
                how='outer',
                suffixes=('_new', '_old'),
                indicator=True
            )
            
            # --- Identify the three cases ---
            
            # Case 1: Brand new customers (in new data, not in dimension)
            new_customers = merged[merged['_merge'] == 'left_only'].copy()
            
            # Case 2: Existing customers with no changes (skip these entirely)
            # Case 3: Existing customers with changes
            existing_customers = merged[merged['_merge'] == 'both'].copy()
            
            # Build a mask for changed records
            changed_mask = pd.Series(False, index=existing_customers.index)
            for col in tracked_columns:
                new_col = f'{col}_new' if f'{col}_new' in existing_customers.columns else col
                old_col = f'{col}_old' if f'{col}_old' in existing_customers.columns else col
                # Handle NaN comparisons correctly
                changed_mask = changed_mask | (
                    existing_customers[new_col].fillna('__NULL__') != 
                    existing_customers[old_col].fillna('__NULL__')
                )
            
            changed_customers = existing_customers[changed_mask].copy()
            unchanged_count = len(existing_customers) - len(changed_customers)
            
            print(f"New customers: {len(new_customers)}")
            print(f"Changed customers: {len(changed_customers)}")
            print(f"Unchanged customers: {unchanged_count} (skipped)")
            
            if changed_customers.empty and new_customers.empty:
                print("No changes detected. Nothing to do.")
                return
            
            # --- Step 1: Close out changed records ---
            if not changed_customers.empty:
                changed_sks = changed_customers['customer_sk'].dropna().tolist()
                # Build parameterized query
                sk_placeholders = ','.join([str(int(sk)) for sk in changed_sks])
                conn.execute(text(f"""
                    UPDATE {target_table}
                    SET effective_to = :close_date,
                        is_current = FALSE
                    WHERE customer_sk IN ({sk_placeholders})
                """), {"close_date": close_date})
                print(f"Closed out {len(changed_sks)} existing records.")
            
            # --- Step 2: Insert new versions for changed records ---
            if not changed_customers.empty:
                # Extract new-value columns
                new_cols = {col: f'{col}_new' for col in tracked_columns 
                           if f'{col}_new' in changed_customers.columns}
                
                new_versions = changed_customers[[natural_key]].copy()
                for col, new_col in new_cols.items():
                    new_versions[col] = changed_customers[new_col].values
                
                # Add non-tracked columns that we still need
                other_cols = [c for c in new_data.columns 
                             if c != natural_key and c not in tracked_columns]
                for col in other_cols:
                    col_new = f'{col}_new' if f'{col}_new' in changed_customers.columns else col
                    if col_new in changed_customers.columns:
                        new_versions[col] = changed_customers[col_new].values
                
                new_versions['effective_from'] = effective_date
                new_versions['effective_to'] = None
                new_versions['is_current'] = True
                new_versions['dw_created_at'] = pd.Timestamp.now()
                new_versions.to_sql(target_table, conn, if_exists='append', index=False)
            
            # --- Step 3: Insert entirely new customers ---
            if not new_customers.empty:
                insert_cols = [c.replace('_new', '') for c in new_customers.columns 
                              if c.endswith('_new')]
                
                new_inserts = pd.DataFrame()
                for col in new_data.columns:
                    col_key = f'{col}_new' if f'{col}_new' in new_customers.columns else col
                    if col_key in new_customers.columns:
                        new_inserts[col] = new_customers[col_key].values
                
                new_inserts['effective_from'] = effective_date
                new_inserts['effective_to'] = None
                new_inserts['is_current'] = True
                new_inserts['dw_created_at'] = pd.Timestamp.now()
                new_inserts.to_sql(target_table, conn, if_exists='append', index=False)
    

    Querying a Type 2 Dimension

    This is where the investment pays off. Want to know what every customer's segment was when they placed orders in Q4 2023?

    -- Join facts to the correct historical version of the customer dimension
    SELECT
        o.order_id,
        o.order_date,
        o.revenue,
        c.customer_id,
        c.company_name,
        c.segment,
        c.region
    FROM fact_orders o
    JOIN dim_customer_t2 c
        ON o.customer_sk = c.customer_sk  -- surrogate key join — pinned to exact version
    
    -- Alternative: if your fact table only has the natural key (less ideal)
    SELECT
        o.order_id,
        o.order_date,
        o.revenue,
        c.company_name,
        c.segment
    FROM fact_orders o
    JOIN dim_customer_t2 c
        ON o.customer_id = c.customer_id
        AND o.order_date BETWEEN c.effective_from AND COALESCE(c.effective_to, '9999-12-31')
    

    The surrogate key join is vastly more efficient than the date-range join. If your fact table only stores the natural key, every query has to evaluate the BETWEEN predicate across all dimension versions. At scale, this kills performance. Store the surrogate key in your fact table and populate it at load time.

    Handling Late-Arriving Data

    Here's a tricky production scenario: a CRM record that changed on March 10th arrives in your pipeline on March 20th (maybe the source system had an outage). How do you handle it?

    With Type 2, you have two options:

    1. Process as-of today: The new version becomes effective on March 20th. Simpler, but historical reports between March 10-20 will show the wrong values.
    2. Back-date the version: Set effective_from to March 10th and adjust the previous version's effective_to accordingly. Accurate, but complex — you may need to re-close existing records and potentially re-open ones that were already closed.

    For most teams, option 1 is the pragmatic choice. Document the decision clearly. If you need option 2, you'll also want to trigger a downstream reprocessing of any fact records that fall in the affected date range.


    SCD Type 3: Remembering One Version Back

    Type 3 takes a different trade-off: instead of adding rows over time, it adds columns. You keep the current value and the previous value side by side in the same row.

    When to use it: When you need to support "as-was" analysis but history beyond one change isn't needed — and when you want query simplicity over full auditing. A common use case is tracking reorganizations: "Show me revenue by the new territory structure, but also let me see the old territory breakdown for comparison."

    When to avoid it: If attributes change more than once (and they will), you lose the intermediate history. Type 3 also gets messy fast if you have many tracked attributes — you end up with a proliferating column structure.

    The Table Structure

    CREATE TABLE dim_customer_t3 (
        customer_id             INTEGER PRIMARY KEY,
        
        -- Current values
        company_name            VARCHAR(200),
        current_segment         VARCHAR(50),
        current_region          VARCHAR(50),
        current_account_owner   VARCHAR(100),
        
        -- Previous values (populated when a change occurs)
        prior_segment           VARCHAR(50),
        prior_region            VARCHAR(50),
        prior_account_owner     VARCHAR(100),
        
        -- When the most recent change happened
        segment_changed_at      TIMESTAMP,
        region_changed_at       TIMESTAMP,
        
        -- Standard audit fields
        source_updated_at       TIMESTAMP,
        dw_updated_at           TIMESTAMP DEFAULT NOW()
    );
    

    SQL Implementation

    -- Update Type 3 dimension
    -- The key: move current values to prior_ columns BEFORE overwriting them
    UPDATE dim_customer_t3 AS dim
    SET
        -- Shift current values to prior columns
        prior_segment           = CASE WHEN stg.segment != dim.current_segment 
                                       THEN dim.current_segment 
                                       ELSE dim.prior_segment END,
        prior_region            = CASE WHEN stg.region != dim.current_region 
                                       THEN dim.current_region 
                                       ELSE dim.prior_region END,
        prior_account_owner     = CASE WHEN stg.account_owner != dim.current_account_owner 
                                       THEN dim.current_account_owner 
                                       ELSE dim.prior_account_owner END,
        
        -- Set change timestamps
        segment_changed_at      = CASE WHEN stg.segment != dim.current_segment 
                                       THEN NOW() 
                                       ELSE dim.segment_changed_at END,
        region_changed_at       = CASE WHEN stg.region != dim.current_region 
                                       THEN NOW() 
                                       ELSE dim.region_changed_at END,
        
        -- Apply new current values
        current_segment         = stg.segment,
        current_region          = stg.region,
        current_account_owner   = stg.account_owner,
        company_name            = stg.company_name,
        source_updated_at       = stg.updated_at,
        dw_updated_at           = NOW()
    
    FROM staging_crm_customers stg
    WHERE dim.customer_id = stg.customer_id
      AND (
          dim.current_segment       != stg.segment       OR
          dim.current_region        != stg.region        OR
          dim.current_account_owner != stg.account_owner OR
          dim.company_name          != stg.company_name
      );
    
    -- Insert new customers (no prior values yet)
    INSERT INTO dim_customer_t3 (
        customer_id, company_name,
        current_segment, current_region, current_account_owner,
        prior_segment, prior_region, prior_account_owner,
        source_updated_at
    )
    SELECT
        stg.customer_id, stg.company_name,
        stg.segment, stg.region, stg.account_owner,
        NULL, NULL, NULL,
        stg.updated_at
    FROM staging_crm_customers stg
    WHERE NOT EXISTS (
        SELECT 1 FROM dim_customer_t3 dim WHERE dim.customer_id = stg.customer_id
    );
    

    Python Implementation

    def apply_scd_type3(
        engine,
        new_data: pd.DataFrame,
        target_table: str,
        natural_key: str,
        tracked_columns: list  # list of column names to track with prior_ versions
    ):
        """
        Apply SCD Type 3 logic.
        Assumes dimension table has current_{col} and prior_{col} columns
        for each column in tracked_columns.
        """
        with engine.begin() as conn:
            existing = pd.read_sql(f"SELECT * FROM {target_table}", conn)
            
            if existing.empty:
                print("First load. Inserting all records with null prior values.")
                insert_df = new_data.copy()
                for col in tracked_columns:
                    insert_df[f'current_{col}'] = insert_df[col]
                    insert_df[f'prior_{col}'] = None
                    insert_df[f'{col}_changed_at'] = None
                    insert_df = insert_df.drop(columns=[col])
                insert_df['dw_updated_at'] = pd.Timestamp.now()
                insert_df.to_sql(target_table, conn, if_exists='append', index=False)
                return
            
            merged = new_data.merge(existing, on=natural_key, how='outer',
                                    suffixes=('_incoming', ''), indicator=True)
            
            new_records = merged[merged['_merge'] == 'left_only'].copy()
            existing_records = merged[merged['_merge'] == 'both'].copy()
            
            # Process updates
            for _, row in existing_records.iterrows():
                updates = {natural_key: row[natural_key]}
                changed = False
                
                for col in tracked_columns:
                    incoming_val = row.get(f'{col}_incoming', row.get(col))
                    current_val = row.get(f'current_{col}')
                    
                    if str(incoming_val) != str(current_val):
                        changed = True
                        updates[f'prior_{col}'] = current_val
                        updates[f'current_{col}'] = incoming_val
                        updates[f'{col}_changed_at'] = pd.Timestamp.now()
                
                if changed:
                    updates['dw_updated_at'] = pd.Timestamp.now()
                    set_clause = ', '.join([f"{k} = :{k}" for k in updates if k != natural_key])
                    conn.execute(
                        text(f"UPDATE {target_table} SET {set_clause} WHERE {natural_key} = :{natural_key}"),
                        updates
                    )
            
            # Insert new records
            if not new_records.empty:
                for _, row in new_records.iterrows():
                    insert_data = {natural_key: row[natural_key]}
                    for col in tracked_columns:
                        col_key = f'{col}_incoming' if f'{col}_incoming' in row.index else col
                        insert_data[f'current_{col}'] = row.get(col_key)
                        insert_data[f'prior_{col}'] = None
                        insert_data[f'{col}_changed_at'] = None
                    insert_data['dw_updated_at'] = pd.Timestamp.now()
                    
                    cols = ', '.join(insert_data.keys())
                    placeholders = ', '.join([f':{k}' for k in insert_data.keys()])
                    conn.execute(
                        text(f"INSERT INTO {target_table} ({cols}) VALUES ({placeholders})"),
                        insert_data
                    )
            
            print(f"Processed {len(existing_records)} existing + {len(new_records)} new records.")
    

    Choosing the Right Strategy: A Decision Framework

    Here's how to think about it systematically, not just by memorizing rules:

    Question If Yes → Consider
    Do users ever ask "what was X at the time of Y?" Type 2
    Does the attribute correct an error rather than represent real change? Type 1
    Is storage/cost a serious constraint? Type 1 or Type 3
    Do you only need current vs. one-version-ago comparisons? Type 3
    Do attributes change frequently (daily or weekly)? Type 1 (if history not needed) or Type 2 with compression strategy
    Are multiple attributes changing at different rates? Type 2 with per-attribute tracking, or hybrid

    Hybrid approaches are common and legitimate. It's entirely reasonable to apply Type 1 to corrective attributes (phone number, email) and Type 2 to analytically meaningful attributes (segment, territory) on the same dimension table. The key is being explicit about which columns get which treatment — document it, or future-you will be very confused.


    Hands-On Exercise

    Build a complete SCD Type 2 pipeline for a dim_product dimension. Here's your scenario:

    A retailer's product catalog changes regularly — prices get adjusted, categories get reorganized, and products move between suppliers. You need to preserve the full history so that margin analysis always reflects the cost and price at the time of sale.

    Your tasks:

    1. Create the dim_product_t2 table with appropriate surrogate key, natural key (product_sku), tracked attributes (price, cost, category, supplier_id), and versioning columns.

    2. Write the initial load SQL that takes the full product catalog and inserts it as the first version of each product (all records with effective_from = '2024-01-01' and is_current = TRUE).

    3. Using this simulated change batch, write the SQL to process the Type 2 update:

    -- Simulated incoming changes
    WITH product_changes AS (
        SELECT 'SKU-1001' as product_sku, 'Wireless Headphones Pro' as product_name, 
               149.99 as price, 62.00 as cost, 'Audio' as category, 201 as supplier_id
        UNION ALL
        SELECT 'SKU-1002', 'USB-C Hub 7-Port', 79.99, 31.50, 'Accessories', 205
        UNION ALL  
        SELECT 'SKU-1003', 'Mechanical Keyboard TKL', 129.99, 54.00, 'Peripherals', 203
        UNION ALL
        SELECT 'SKU-9001', 'Bluetooth Speaker Mini', 59.99, 22.00, 'Audio', 201  -- new product
    )
    

    Assume SKU-1001's price changed from 129.99 to 149.99, SKU-1002 is unchanged, and SKU-1003 moved to a new supplier (was 202, now 203). SKU-9001 is brand new.

    1. Write a query that shows, for each product, all historical versions ordered by effective date — so analysts can see the full change history.

    2. Bonus: Add a column version_number (1 for the first version, 2 for the second, etc.) to your historical query using a window function.


    Common Mistakes & Troubleshooting

    Mistake 1: Forgetting the Transaction Boundary in Type 2

    The close-then-insert pattern in Type 2 must be atomic. If your pipeline crashes between the UPDATE (closing records) and the INSERT (adding new versions), you've orphaned fact records — they point to surrogate keys that are now flagged as historical, and there's no current version to replace them.

    Fix: Always wrap the two operations in a single database transaction. In your orchestration layer, ensure retries replay the entire unit — not just the failed step.

    Mistake 2: Using Natural Keys in Fact Tables

    If your fact table stores customer_id (the natural business key) instead of customer_sk (the surrogate key), you've lost the precise version link. Every historical query now has to compute which dimension version was active for each fact row via a date range join — which is slow and error-prone.

    Fix: At fact load time, look up the current customer_sk and store it. One lookup at load time is vastly cheaper than recomputing it on every query.

    Mistake 3: Comparing NULLs Incorrectly

    In SQL, NULL != NULL evaluates to NULL (not TRUE). If an attribute changes from a real value to NULL, a naive WHERE dim.segment != stg.segment will miss it.

    Fix: Use IS DISTINCT FROM in PostgreSQL, or the equivalent COALESCE(dim.segment, '') != COALESCE(stg.segment, '') pattern in databases that don't support it.

    -- Correct NULL-safe comparison
    WHERE dim.segment IS DISTINCT FROM stg.segment
       OR dim.region  IS DISTINCT FROM stg.region
    

    Mistake 4: Processing the Full Snapshot on Every Run

    Loading your entire source table into a staging area and comparing every row on every pipeline run is wasteful and slow. At 10 million customers, this is a problem.

    Fix: Use the source system's updated_at timestamp to filter only changed records since your last run. Store your pipeline's high-water mark:

    def get_high_water_mark(engine, pipeline_name: str) -> datetime:
        with engine.connect() as conn:
            result = conn.execute(text("""
                SELECT last_processed_at 
                FROM pipeline_control 
                WHERE pipeline_name = :name
            """), {"name": pipeline_name})
            row = result.fetchone()
            return row[0] if row else datetime(2000, 1, 1)
    
    # Then filter source data:
    # SELECT * FROM crm_customers WHERE updated_at > :high_water_mark
    

    Mistake 5: Not Handling the "Same Day, Multiple Changes" Case

    If a source record changes twice in a single day and your pipeline runs once a day, you only see the final state. In Type 2, you'll generate one new version when there should logically be two.

    This is often acceptable — you document that your SCD Type 2 captures daily snapshots, not sub-daily changes. But if it's not acceptable, you need either an event-driven pipeline (CDC from the source system's transaction log) or you need the source to provide a full change history, not just a current snapshot.

    Mistake 6: The is_current Flag Gets Out of Sync

    If you ever update is_current without updating effective_to (or vice versa), your dimension becomes inconsistent. Some queries use is_current, others use effective_to IS NULL, and they'll return different results.

    Fix: Always update both columns together. Better yet, create a database trigger or application-level constraint that enforces consistency, and add a data quality check to your pipeline that flags any customer_id where COUNT(*) WHERE is_current = TRUE > 1.


    Summary & Next Steps

    Here's what you've built an understanding of:

    SCD Type 1 is appropriate for corrective changes and non-historical attributes. The implementation is a standard upsert with a change-detection predicate. Use it freely for attributes where history adds no analytical value.

    SCD Type 2 is the cornerstone of proper historical dimension management. It requires surrogate keys in your fact tables, careful transaction handling, and thoughtful index design — but it makes your analytics trustworthy. The core pattern (close old version, insert new version) is straightforward; the complexity lives in edge cases like late-arriving data and NULL handling.

    SCD Type 3 offers a middle ground with limited history. It's best for structured comparisons ("current vs. prior") and situations where full versioning is overkill. It degrades gracefully for attributes with low change frequency.

    For most production data warehouses, you'll end up using all three — applied at the column level based on the analytical requirements for each attribute. That's not a cop-out; it's good data modeling.

    Where to go from here:

    • Change Data Capture (CDC): Instead of batch-processing daily snapshots, CDC reads from your source database's transaction log to capture every individual change event. This enables sub-daily SCD processing and is the pattern you need for near-real-time warehousing. Tools like Debezium and Fivetran implement this.

    • dbt Snapshots: If your stack includes dbt, its snapshot feature implements SCD Type 2 with a declarative YAML configuration. Understanding the raw SQL implementation (as you do now) will help you configure dbt snapshots correctly and debug them when they produce unexpected results.

    • Fact Table Loading: SCD Type 2 only works if your fact table correctly captures the surrogate key at load time. The next layer of complexity is building a fact table loader that looks up the right dimension version for each incoming record — particularly when facts and dimension changes arrive in the same batch.

    • SCD Type 4 and Type 6: These less common variants extend the ideas you've learned here. Type 4 splits current data into a separate "mini-dimension" for query performance. Type 6 is a hybrid of Types 1, 2, and 3 that adds "current value" columns to the Type 2 table for easier querying.

    Learning Path: Data Pipeline Fundamentals

    Previous

    Data Serialization Formats for Pipelines: When to Use JSON, CSV, Parquet, and Avro

    Next

    Dynamic DAG Generation: Building Programmatic, Metadata-Driven Pipeline Factories at Scale

    Related Articles

    Data Engineering🔥 Expert

    Migrating a Monolithic dbt Project to a Multi-Project Architecture with dbt Mesh

    27 min
    Data Engineering🔥 Expert

    Cross-System Transaction Management: Implementing Distributed Sagas and Compensating Transactions for Multi-Sink Data Pipelines

    24 min
    Data Engineering⚡ Practitioner

    Building and Managing dbt Packages: Reusable Macros, Models, and Tests Across Projects

    20 min
  • Querying a Type 2 Dimension
  • Handling Late-Arriving Data
  • SCD Type 3: Remembering One Version Back
  • The Table Structure
  • SQL Implementation
  • Python Implementation
  • Choosing the Right Strategy: A Decision Framework
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Mistake 1: Forgetting the Transaction Boundary in Type 2
  • Mistake 2: Using Natural Keys in Fact Tables
  • Mistake 3: Comparing NULLs Incorrectly
  • Mistake 4: Processing the Full Snapshot on Every Run
  • Mistake 5: Not Handling the "Same Day, Multiple Changes" Case
  • Mistake 6: The is_current Flag Gets Out of Sync
  • Summary & Next Steps