Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
Power Query

Implementing Surrogate Key Generation in Power Query: Deterministic Hashing, Sequence-Based Indexing, and Cross-Source Key Management for Data Warehouse Loads

Surrogate key generation is one of the most failure-prone steps in data warehouse ETL — especially when sources disagree on key formats. This deep-dive lesson teaches you deterministic hashing, offset-aware sequence indexing, and a robust cross-source mapping table pattern in pure M code.

🔥 Expert24 min readSep 13, 2026Updated Sep 23, 2026
Implementing Surrogate Key Generation in Power Query: Deterministic Hashing, Sequence-Based Indexing, and Cross-Source Key Management for Data Warehouse Loads
On this page
  • Introduction
  • Prerequisites
  • The Surrogate Key Landscape: Two Fundamental Approaches
  • Sequence-Based Surrogate Keys
  • Hash-Based (Deterministic) Surrogate Keys
  • Building Sequence-Based Surrogate Keys with Index Management
  • The Naive Approach (and Why It Fails)
  • The Right Approach: Offset-Aware Indexing
  • Multi-Source Indexing with Source System Offsets
  • Deterministic Hashing in M: Building Hash-Based Surrogate Keys
  • Using Crypto.HashData (Power BI Dataflows and Premium)
  • Folding a Hash to an Integer
  • Building a Hash Function Without Crypto.HashData
  • Natural Key Normalization: The Hidden Critical Step
  • Cross-Source Key Management: The Surrogate Key Mapping Table
  • Reading the Mapping Table in Power Query
  • Handling the Unknown Member and Null Natural Keys
  • Performance Considerations at Scale
  • Buffer the Mapping Table
  • Filter Early, Not Late
  • Hash Computation Parallelism
  • Managing Schema Evolution in the Key Management Layer
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Mistake 1: Not Buffering the Mapping Table
  • Mistake 2: Case-Sensitive Natural Key Mismatch
  • Mistake 3: IndexColumn Restart on Incremental Loads
  • Mistake 4: Natural Key Length Overflow
  • Mistake 5: Hash Function Unavailability in Non-Premium Contexts
  • Mistake 6: Forgetting the Unknown Member
  • Summary & Next Steps

Implementing Surrogate Key Generation in Power Query: Deterministic Hashing, Sequence-Based Indexing, and Cross-Source Key Management for Data Warehouse Loads

Introduction

You've built a beautiful staging pipeline. Data flows in from three source systems — a legacy CRM with integer customer IDs, a SaaS platform with GUIDs, and a flat-file export from an ancient ERP system using composite keys like "ORG-4422-CUST". Now you need to load a DimCustomer table into a SQL Server data warehouse. Every row needs a single, stable, integer surrogate key that works across all three sources, persists across incremental loads, and never collides.

This is the surrogate key problem, and it's one of the messiest challenges in data warehouse ETL work. Get it wrong and you'll spend a Friday night explaining to your team why 40,000 customer records suddenly have duplicate keys, or why the fact table's foreign keys are pointing into empty space because a fresh load overwrote last month's dimension assignments.

Power Query is not traditionally thought of as a surrogate key engine — that's usually SQL's domain. But with the right M code patterns, Power Query can handle deterministic hashing, sequence-based indexing with offset management, and cross-source key consolidation reliably and repeatably. By the end of this lesson, you'll be able to implement all three approaches, understand when to use each, and manage the edge cases that break naive implementations.

What you'll learn:

  • The architectural difference between sequence-based and hash-based surrogate keys, and when each is appropriate
  • How to implement deterministic SHA-256-style hashing in M using available primitives
  • How to build sequence-based surrogate keys with configurable offsets for incremental load safety
  • How to consolidate natural keys from heterogeneous sources into a unified surrogate key mapping table
  • How to manage cross-source key collisions, null natural keys, and late-arriving dimension members

Prerequisites

You should be comfortable writing M code directly in the Advanced Editor, not just clicking through the Power Query UI. Specifically, you should understand:

  • How let...in expressions work and how query steps chain
  • The difference between Table.AddColumn, Table.TransformColumns, and List.Accumulate
  • How merge queries work, including left anti-joins
  • Basic understanding of data warehouse dimension modeling concepts (what a surrogate key is and why it exists)

If you're newer to writing custom M expressions, start with Understanding the M Formula Language: Syntax, Data Types, and Expression Basics before continuing here.


The Surrogate Key Landscape: Two Fundamental Approaches

Before writing a single line of M code, you need to make a design decision that will affect every downstream query: are your surrogate keys sequence-based or hash-based?

Sequence-Based Surrogate Keys

A sequence-based key is simply an integer assigned in order: 1, 2, 3, ... or with an offset like 10001, 10002, 10003. The key itself carries no information — it's purely a row identifier. This is the classical approach in Ralph Kimball-style dimensional modeling.

