Learn how to replace brittle hardcoded `if-then-else` chains with proper lookup tables and substitution pipelines in Power Query M. This lesson covers Record-based lookups, Table joins, null handling, and reusable function patterns for professional data standardization.

Imagine you've just pulled transaction data from three different source systems. One system stores product categories as numeric codes like 1, 2, 3. Another uses abbreviations like ELEC, FURN, APPL. A third system uses full names — but spelled inconsistently, like Electronics, electronic, and ELECTRONICS. Your report needs one clean, consistent category name in every row.
This problem — translating raw codes and inconsistent values into standardized, human-readable labels — shows up constantly in real data work. It's called code translation or value mapping, and doing it manually (hardcoding a dozen if-then-else branches in your query) is a maintenance nightmare. Change one code, and you're editing M formula logic. Add a new category, and you're hunting through nested conditions. There's a better way.
By the end of this lesson, you'll know how to build lookup and substitution tables as proper M structures, apply them dynamically to any column, and package the whole thing as a reusable function that you can drop into any pipeline. This is the difference between a brittle one-off transformation and a production-ready data standardization system.
What you'll learn:
Record.Field, List.PositionOf, and Table.JoinYou should be comfortable with basic Power Query navigation and have some exposure to M syntax. If you've never written an M expression before, spend some time with M Language Fundamentals: Syntax, Types, and Expressions for Power Query first. You should also understand what Records and Lists are — if those feel fuzzy, List and Record Operations in M: Transform, Select, and Combine Data Structures will fill in the gaps quickly.
Before building the right solution, let's clearly see why the naive approach fails. Say you need to translate two-letter US state abbreviations to full names. The tempting approach looks like this:
= if [StateCode] = "CA" then "California"
else if [StateCode] = "TX" then "Texas"
else if [StateCode] = "NY" then "New York"
else "Unknown"
This works — until you need to add Alaska, update a name, or reuse this logic in a different query. Now you're copying and pasting conditional chains. You're modifying formula logic every time the business rules change. And there's no central place to see all your mappings at once.
The fundamental problem is that you've mixed data (the mapping) with logic (the transformation). Good data pipelines keep these separate. Your lookup table is data. Your transformation logic should accept any lookup table as input.
The simplest lookup structure in M is a Record. A record is a collection of named fields — essentially a key-value store. You can define one inline like this:
let
CategoryLookup = [
#"1" = "Electronics",
#"2" = "Furniture",
#"3" = "Appliances",
#"4" = "Clothing",
#"5" = "Sporting Goods"
]
in
CategoryLookup
Note
The #"..." syntax is M's way of quoting field names that start with digits or contain special characters. Field names in records must be valid identifiers, so numeric keys need quoting.
To look up a value, you use Record.Field:
Record.Field(CategoryLookup, "3")
// Returns: "Appliances"
Or using the dot shorthand when the key is known at author time:
CategoryLookup[#"3"]
// Returns: "Appliances"
The record approach is excellent when your lookup set is small, stable, and defined by the developer. But it has a limitation: field names must be text, and if a code doesn't exist in the record, M throws an error. We'll address both issues shortly.
Let's say your source data has a CategoryCode column with numeric codes. You want to add a CategoryName column by looking up each code. Here's the full query:
let
Source = Table.FromRecords({
[OrderID = 1001, CategoryCode = "1", Amount = 299.99],
[OrderID = 1002, CategoryCode = "3", Amount = 149.50],
[OrderID = 1003, CategoryCode = "2", Amount = 899.00],
[OrderID = 1004, CategoryCode = "5", Amount = 45.00],
[OrderID = 1005, CategoryCode = "9", Amount = 120.00] // Unknown code
}),
CategoryLookup = [
#"1" = "Electronics",
#"2" = "Furniture",
#"3" = "Appliances",
#"4" = "Clothing",
#"5" = "Sporting Goods"
],
AddCategoryName = Table.AddColumn(
Source,
"CategoryName",
each if Record.HasFields(CategoryLookup, [CategoryCode])
then Record.Field(CategoryLookup, [CategoryCode])
else "Unknown Category"
)
in
AddCategoryName
Notice the Record.HasFields check before calling Record.Field. This prevents errors when a code like "9" doesn't exist in your lookup. Instead of crashing the entire query, you get the fallback string "Unknown Category". This is the pattern you should always use when your input data might have values outside your mapping.
Tip
Record.HasFields can also accept a list of field names and returns true only if all of them exist. For single-key lookups, just pass the key as a single-element list: Record.HasFields(CategoryLookup, {"MyKey"}).
For larger or externally managed lookup tables, the best structure is a proper M Table. This lets you load your mappings from an Excel sheet, a SharePoint list, a database table, or a hardcoded inline table — and apply them using a Table join.
Here's a self-contained example using Table.FromRecords to define the lookup inline. In a real scenario, this could be a reference to another query that loads from Excel.
let
// Your transaction data
TransactionData = Table.FromRecords({
[InvoiceID = "INV-001", StatusCode = "PEND", Region = "NA"],
[InvoiceID = "INV-002", StatusCode = "APPR", Region = "EU"],
[InvoiceID = "INV-003", StatusCode = "REJT", Region = "APAC"],
[InvoiceID = "INV-004", StatusCode = "PEND", Region = "NA"],
[InvoiceID = "INV-005", StatusCode = "CLOS", Region = "EU"]
}),
// Your lookup table — this could come from another query
StatusLookup = Table.FromRecords({
[Code = "PEND", StatusName = "Pending Review", Priority = 1],
[Code = "APPR", StatusName = "Approved", Priority = 3],
[Code = "REJT", StatusName = "Rejected", Priority = 2],
[Code = "CLOS", StatusName = "Closed", Priority = 4]
}),
// Merge the two tables on matching codes
Merged = Table.NestedJoin(
TransactionData, {"StatusCode"},
StatusLookup, {"Code"},
"LookupResult",
JoinKind.LeftOuter
),
// Expand only the columns you need
Expanded = Table.ExpandTableColumn(
Merged, "LookupResult",
{"StatusName", "Priority"},
{"StatusName", "Priority"}
)
in
Expanded
This approach is significantly more powerful than the record method for several reasons:
StatusName and Priority)JoinKind.LeftOuter ensures rows without a match still appear in the output (with null values rather than errors)Key insight
The Table join approach is the right architecture for enterprise pipelines. Your lookup table becomes a managed configuration asset — version controlled, auditable, and editable by people who don't write M code.
For a deeper look at join strategies and when to use them, see Combining Queries with Table.NestedJoin, Table.Join, and Merge Strategies in Power Query M: When and How to Use Each.
After a left outer join, rows that didn't find a match in the lookup table will have null in the expanded columns. That's correct behavior, but you probably want to communicate something more meaningful than a blank cell.
You can add a fallback step:
// Replace nulls in StatusName with a meaningful fallback
HandleUnmatched = Table.TransformColumns(
Expanded,
{
{"StatusName", each if _ = null then "[Unmapped Code]" else _, type text},
{"Priority", each if _ = null then 99 else _, type number}
}
)
This way, unmapped codes get a clearly labeled placeholder, and numeric columns get a sentinel value (like 99) that you can filter on downstream to audit your unmapped records.
Here's where everything comes together. Instead of writing the same lookup logic in every query that needs it, you can extract it into a function. This is one of the highest-leverage techniques in Power Query M.
The goal: a function that accepts your raw value, your lookup record (or table), and a fallback, then returns the mapped value.
// Save this as a separate query named: fnLookupValue
(lookupRecord as record, inputKey as text, fallback as text) as text =>
let
Result = if Record.HasFields(lookupRecord, {inputKey})
then Record.Field(lookupRecord, inputKey)
else fallback
in
Result
Once saved as a query named fnLookupValue, you can call it from any other query:
let
Source = ..., // your data
CategoryLookup = [
#"1" = "Electronics",
#"2" = "Furniture",
#"3" = "Appliances"
],
AddCategoryName = Table.AddColumn(
Source,
"CategoryName",
each fnLookupValue(CategoryLookup, [CategoryCode], "Unknown")
)
in
AddCategoryName
To learn the full mechanics of building and structuring custom functions like this, check out Writing Custom M Functions from Scratch in Power Query.
For the table-join pattern, the reusable version is slightly more complex but far more flexible:
// Save this as: fnTableLookup
(
sourceTable as table,
sourceKeyCol as text,
lookupTable as table,
lookupKeyCol as text,
lookupValueCol as text,
fallbackValue as text
) as table =>
let
Merged = Table.NestedJoin(
sourceTable, {sourceKeyCol},
lookupTable, {lookupKeyCol},
"_LookupTemp",
JoinKind.LeftOuter
),
Expanded = Table.ExpandTableColumn(
Merged, "_LookupTemp",
{lookupValueCol},
{lookupValueCol}
),
HandleNulls = Table.TransformColumns(
Expanded,
{{lookupValueCol, each if _ = null then fallbackValue else _, type text}}
)
in
HandleNulls
This function is now a complete, self-contained lookup engine. You pass it your source table, tell it which columns to match on, specify which value to pull from the lookup table, and provide a fallback. The function handles the join, expansion, and null replacement internally.
Real data doesn't always use clean, predictable codes. Sometimes you have freeform text that needs normalization: "elec.", "Electronics", "ELEC", and "electronic goods" all mean the same thing, but they'll never match a simple code lookup.
The solution is a sequential substitution pipeline: first normalize the text (lowercase, trim, strip punctuation), then apply your lookup.
let
Source = Table.FromRecords({
[ProductID = "P001", RawCategory = " Electronics "],
[ProductID = "P002", RawCategory = "FURNITURE"],
[ProductID = "P003", RawCategory = "elec."],
[ProductID = "P004", RawCategory = "sporting goods"],
[ProductID = "P005", RawCategory = "Apparel & Clothing"]
}),
// Step 1: Normalize text before lookup
NormalizeText = Table.TransformColumns(Source, {
{"RawCategory", each Text.Lower(Text.Trim(_)), type text}
}),
// Step 2: A substitution table for known variants
VariantLookup = [
electronics = "Electronics",
#"elec." = "Electronics",
#"elec" = "Electronics",
furniture = "Furniture",
#"sporting goods" = "Sporting Goods",
#"apparel & clothing" = "Clothing",
clothing = "Clothing"
],
// Step 3: Apply the lookup with fallback
AddStandardCategory = Table.AddColumn(
NormalizeText,
"StandardCategory",
each if Record.HasFields(VariantLookup, {[RawCategory]})
then Record.Field(VariantLookup, [RawCategory])
else "Review Needed"
)
in
AddStandardCategory
Warning
The Record.HasFields check uses exact matching after normalization. If your text has inconsistent spacing inside the phrase (like "sporting goods" with two spaces), the normalized text won't match your record key. A Text.Replace step to collapse multiple spaces is worth adding if you expect this in your data.
This pipeline is already a major improvement. For cases where even normalized text doesn't match reliably, you'd extend this approach with similarity scoring — a topic covered in detail in Implementing Custom Fuzzy Matching and Record Linkage Algorithms in Power Query M: Beyond Native Fuzzy Join.
In production, you almost never want to hardcode your lookup values in M. You want them in a maintainable, editable file. The most common approach is an Excel workbook with a dedicated "Mappings" sheet.
Here's how to wire up an externally managed lookup table:
let
Source = Excel.Workbook(
File.Contents("C:\DataPipelines\ReferenceData\Mappings.xlsx"),
null, true
),
MappingsSheet = Source{[Item="StatusMappings", Kind="Table"]}[Data],
TypedTable = Table.TransformColumnTypes(MappingsSheet, {
{"Code", type text},
{"StatusName", type text},
{"Priority", type number}
})
in
TypedTable
StatusLookupTableStatusLookupTable is now available like any other datasetNow your business team can update the Excel file, and the next refresh automatically picks up the new mappings without anyone touching M code. That's the real goal: data people manage the data; code manages the transformation logic.
Tip
If you're deploying to Power BI Service, replace file paths with SharePoint URLs or use a certified data source (like a database table or SharePoint list) so the gateway can reach it. Hardcoded local paths won't work in the cloud.
Open Power Query Editor and build the following pipeline from scratch. This exercise combines everything covered in this lesson.
Scenario: You work for a logistics company. Your shipment data uses carrier codes (FEDX, UPS, USPS, DHL, ONTR) and service level codes (GND, 2DA, NDAY, INT). You need a query that translates both codes and adds a ShippingCost_Tier classification based on the service level.
Step 1: Create a blank query and paste this source data:
let
Source = Table.FromRecords({
[ShipmentID = "SH-001", CarrierCode = "FEDX", ServiceCode = "NDAY", Weight_lbs = 5.2],
[ShipmentID = "SH-002", CarrierCode = "UPS", ServiceCode = "GND", Weight_lbs = 12.0],
[ShipmentID = "SH-003", CarrierCode = "USPS", ServiceCode = "2DA", Weight_lbs = 0.8],
[ShipmentID = "SH-004", CarrierCode = "DHL", ServiceCode = "INT", Weight_lbs = 3.5],
[ShipmentID = "SH-005", CarrierCode = "ONTR", ServiceCode = "GND", Weight_lbs = 45.0],
[ShipmentID = "SH-006", CarrierCode = "AMAZ", ServiceCode = "2DA", Weight_lbs = 2.1]
})
in
Source
Step 2: Add a carrier lookup record and translate CarrierCode to CarrierName. Use a fallback of "Unknown Carrier".
Step 3: Add a service level lookup table (as an M Table, not a record) with columns Code, ServiceName, and CostTier. Use JoinKind.LeftOuter to merge it in.
Step 4: Handle nulls in ServiceName and CostTier for the "2DA" code that appears in the data (intentionally leave "2DA" out of your lookup table to test the null handling).
Step 5: Wrap the carrier lookup in a reusable function named fnCarrierLookup that takes a code and returns the name.
Expected final table should have columns: ShipmentID, CarrierCode, CarrierName, ServiceCode, ServiceName, CostTier, Weight_lbs.
"Expression.Error: The field '...' of the record wasn't found"
This happens when you use Record.Field or the dot accessor without first checking if the key exists. Always guard with Record.HasFields or wrap in a try...otherwise block. See Error Handling and Try-Otherwise Patterns in M: Building Production-Ready Power Query Solutions for the full pattern.
Case sensitivity causing mismatches
M string comparisons are case-sensitive by default. "FEDX" = "fedx" returns false. Always normalize to a consistent case before lookup. Use Text.Lower or Text.Upper on both sides.
Numeric codes failing in Record lookups
Record field names must be text. If your codes are numbers (actual number type, not text), converting them with Number.ToText([Code]) before the lookup is required. Better yet, ensure your source column is typed as text from the start.
Left outer join returning duplicate rows
If your lookup table has duplicate keys, the join will multiply your rows. Always ensure your lookup table has unique keys in the join column. Add a distinct step: Table.Distinct(LookupTable, {"Code"}).
Performance with large lookup tables
If your lookup table has thousands of rows and you're joining against millions of source rows, performance can degrade. Consider using Table.Buffer on the lookup table to load it into memory once. This topic is explored in M Language Performance Patterns and Anti-Patterns: Optimize Power Query for Speed.
You now have a complete toolkit for value mapping in Power Query M:
The key architectural principle to carry forward: separate your mapping data from your transformation logic. Your M code should be a generic engine. Your lookup tables should be the configuration that drives it.
From here, a natural next step is organizing your lookup functions and helper queries into a proper library. Building a Reusable Function Library in Power Query covers how to structure, document, and share these assets across a team or report suite. If your lookup pipelines grow into multi-stage ETL processes, Building Multi-Stage ETL Pipelines in Power Query M: Orchestrating Dependent Transformations with Modular Query Chains will show you how to orchestrate them cleanly.