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.

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:
You should be comfortable writing M code directly in the Advanced Editor, not just clicking through the Power Query UI. Specifically, you should understand:
let...in expressions work and how query steps chainTable.AddColumn, Table.TransformColumns, and List.AccumulateIf you're newer to writing custom M expressions, start with Understanding the M Formula Language: Syntax, Data Types, and Expression Basics before continuing here.
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?
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:
Weaknesses:
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:
Weaknesses:
7f3a9b... tells you nothing about the underlying entityKey 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.
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.
// 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 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
CurrentMaxKeyand 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.
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.
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.
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.HashDatais 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.
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.
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.
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.
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)
);
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 todbo.SurrogateKeyMappingbefore loadingDimCustomer. 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.
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:
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.
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.
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.
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.
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.
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.
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:
ProductID (int), ProductCode (varchar), ProductName, CategoryVendorSKU (string), Description, UnitCost, ProductFamilyxref.VendorProductMapping)dbo.DimProduct with a ProductKey (bigint) surrogateTasks:
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##".
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.
Build a StagingProducts_Vendor query that loads the CSV file, applies NormalizeProductKey("Vendor", [VendorSKU]), and uses Table.Buffer to prevent repeated file reads.
Build a SurrogateKeyMap_Product query that reads dbo.SurrogateKeyMapping filtered to EntityType = 'Product' and buffers the result.
Build a DimProduct_Load query that:
SurrogateKeyMap_Product on NaturalKeyProductKey columnProductKey results by assigning 0 (unknown member)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.
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.
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.
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.
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.
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.
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.
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:
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.