Strengths:

  • Compact integer storage — fast joins in SQL
  • Easy to reason about when debugging
  • Natural compatibility with SQL identity columns and sequences
  • Simple to implement incrementally by tracking the current maximum key

Weaknesses:

  • The assignment depends on load order — if two loads run concurrently or rows arrive out of order, you can get different keys for the same business entity
  • Requires external state to manage what keys have already been assigned
  • Hard to pre-compute for a row without knowing what else is in the table

Hash-Based (Deterministic) Surrogate Keys

A hash-based key is computed from the row's natural key using a deterministic algorithm. Given the same input, you always get the same output — no state required. This is increasingly popular in modern data lakehouse patterns.

Strengths:

  • Stable across loads — the same natural key always produces the same surrogate key
  • No external state needed — the key can be computed anywhere, anytime
  • Enables parallel and distributed processing safely
  • Works beautifully for incremental loads where you need to pre-join before loading

Weaknesses:

  • Hash collisions (extremely rare but theoretically possible)
  • Larger storage footprint if you use full hash strings rather than numeric hashes
  • Can be opaque — looking at key 7f3a9b... tells you nothing about the underlying entity
  • Integer range folding from a hash can theoretically collide if done naively

Key insight

The right choice isn't universal. If you're loading into a traditional SQL Server data warehouse with integer foreign keys everywhere, sequence-based keys are usually better — they're smaller, faster to join on, and match the DBA's expectations. If you're building a lakehouse on Parquet files in Azure Data Lake or Fabric, hash-based keys are often preferable because they eliminate the centralized state problem entirely.


Building Sequence-Based Surrogate Keys with Index Management

The simplest surrogate key approach in Power Query is Table.AddIndexColumn. But as soon as you try to use it for incremental loads against a live warehouse, you'll discover its fatal flaw: it always starts at whatever number you tell it to, regardless of what's already in the destination.

The Naive Approach (and Why It Fails)

// DON'T do this for incremental loads
let
    Source = Sql.Database("dw-server", "Staging", [Query="SELECT * FROM stg.Customer"]),
    AddSurrogate = Table.AddIndexColumn(Source, "CustomerKey", 1, 1, Int64.Type)
in
    AddSurrogate

This looks fine on first load. On second load, it will generate keys starting at 1 again — creating duplicates for any rows that weren't there on the first load, and silently overwriting or colliding with existing dimension members.

The Right Approach: Offset-Aware Indexing

The fix is to query the current maximum key from your destination table, then start the index sequence above that. Here's the full pattern:

let
    // Step 1: Get the current maximum surrogate key from the warehouse dimension
    CurrentMaxKey = 
        let
            MaxQuery = Sql.Database(
                "dw-server", 
                "DataWarehouse", 
                [Query = "SELECT ISNULL(MAX(CustomerKey), 0) AS MaxKey FROM dbo.DimCustomer"]
            ),
            MaxValue = MaxQuery{0}[MaxKey]
        in
            MaxValue,

    // Step 2: Load new/changed records from staging
    StagingData = Sql.Database(
        "dw-server", 
        "Staging", 
        [Query = "SELECT * FROM stg.Customer WHERE IsNew = 1 OR IsChanged = 1"]
    ),

    // Step 3: Assign surrogate keys starting above the current maximum
    WithSurrogate = Table.AddIndexColumn(
        StagingData, 
        "CustomerKey", 
        CurrentMaxKey + 1,  // Start one above the current max
        1,                  // Increment by 1
        Int64.Type
    )
in
    WithSurrogate

Warning

This pattern has a race condition if two Power Query refreshes run simultaneously. Both might read the same CurrentMaxKey and generate overlapping sequences. For production pipelines with concurrent execution, use a SQL SEQUENCE object or IDENTITY column in the warehouse to assign keys, and have Power Query request keys via a stored procedure or sequence fetch. Power Query handles the transformation; SQL handles the sequence management.

Multi-Source Indexing with Source System Offsets

When you're pulling from multiple source systems, one clean pattern is to reserve key ranges per source system. This prevents cross-source collisions even without checking the current warehouse maximum:

let
    SourceOffsets = [
        CRM = 1000000,      // CRM customers: 1,000,001 to 1,999,999
        SaaS = 2000000,     // SaaS customers: 2,000,001 to 2,999,999  
        ERP = 3000000       // ERP customers: 3,000,001 to 3,999,999
    ],

    // CRM source
    CRMData = Sql.Database("crm-server", "CRM", [Query = "SELECT CustomerID, Name, Email FROM Customers"]),
    CRMMaxKey = Sql.Database("dw-server", "DW", [Query = "SELECT ISNULL(MAX(CustomerKey), " & Text.From(SourceOffsets[CRM]) & ") FROM DimCustomer WHERE SourceSystem = 'CRM'"]){0}[Column1],
    CRMWithKey = Table.AddIndexColumn(
        Table.AddColumn(CRMData, "SourceSystem", each "CRM", type text),
        "CustomerKey", 
        CRMMaxKey + 1, 
        1, 
        Int64.Type
    ),

    // SaaS source
    SaaSData = OData.Feed("https://api.saasplatform.com/customers"),
    SaaSMaxKey = Sql.Database("dw-server", "DW", [Query = "SELECT ISNULL(MAX(CustomerKey), " & Text.From(SourceOffsets[SaaS]) & ") FROM DimCustomer WHERE SourceSystem = 'SaaS'"]){0}[Column1],
    SaaSWithKey = Table.AddIndexColumn(
        Table.AddColumn(SaaSData, "SourceSystem", each "SaaS", type text),
        "CustomerKey",
        SaaSMaxKey + 1,
        1,
        Int64.Type
    ),

    // Combine
    Combined = Table.Combine([CRMWithKey, SaaSWithKey])
in
    Combined

This approach is clean and easy to audit. The trade-off is that your key ranges are hardcoded limits — if CRM ever exceeds 999,999 customers, you have a collision problem. For most enterprise scenarios this is fine, but document those limits explicitly.


Deterministic Hashing in M: Building Hash-Based Surrogate Keys

M doesn't ship with a native SHA256() or MD5() function that returns a string you can use directly as a surrogate key. What it does have is Binary.FromText, Crypto.HashData (available in Power BI Desktop and Dataflows), and a suite of binary manipulation functions. Let's work through this properly.

Using Crypto.HashData (Power BI Dataflows and Premium)

In Power BI Dataflows and some Premium capacity contexts, Crypto.HashData is available:

let
    Source = Sql.Database("crm-server", "CRM", [Query = "SELECT CustomerID, Email FROM Customers"]),
    
    // Build a composite natural key string
    WithNaturalKey = Table.AddColumn(
        Source, 
        "NaturalKey", 
        each "CRM|" & Text.From([CustomerID]) & "|" & Text.Lower(Text.Trim([Email])),
        type text
    ),
    
    // Hash the natural key to get a deterministic surrogate
    WithHash = Table.AddColumn(
        WithNaturalKey,
        "CustomerHashKey",
        each 
            let
                InputBytes = Text.ToBinary([NaturalKey], TextEncoding.Utf8),
                HashBytes = Crypto.HashData("SHA256", InputBytes),
                HashHex = Binary.ToText(HashBytes, BinaryEncoding.Hex)
            in
                HashHex,
        type text
    )
in
    WithHash

This produces a 64-character hex string like "a3f7b2c1..." as your surrogate key. For most warehouse loads, you'd store this as CHAR(64) or NVARCHAR(64) in SQL Server. It's deterministic — the same email and customer ID will always produce the same hash.

Note

Crypto.HashData is not available in all Power Query contexts. It works in Power BI Service Dataflows and in Premium capacity refreshes, but may not be available in Power BI Desktop on some builds, Excel's Power Query, or SSIS/ADF Power Query contexts. Test your deployment target before committing to this approach.

Folding a Hash to an Integer

Sometimes you need an integer surrogate key but still want hash-based determinism. You can fold a SHA-256 hash into an integer range using modular arithmetic. The collision probability is extremely low at reasonable dataset sizes:

let
    Source = Sql.Database("crm-server", "CRM", [Query = "SELECT CustomerID, Email FROM Customers"]),
    
    WithNaturalKey = Table.AddColumn(
        Source, 
        "NaturalKey",
        each "CRM|" & Text.From([CustomerID]) & "|" & Text.Lower(Text.Trim([Email])),
        type text
    ),
    
    WithIntegerKey = Table.AddColumn(
        WithNaturalKey,
        "CustomerKey",
        each
            let
                InputBytes = Text.ToBinary([NaturalKey], TextEncoding.Utf8),
                HashBytes = Crypto.HashData("SHA256", InputBytes),
                // Take the first 8 bytes of the hash (64 bits)
                // and convert to a positive Int64
                FirstEightBytes = Binary.Range(HashBytes, 0, 8),
                AsNumber = Number.FromText(
                    Binary.ToText(FirstEightBytes, BinaryEncoding.Hex), 
                    16
                ),
                // Keep it positive and within SQL Server BigInt range
                PositiveKey = Number.Abs(Number.Mod(AsNumber, 9007199254740991))
            in
                PositiveKey,
        Int64.Type
    )
in
    WithIntegerKey

Warning

Integer-folded hashes introduce collision risk that pure string hashes don't. For a table with 10 million rows using a 63-bit folded hash, the birthday paradox probability of at least one collision is approximately 0.005% — small but non-zero. Always add a post-load validation step that checks for duplicate surrogate keys in your dimension table. See Implementing Data Validation and Quality Checks in Power Query for how to build that assertion layer.

Building a Hash Function Without Crypto.HashData

When Crypto.HashData isn't available, you need a workaround. One practical approach is to build a high-quality composite key string and use it directly as a string surrogate — no hashing at all — but that's only workable if your warehouse schema allows VARCHAR primary keys.

Alternatively, you can call an external endpoint to perform hashing, but that creates refresh-time dependencies. The most pragmatic fallback in constrained environments is to use a sequence-based key with a natural key lookup table that maps source keys to surrogates, stored externally. We'll cover that in the Cross-Source Key Management section.

Natural Key Normalization: The Hidden Critical Step

The quality of a hash-based surrogate key depends entirely on the consistency of the natural key you feed into the hash function. This is where most implementations break down.

Consider an email address: "John.Smith@ACME.COM" and "john.smith@acme.com" are the same customer, but they'll produce completely different hashes if you don't normalize first. Here's a robust normalization function:

// Define as a helper function query called "NormalizeNaturalKey"
(sourceSystem as text, businessKey as text) as text =>
let
    // Remove leading/trailing whitespace
    Trimmed = Text.Trim(businessKey),
    
    // Lowercase for case-insensitive keys (emails, names, codes)
    Lowered = Text.Lower(Trimmed),
    
    // Remove any embedded newlines or carriage returns that might
    // come from copy-paste data entry errors
    NoNewlines = Text.Replace(Text.Replace(Lowered, "#(lf)", ""), "#(cr)", ""),
    
    // Collapse multiple internal spaces to single space
    NormalizedSpaces = Text.Combine(
        List.Select(
            Text.Split(NoNewlines, " "), 
            each _ <> ""
        ), 
        " "
    ),
    
    // Prefix with source system to guarantee cross-source uniqueness
    Prefixed = sourceSystem & "|" & NormalizedSpaces
in
    Prefixed

This function can then be called in any query:

WithNaturalKey = Table.AddColumn(
    Source,
    "NaturalKey",
    each NormalizeNaturalKey("CRM", Text.From([CustomerID])),
    type text
)

Building this as a separate reusable function query is essential for maintainability. For deeper coverage of building modular M function libraries, see Building Reusable Power Query Function Libraries: Parameters, Recursion, and Modular M Code Patterns.


Cross-Source Key Management: The Surrogate Key Mapping Table

The surrogate key mapping table (sometimes called the key translation table or xref table) is the heart of any multi-source dimension load. Its job is simple: given a source system name and a natural key, return the surrogate key. If no surrogate exists yet, assign a new one.

This table typically lives in your staging or reference layer in the warehouse, not in the dimension itself:

CREATE TABLE dbo.SurrogateKeyMapping (
    MappingID        BIGINT IDENTITY(1,1) PRIMARY KEY,
    EntityType       VARCHAR(50)  NOT NULL,  -- 'Customer', 'Product', etc.
    SourceSystem     VARCHAR(50)  NOT NULL,
    NaturalKey       VARCHAR(500) NOT NULL,
    SurrogateKey     BIGINT       NOT NULL,
    CreatedDate      DATETIME2    NOT NULL DEFAULT SYSUTCDATETIME(),
    CONSTRAINT UQ_NaturalKey UNIQUE (EntityType, SourceSystem, NaturalKey),
    CONSTRAINT UQ_SurrogateKey UNIQUE (EntityType, SurrogateKey)
);

Reading the Mapping Table in Power Query

When loading a dimension, the Power Query pipeline becomes a lookup-and-merge operation:

let
    // Step 1: Load all three sources and normalize natural keys
    CRMCustomers = 
        let
            Raw = Sql.Database("crm-server", "CRM", [Query = "SELECT CustomerID, Name, Email, Phone FROM Customers"]),
            WithKeys = Table.AddColumn(Raw, "NaturalKey", each NormalizeNaturalKey("CRM", Text.From([CustomerID])), type text),
            WithSource = Table.AddColumn(WithKeys, "SourceSystem", each "CRM", type text)
        in
            WithSource,

    SaaSCustomers =
        let
            Raw = OData.Feed("https://api.saasplatform.com/v2/customers", null, [Implementation="2.0"]),
            WithKeys = Table.AddColumn(Raw, "NaturalKey", each NormalizeNaturalKey("SaaS", [id]), type text),
            WithSource = Table.AddColumn(WithKeys, "SourceSystem", each "SaaS", type text)
        in
            WithSource,

    ERPCustomers =
        let
            Raw = Csv.Document(File.Contents("\\fileserver\exports\erp_customers.csv"), [Delimiter=",", Encoding=65001]),
            Promoted = Table.PromoteHeaders(Raw, [PromoteAllScalars=true]),
            WithKeys = Table.AddColumn(Promoted, "NaturalKey", each NormalizeNaturalKey("ERP", [CustomerCode]), type text),
            WithSource = Table.AddColumn(WithKeys, "SourceSystem", each "ERP", type text)
        in
            WithSource,

    // Step 2: Combine all sources  
    AllCustomers = Table.Combine([CRMCustomers, SaaSCustomers, ERPCustomers]),

    // Step 3: Load the existing surrogate key mapping
    ExistingMappings = Sql.Database(
        "dw-server", 
        "DataWarehouse",
        [Query = "SELECT NaturalKey, SurrogateKey FROM dbo.SurrogateKeyMapping WHERE EntityType = 'Customer'"]
    ),

    // Step 4: Left join to get existing surrogate keys
    WithExistingKeys = Table.NestedJoin(
        AllCustomers, 
        {"NaturalKey"}, 
        ExistingMappings, 
        {"NaturalKey"}, 
        "KeyLookup", 
        JoinKind.Left
    ),
    ExpandedKeys = Table.ExpandTableColumn(WithExistingKeys, "KeyLookup", {"SurrogateKey"}, {"ExistingKey"}),

    // Step 5: Separate new records (no existing surrogate key) from existing records
    ExistingRecords = Table.SelectRows(ExpandedKeys, each [ExistingKey] <> null),
    NewRecords = Table.SelectRows(ExpandedKeys, each [ExistingKey] = null),

    // Step 6: Assign new surrogate keys to new records
    CurrentMaxKey = Sql.Database(
        "dw-server",
        "DataWarehouse",
        [Query = "SELECT ISNULL(MAX(SurrogateKey), 0) FROM dbo.SurrogateKeyMapping WHERE EntityType = 'Customer'"]
    ){0}[Column1],
    
    NewRecordsWithKey = Table.AddIndexColumn(NewRecords, "NewKey", CurrentMaxKey + 1, 1, Int64.Type),
    NewRecordsNormalized = Table.AddColumn(
        NewRecordsWithKey,
        "CustomerKey",
        each [NewKey],
        Int64.Type
    ),

    // Step 7: Align schemas and reunite
    ExistingNormalized = Table.AddColumn(ExistingRecords, "CustomerKey", each [ExistingKey], Int64.Type),
    
    FinalCustomers = Table.Combine([
        Table.RemoveColumns(ExistingNormalized, {"ExistingKey"}),
        Table.RemoveColumns(NewRecordsNormalized, {"NewKey", "ExistingKey"})
    ])

in
    FinalCustomers

This pipeline is the backbone of a robust multi-source load. The key insight is that the mapping table acts as the source of truth for key assignments, not the destination dimension itself.

Tip

In a full production implementation, after computing NewRecordsWithKey, you'd write those new mappings back to dbo.SurrogateKeyMapping before loading DimCustomer. In Power Query, "writing back" typically means loading a separate query whose output targets that mapping table. Structure your Power Query workspace so that the mapping table load runs before the dimension load — use query dependencies to enforce this ordering. The article Orchestrating Multi-Query Refresh Dependencies in Power Query covers exactly this pattern.


Handling the Unknown Member and Null Natural Keys

Every dimension table in a Kimball-style warehouse needs an "unknown" member — the row that fact table records point to when the dimension key is unknown or null at load time. This row gets surrogate key 0 or -1 by convention.

Power Query needs to handle the case where incoming fact records reference a dimension natural key that doesn't exist in the mapping table. Rather than letting those fact records fail referential integrity, you either:

  1. Point them to the unknown member
  2. Create a new dimension row on-the-fly (late-arriving dimension handling)

Here's how to handle null and missing natural keys explicitly:

let
    FactOrders = Sql.Database("erp-server", "ERP", [Query = "SELECT OrderID, CustomerCode, Amount, OrderDate FROM Orders"]),

    // Normalize the natural key — handle nulls explicitly
    WithNaturalKey = Table.AddColumn(
        FactOrders,
        "CustomerNaturalKey",
        each 
            if [CustomerCode] = null or Text.Trim([CustomerCode]) = "" 
            then "##UNKNOWN##"
            else NormalizeNaturalKey("ERP", [CustomerCode]),
        type text
    ),

    // Look up surrogate keys
    MappingTable = Sql.Database(
        "dw-server",
        "DataWarehouse",
        [Query = "SELECT NaturalKey, SurrogateKey FROM dbo.SurrogateKeyMapping WHERE EntityType = 'Customer'"]
    ),

    // Add the unknown member mapping explicitly
    MappingWithUnknown = Table.Combine([
        MappingTable,
        #table(
            type table [NaturalKey = text, SurrogateKey = Int64.Type],
            {{"##UNKNOWN##", 0}}
        )
    ]),

    WithSurrogate = Table.NestedJoin(
        WithNaturalKey,
        {"CustomerNaturalKey"},
        MappingWithUnknown,
        {"NaturalKey"},
        "KeyLookup",
        JoinKind.Left
    ),
    Expanded = Table.ExpandTableColumn(WithSurrogate, "KeyLookup", {"SurrogateKey"}, {"CustomerKey"}),

    // Any remaining nulls (natural key exists but not in mapping yet) go to unknown
    FinalWithFallback = Table.TransformColumns(
        Expanded,
        {{"CustomerKey", each if _ = null then 0 else _, Int64.Type}}
    )
in
    FinalWithFallback

This pattern is directly related to slowly changing dimension handling, which you'll encounter whenever dimension attributes update over time. The Merging Slowly Changing Dimensions in Power Query: Tracking Historical Changes with Type 1 and Type 2 SCD Patterns article covers how surrogate keys interact with SCD Type 2 versioning, where each historical version of a dimension member gets its own surrogate key.


Performance Considerations at Scale

Surrogate key generation involves table joins, large lookups, and potentially row-by-row hash computation. At scale, these operations become bottlenecks. Here's how to keep them manageable.

Buffer the Mapping Table

If you're joining against SurrogateKeyMapping multiple times in the same refresh (once for customers, once for products, once for employees), each reference will re-evaluate the query against the database. Use Table.Buffer to load it once:

// Create a shared reference query
SurrogateKeyMappingBuffered = 
    Table.Buffer(
        Sql.Database(
            "dw-server",
            "DataWarehouse",
            [Query = "SELECT EntityType, NaturalKey, SurrogateKey FROM dbo.SurrogateKeyMapping"]
        )
    )

Reference this query from all your dimension loading queries rather than re-querying the database. This turns an N-queries-per-dimension-type problem into a single load. For a deep dive on query folding and buffering trade-offs, see Power Query Performance: Master Folding, Buffering & Optimization Techniques.

Filter Early, Not Late

When loading your staging data, push filters as close to the source as possible using native query parameters rather than M-side filtering:

// Better: filter in SQL
Raw = Sql.Database("crm-server", "CRM", 
    [Query = "SELECT CustomerID, Name, Email FROM Customers WHERE ModifiedDate > '2024-01-01'"])

// Worse: load everything then filter in M
Raw = Sql.Database("crm-server", "CRM", [Query = "SELECT * FROM Customers"]),
Filtered = Table.SelectRows(Raw, each [ModifiedDate] > #datetime(2024, 1, 1, 0, 0, 0))

The SQL-side filter folds all the way to the source. The M-side filter loads the full table into memory first. This matters enormously when Customers has 10 million rows.

Hash Computation Parallelism

When using Crypto.HashData in a custom column, Power Query evaluates each row's expression using its internal parallelism heuristics. For hash computation specifically, which is CPU-bound, this can be slow on large tables when run in Power BI Desktop (which has fewer resources than Service). Consider pre-computing hashes in your staging SQL layer when possible:

-- In your staging procedure, add a computed column
ALTER TABLE stg.Customer ADD
    NaturalKeyHash AS CONVERT(
        CHAR(64), 
        HASHBYTES('SHA2_256', CONCAT('CRM|', LOWER(LTRIM(RTRIM(Email))))),
        2
    ) PERSISTED;

Then just read the pre-computed hash in Power Query rather than computing it in M. This leverages SQL Server's SIMD-optimized hash computation and persists the result.

Key insight

The best surrogate key generation happens at the layer best equipped to do it efficiently. Power Query excels at orchestration, transformation, and lookup logic. SQL excels at hash computation and sequence management. Use each for what it does best rather than forcing everything into M code.


Managing Schema Evolution in the Key Management Layer

Source systems evolve. The ERP might change its customer code format from "ORG-4422-CUST" to "4422" after a system upgrade. When the natural key format changes, you need to update your normalization logic — but you also need to ensure that existing surrogate key mappings aren't broken.

The two strategies are:

Natural Key Migration: Update the NaturalKey values in SurrogateKeyMapping for affected rows, repoint them to match the new format. This is clean but requires a one-time migration script coordinated with the source system upgrade.

Natural Key Aliasing: Add an AliasNaturalKey column to your mapping table. When looking up surrogates, check both the primary and alias natural keys. This allows old and new formats to coexist during a transition period.

// Alias-aware lookup
MappingWithAliases = Sql.Database(
    "dw-server",
    "DataWarehouse",
    [Query = "
        SELECT NaturalKey, SurrogateKey FROM dbo.SurrogateKeyMapping WHERE EntityType = 'Customer'
        UNION
        SELECT AliasNaturalKey, SurrogateKey FROM dbo.SurrogateKeyMapping 
        WHERE EntityType = 'Customer' AND AliasNaturalKey IS NOT NULL
    "]
)

This is directly analogous to handling slowly changing lookups — the same point-in-time merging concepts apply. For more on this pattern, see Implementing Slowly Changing Lookup Tables in Power Query: Point-in-Time Merges with Effective Date Ranges.


Hands-On Exercise

In this exercise, you'll build a complete surrogate key pipeline for a DimProduct dimension loaded from two sources: a SQL Server product catalog and a CSV export from a vendor system.

Setup:

  • SQL Server product catalog: columns ProductID (int), ProductCode (varchar), ProductName, Category
  • CSV vendor file: columns VendorSKU (string), Description, UnitCost, ProductFamily
  • Some products exist in both sources (the vendor SKU maps to an internal product code via a cross-reference table xref.VendorProductMapping)
  • Your destination is dbo.DimProduct with a ProductKey (bigint) surrogate

Tasks:

  1. Create a NormalizeProductKey helper function that accepts a source system name and natural key string, trims whitespace, lowercases, and prefixes with the source system. Handle null inputs by returning "##UNKNOWN##".

  2. Build a StagingProducts_SQL query that loads the SQL Server product catalog, applies NormalizeProductKey("Catalog", [ProductCode]), and filters to only rows modified in the last 7 days.

  3. Build a StagingProducts_Vendor query that loads the CSV file, applies NormalizeProductKey("Vendor", [VendorSKU]), and uses Table.Buffer to prevent repeated file reads.

  4. Build a SurrogateKeyMap_Product query that reads dbo.SurrogateKeyMapping filtered to EntityType = 'Product' and buffers the result.

  5. Build a DimProduct_Load query that:

    • Appends the two staging queries
    • Left-joins against SurrogateKeyMap_Product on NaturalKey
    • Separates new records from existing records
    • Queries current max key and assigns new keys to new records starting at max + 1
    • Reunites both sets with a consistent ProductKey column
    • Handles null ProductKey results by assigning 0 (unknown member)
  6. Bonus: Add a deduplication step before surrogate key assignment. If the same NaturalKey appears in both the SQL source and the vendor CSV (via the cross-reference), keep the SQL Catalog version and discard the Vendor duplicate. Use the approach covered in Managing Many-to-Many Relationships in Power Query: Deduplication, Bridge Tables, and Safe Merge Strategies.

Validation check: After loading, the count of distinct ProductKey values in your output should equal the count of rows (no duplicates). The minimum ProductKey should be CurrentMaxKey + 1 for new records, and all existing records should have keys that match what's in SurrogateKeyMap_Product.


Common Mistakes & Troubleshooting

Mistake 1: Not Buffering the Mapping Table

Symptom: Refresh is extremely slow; query plan shows repeated SQL calls to SurrogateKeyMapping.

Cause: Every reference to the mapping table query triggers a re-evaluation. If five dimension queries all reference it, you get five database round-trips per refresh.

Fix: Wrap the mapping table query in Table.Buffer and reference it as a shared query from all dimension loads.


Mistake 2: Case-Sensitive Natural Key Mismatch

Symptom: Records that obviously correspond to the same entity are getting different surrogate keys across loads. DimCustomer has duplicate logical customers.

Cause: Natural key normalization isn't being applied consistently. CRM exports "ACME-001" sometimes and "acme-001" other times depending on who exported the file.

Fix: Apply Text.Lower and Text.Trim to all natural key components before hashing or mapping. Make this normalization happen in the NormalizeNaturalKey function so it's impossible to forget.


Mistake 3: IndexColumn Restart on Incremental Loads

Symptom: After the second or third incremental load, dimension table shows duplicate surrogate keys. Fact table joins return multiple rows per key.

Cause: Table.AddIndexColumn was called without first querying the current maximum key from the warehouse.

Fix: Always query MAX(SurrogateKey) from the warehouse (or the mapping table) before calling Table.AddIndexColumn, and use MaxKey + 1 as the starting index.


Mistake 4: Natural Key Length Overflow

Symptom: Composite natural keys are being truncated at 255 characters. Hashes are colliding more than expected.

Cause: Your NaturalKey column is defined as VARCHAR(255) in the mapping table, but some composite keys (especially from ERPs with multi-part keys) exceed that length.

Fix: Change NaturalKey in the mapping table to VARCHAR(500) or NVARCHAR(MAX) depending on your data. Add a validation step in Power Query that flags any natural key exceeding your defined length limit before it reaches the mapping table insert.


Mistake 5: Hash Function Unavailability in Non-Premium Contexts

Symptom: Query works in Power BI Desktop but fails in Excel's Power Query or in an on-premises gateway refresh with "Expression.Error: The name 'Crypto.HashData' doesn't exist."

Cause: Crypto.HashData is a Power BI-specific function, not universally available across all M environments.

Fix: Move hash computation to the SQL layer using HASHBYTES('SHA2_256', ...) and read the pre-computed value in Power Query. Or switch to a sequence-based approach with the SQL Server SEQUENCE object managing key assignment.


Mistake 6: Forgetting the Unknown Member

Symptom: Fact table load fails with foreign key constraint violations for records where the dimension natural key is null or blank.

Cause: No ##UNKNOWN## handling in the surrogate key lookup. Null natural keys don't match any mapping table entry, so they get no surrogate key, and the load fails.

Fix: Always add the unknown member (surrogate key 0) to your mapping table lookup before joining, and add a final Table.TransformColumns step to replace any remaining null surrogate keys with 0.


Summary & Next Steps

You've now seen the complete landscape of surrogate key generation in Power Query: the design trade-offs between sequence-based and hash-based approaches, concrete M implementations for both, and the surrogate key mapping table pattern that makes multi-source cross-system key management tractable.

The key architectural takeaways are:

  • Use sequence-based keys when you need compact integers, when your warehouse has SQL identity management infrastructure, and when you can reliably query the current maximum before each load
  • Use hash-based keys when you need stateless, deterministic key assignment — particularly in distributed or lakehouse architectures where there's no central sequence authority
  • The surrogate key mapping table is the single most important investment you can make in a multi-source dimension load — it decouples natural key management from the dimension schema and makes source system migrations survivable
  • Normalize natural keys aggressively — every inconsistency in natural key formatting will eventually produce a duplicate dimension member, and that failure is silent and insidious

The patterns in this article fit into a larger multi-stage architecture. Your staging layer receives raw data, your cleansed layer normalizes and validates it, and your conformed layer is where surrogate keys get assigned and dimension records are unified. For a complete view of how to structure those layers, see Building Multi-Stage Staging Architectures in Power Query: Separating Raw, Cleansed, and Conformed Layers for Scalable ETL Pipelines.

For incremental load management — where you need to track which records have already been processed and avoid reloading the full dimension on every refresh — the next essential topic is Automating Incremental Data Refreshes in Power Query with Persistent State and Change Tracking.

If you're working in a warehouse that also involves date dimension keys (as almost all do), the surrogate key patterns here apply directly to date surrogate key generation, covered in Building a Dynamic Date Dimension Table in Power Query Using Pure M Code.

The surrogate key is the foundation that everything else in your dimensional model stands on. Get it right once, and every incremental load, every SCD merge, every fact table join works reliably. Get it wrong and you're chasing phantom duplicates forever.

Work With Us

From insight to implementation

Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

Let's Build

Power Query Essentials

Previous

Implementing Data Validation and Quality Checks in Power Query: Building Automated Assertion Pipelines to Catch Nulls, Duplicates, and Referential Integrity Failures Before Load

Next

Understanding Table, List, and Record Data Structures in Power Query: How M Represents and Navigates Data

Related Insights

Power QueryFoundation

Scoping Variables and Let Expressions in M: Understanding Binding, Shadowing, and Expression Isolation in Power Query

17 min
Power QueryFoundation

Changing Data Types in Power Query: When, Why, and How to Set Column Types Correctly

19 min
Power QueryExpert

Implementing Custom OData Feed Clients in Power Query M: Handling Service Metadata, Entity Expansion, and Filter Pushdown for Enterprise API Integration

27 min

On this page

  • Introduction
  • Prerequisites
  • The Surrogate Key Landscape: Two Fundamental Approaches
  • Sequence-Based Surrogate Keys
  • Hash-Based (Deterministic) Surrogate Keys
  • Building Sequence-Based Surrogate Keys with Index Management
  • The Naive Approach (and Why It Fails)
  • The Right Approach: Offset-Aware Indexing
  • Multi-Source Indexing with Source System Offsets
  • Deterministic Hashing in M: Building Hash-Based Surrogate Keys
  • Using Crypto.HashData (Power BI Dataflows and Premium)
  • Folding a Hash to an Integer
  • Building a Hash Function Without Crypto.HashData
  • Natural Key Normalization: The Hidden Critical Step
  • Cross-Source Key Management: The Surrogate Key Mapping Table
  • Reading the Mapping Table in Power Query
  • Handling the Unknown Member and Null Natural Keys
  • Performance Considerations at Scale
  • Buffer the Mapping Table
  • Filter Early, Not Late
  • Hash Computation Parallelism
  • Managing Schema Evolution in the Key Management Layer
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Mistake 1: Not Buffering the Mapping Table
  • Mistake 2: Case-Sensitive Natural Key Mismatch
  • Mistake 3: IndexColumn Restart on Incremental Loads
  • Mistake 4: Natural Key Length Overflow
  • Mistake 5: Hash Function Unavailability in Non-Premium Contexts
  • Mistake 6: Forgetting the Unknown Member
  • Summary & Next Steps