When reference data changes — customer segments, sales territories, product categories — a simple merge applies today's values to all your historical records. This lesson shows you exactly how to build a slowly changing lookup table with effective date ranges and merge it correctly in Power Query for accurate point-in-time reporting.

Here's a scenario that should feel familiar. Your sales director walks in and asks why Q3 revenue looks different in this month's report than it did in last quarter's report. You investigate and discover the root cause: a customer was reclassified from "SMB" to "Enterprise" in October, and your lookup table reflects today's classification — not what it was when those Q3 transactions actually happened. Every historical transaction now shows the wrong segment. Your report is technically accurate as of today, but it's historically wrong, and that matters enormously for trend analysis, period-over-period comparisons, and audit trails.
This is the slowly changing dimension problem, and it's one of the most underappreciated challenges in practical data work. Most practitioners learn to do a simple merge in Power Query — match a key column, pull in the attribute — and that works perfectly as long as the reference data never changes. But reference data always changes eventually. Customers change segments. Products change categories. Sales reps change territories. Employees change departments. When you need your historical transactions to reflect the attribute values that were in effect at the time of the transaction, a simple merge catastrophically fails you.
By the end of this lesson, you'll know how to model reference data with effective date ranges, merge it against a transaction table using point-in-time logic in Power Query, and produce historically accurate reports that survive reclassification events without breaking. You'll work with a realistic sales scenario throughout.
What you'll learn:
EffectiveDate and ExpiryDate columnsTable.AddColumn with nested row-level lookups to find the correct historical recordYou should be comfortable with Power Query's core operations: importing data, basic merges, adding custom columns, and writing simple M expressions. You don't need to be an M language expert, but you should know the difference between Table.SelectRows, Table.AddColumn, and List.First. Familiarity with the concept of slowly changing dimensions (SCDs) from data warehousing is helpful but not required — we'll build the mental model from scratch.
Let's ground this with concrete data. Suppose you have a transactions table that looks like this:
| TransactionID | CustomerID | SaleDate | Revenue |
|---|---|---|---|
| T-1001 | C-42 | 2023-03-15 | 12,500 |
| T-1002 | C-42 | 2023-09-20 | 31,000 |
| T-1003 | C-42 | 2024-01-08 | 29,750 |
And a customer reference table:
| CustomerID | CustomerName | Segment |
|---|---|---|
| C-42 | Meridian Logistics | Enterprise |
A standard merge on CustomerID correctly identifies the customer name and segment for every transaction. But here's the problem: Meridian Logistics was classified as SMB until September 30, 2023, when they crossed the revenue threshold and were reclassified as Enterprise. Transaction T-1001 (March 2023) should show SMB. Transaction T-1002 (September 2023) should show SMB. Transaction T-1003 (January 2024) should correctly show Enterprise.
With a simple lookup table, all three transactions show Enterprise — because that's the current state, and there's no historical record of what the segment was before. Your trend reports will show Enterprise revenue going back to the beginning of time for this customer, which is misleading at best and audit-bait at worst.
The solution is to restructure your reference table to capture when each version of the record was valid.
The canonical approach is to add two columns to your reference data: EffectiveDate (when this version became valid) and ExpiryDate (when it stopped being valid, exclusive). This is sometimes called a Type 2 Slowly Changing Dimension in data warehouse terminology.
Your customer reference table should look like this:
| CustomerID | CustomerName | Segment | EffectiveDate | ExpiryDate |
|---|---|---|---|---|
| C-42 | Meridian Logistics | SMB | 2020-01-01 | 2023-10-01 |
| C-42 | Meridian Logistics | Enterprise | 2023-10-01 | 9999-12-31 |
| C-77 | Hargrove Systems | Mid-Market | 2019-06-15 | 9999-12-31 |
A few design decisions are baked into this structure that are worth understanding explicitly:
The ExpiryDate is exclusive. A transaction on 2023-10-01 belongs to the Enterprise record, not the SMB record. This means your lookup logic will use SaleDate >= EffectiveDate AND SaleDate < ExpiryDate. Some teams use an inclusive ExpiryDate instead (<=), which means the cutoff date is 2023-09-30 rather than 2023-10-01. Either convention works — what matters is that you're consistent and that your team documents the choice clearly.
Use a sentinel date for open-ended records. Instead of using null for records that are still active, use a far-future date like 9999-12-31. This makes the date comparison logic uniform — you don't need special null-handling in your M code. Every record participates in the same SaleDate < ExpiryDate check.
Each CustomerID can have multiple rows. This is the key structural difference from a simple lookup table. Your merge logic cannot simply match on CustomerID alone — it must also qualify the match by the date range.
Let's walk through the implementation step by step. We'll assume both tables are already loaded into Power Query — your Transactions table and your CustomerHistory table (the slowly changing lookup).
Before doing anything else, confirm that your date columns are actually recognized as Date type, not Text. In the Power Query editor, select the SaleDate column in Transactions, then check the data type icon in the column header. Do the same for EffectiveDate and ExpiryDate in CustomerHistory.
If any of them show as Text or Any, click the data type icon (or use the Transform tab → Data Type) and set them to Date. Getting this wrong will cause your date comparisons to behave alphabetically rather than chronologically — a subtle bug that's painful to diagnose later.
Power Query's built-in Merge Queries feature handles equi-joins beautifully — match where Column A equals Column B. But our join condition is not an equi-join. It's a range join:
CustomerID matches
AND SaleDate >= EffectiveDate
AND SaleDate < ExpiryDate
Power Query has no native UI for this. You could try merging on CustomerID first and then filtering on date range, but that approach creates a massive intermediate result — every transaction row gets all the historical records for that customer before you filter them down. For customers with long histories, this explodes memory usage and destroys performance.
The correct approach is to use Table.AddColumn with a row-level lookup function that does the full qualified match inline.
In your Transactions query, add a custom column. Go to Add Column → Custom Column and use the following logic:
Table.SelectRows(
CustomerHistory,
(lookup) =>
lookup[CustomerID] = [CustomerID] and
lookup[EffectiveDate] <= [SaleDate] and
lookup[ExpiryDate] > [SaleDate]
)
Let's break down what's happening here. For each row in Transactions, Power Query evaluates this expression. [CustomerID] and [SaleDate] refer to the current transaction row's values. (lookup) is the iterator variable — it represents each row in CustomerHistory as Power Query scans through it. The expression returns a filtered table containing only the CustomerHistory rows where all three conditions are true.
If your data is clean, this filtered table should contain exactly one row per transaction. If it contains zero rows, the customer had no active record on that date. If it contains more than one row, you have overlapping date ranges in your reference data — which is a data quality problem we'll address later.
Name this custom column MatchedRecord.
After Step 3, your MatchedRecord column contains a Table object in each cell. You now need to drill into those tables to pull out the specific columns you want.
Add another custom column to extract the Segment:
if Table.RowCount([MatchedRecord]) > 0
then [MatchedRecord]{0}[Segment]
else null
The {0} syntax retrieves the first (zero-indexed) row of the table. [Segment] then retrieves the Segment field from that row. The if guard handles the case where no matching record was found, returning null rather than throwing an error.
Repeat this pattern for any other attributes you want to bring across from CustomerHistory — CustomerName, AccountTier, SalesRegion, etc.
Once you've extracted all the columns you need, right-click the MatchedRecord column and remove it. It's done its job and you don't need it in your final output.
Tip: You can also use
Table.First([MatchedRecord], null)to get the first row of a table or null if empty, then access fields from the result. This can be slightly more concise, but the explicitTable.RowCountcheck reads more clearly for colleagues who are less familiar with M syntax.
In practice, you'll often want to write a single expression that does the lookup and extraction in one shot, avoiding the intermediate MatchedRecord column entirely. Here's what that looks like for the Segment attribute:
let
matched = Table.SelectRows(
CustomerHistory,
(lookup) =>
lookup[CustomerID] = [CustomerID] and
lookup[EffectiveDate] <= [SaleDate] and
lookup[ExpiryDate] > [SaleDate]
),
result = if Table.RowCount(matched) > 0
then matched{0}[Segment]
else null
in
result
This is cleaner for the final query, though it does slightly harder to debug if something goes wrong. During development, keeping MatchedRecord as an intermediate column so you can visually inspect the matched tables is a useful diagnostic technique.
There's an important performance consideration that most tutorials skip over entirely, and it will hurt you in production.
When you write Table.SelectRows(CustomerHistory, ...) inside an Table.AddColumn expression, Power Query re-evaluates the CustomerHistory query for every single row in your Transactions table. If Transactions has 500,000 rows and CustomerHistory is pulled from a slow data source, you've just turned one data source query into 500,001 queries. This is the M equivalent of a nested loop hitting a database — catastrophic.
The fix is to wrap CustomerHistory in Table.Buffer() inside your Transactions query. This forces Power Query to load the entire CustomerHistory table into memory once, before the row-level iteration begins.
In the Advanced Editor for your Transactions query, find where CustomerHistory is referenced and adjust your step to buffer it:
let
Source = Excel.Workbook(...),
Transactions_Raw = Source{[Name="Transactions"]}[Data],
// Buffer the lookup table into memory once
CustomerHistory_Buffered = Table.Buffer(CustomerHistory),
// Now use the buffered version in the row-level lookup
AddSegment = Table.AddColumn(
Transactions_Raw,
"Segment",
(row) =>
let
matched = Table.SelectRows(
CustomerHistory_Buffered,
(lookup) =>
lookup[CustomerID] = row[CustomerID] and
lookup[EffectiveDate] <= row[SaleDate] and
lookup[ExpiryDate] > row[SaleDate]
)
in
if Table.RowCount(matched) > 0
then matched{0}[Segment]
else null
)
in
AddSegment
Notice that in this version, the row-level function takes an explicit row parameter rather than relying on implicit field access with [ColumnName]. This is necessary when you've restructured the query in the Advanced Editor — it makes the scoping explicit and avoids subtle bugs where field references resolve to the wrong context.
Warning: Do not buffer enormous tables.
Table.Buffer()loads the entire table into Excel or Power BI's memory. If your CustomerHistory table has millions of rows, buffering it may cause out-of-memory errors. In those cases, consider pre-aggregating or filtering CustomerHistory before buffering, or look at alternatives like pushing the join logic to your database engine via DirectQuery.
Real-world reference data is rarely pristine. Here are the edge cases you'll encounter and how to handle each one.
A transaction's date falls outside any effective range for that customer, or the customer doesn't exist in CustomerHistory at all. Your if Table.RowCount(matched) > 0 guard already handles this by returning null.
In your final dataset, filter for rows where Segment is null and investigate — these usually indicate data entry errors, newly onboarded customers who weren't added to the reference table yet, or deleted records that should have been archived instead. Don't silently ignore them.
If two rows in CustomerHistory have the same CustomerID with overlapping date ranges, your Table.SelectRows will return a table with more than one row, and matched{0}[Segment] will silently return whichever row happened to come first. You might never notice this.
Add a data quality check step early in your CustomerHistory query:
// Check for overlapping ranges — this should return 0 rows if data is clean
let
Source = CustomerHistory,
AddRowCheck = Table.AddColumn(
Source,
"OverlapCount",
(outer) =>
Table.RowCount(
Table.SelectRows(
Source,
(inner) =>
inner[CustomerID] = outer[CustomerID] and
inner[EffectiveDate] < outer[ExpiryDate] and
inner[ExpiryDate] > outer[EffectiveDate] and
inner[EffectiveDate] <> outer[EffectiveDate]
)
)
),
FlaggedOverlaps = Table.SelectRows(AddRowCheck, each [OverlapCount] > 0)
in
FlaggedOverlaps
Run this as a diagnostic query (not part of your main data pipeline) to surface any data quality issues. Fix them in the source data before they silently corrupt your reports.
A customer's history has a gap — perhaps the SMB record expired on March 31 and the Enterprise record didn't start until April 15. Any transactions between April 1 and April 14 will return null. Depending on your business requirements, you might want to handle gaps by using the most recently expired record as a fallback:
let
exact_match = Table.SelectRows(
CustomerHistory_Buffered,
(lookup) =>
lookup[CustomerID] = row[CustomerID] and
lookup[EffectiveDate] <= row[SaleDate] and
lookup[ExpiryDate] > row[SaleDate]
),
// Fallback: use the most recently expired record
fallback_match = Table.SelectRows(
CustomerHistory_Buffered,
(lookup) =>
lookup[CustomerID] = row[CustomerID] and
lookup[ExpiryDate] <= row[SaleDate]
),
fallback_sorted = Table.Sort(fallback_match, {{"ExpiryDate", Order.Descending}}),
result = if Table.RowCount(exact_match) > 0
then exact_match{0}[Segment]
else if Table.RowCount(fallback_sorted) > 0
then fallback_sorted{0}[Segment]
else null
in
result
Whether the fallback makes sense depends entirely on your business logic. Sometimes a gap means "we don't know" and null is the correct answer. Sometimes a gap is a data error and using the nearest record is appropriate. Make this decision explicitly and document it.
If your Transactions table contains future-dated records (forecasts, planned orders), you'll want to make sure your CustomerHistory has an appropriate open-ended record for each active customer — which is exactly what the 9999-12-31 sentinel date provides. A transaction dated 2026-03-01 will correctly match the record with ExpiryDate = 9999-12-31.
The row-level Table.SelectRows approach is correct and reasonably clean, but it's not always the best tool. Know when to look elsewhere.
Use a database-side join when you can. If your transactions and customer history both live in SQL Server, Postgres, Snowflake, or any other queryable system, implement the range join there. A properly indexed SQL query with BETWEEN or explicit date range conditions will be orders of magnitude faster than row-level M evaluation. Use Power Query for the shape and presentation layer, not for complex joins on large datasets.
Consider Power BI's DAX for point-in-time calculations. If you're building a Power BI model, you can sometimes implement point-in-time lookup logic using DAX measures with CALCULATE and FILTER, which can be more flexible for interactive reporting. However, this shifts the complexity to your measures rather than cleaning it up in the data model, which creates its own maintenance burden.
Pre-process in your data pipeline. If you have a proper ETL/ELT pipeline (dbt, Azure Data Factory, SSIS), implement the slowly changing dimension logic there and expose a pre-joined, historically accurate dataset to Power Query. Power Query then has a simple, fast job to do.
The row-level M approach is the right choice when: you're working with Excel or Power BI without a proper database backend, your tables are small to medium size (under ~500K transaction rows, under ~50K reference rows after buffering), and you need the solution to be self-contained in a Power Query file that non-engineers can maintain.
Let's put everything together. You're going to build a complete solution from scratch using the following scenario.
Scenario: You work for a manufacturing company. Your sales team manages accounts across three territories: North, South, and West. Territory assignments change as the sales org restructures. You need to report historical revenue by the territory that was active at the time of the sale, not the current assignment.
Step 1: Set up the data
Create an Excel workbook with two sheets.
Sheet "Transactions" — populate it with this data:
| TransactionID | SalesRepID | SaleDate | Revenue |
|---|---|---|---|
| TXN-001 | SR-10 | 2023-01-15 | 45,200 |
| TXN-002 | SR-10 | 2023-07-22 | 67,800 |
| TXN-003 | SR-10 | 2024-02-10 | 52,100 |
| TXN-004 | SR-22 | 2023-04-05 | 33,500 |
| TXN-005 | SR-22 | 2023-11-18 | 41,200 |
| TXN-006 | SR-33 | 2023-06-30 | 28,900 |
Sheet "TerritoryHistory" — populate it with this data:
| SalesRepID | TerritoryName | EffectiveDate | ExpiryDate |
|---|---|---|---|
| SR-10 | West | 2022-01-01 | 2023-06-01 |
| SR-10 | North | 2023-06-01 | 9999-12-31 |
| SR-22 | South | 2021-03-15 | 9999-12-31 |
| SR-33 | West | 2020-09-01 | 2023-04-01 |
| SR-33 | North | 2023-04-01 | 9999-12-31 |
Step 2: Load both sheets into Power Query
In Excel, go to Data → Get Data → From File → From Workbook, select your file, and load both sheets into Power Query. Name the queries "Transactions" and "TerritoryHistory."
Step 3: Set correct data types
In the TerritoryHistory query, ensure EffectiveDate and ExpiryDate are typed as Date. In Transactions, ensure SaleDate is typed as Date.
Step 4: Add the buffered lookup to Transactions
Open the Transactions query in Advanced Editor and modify it to include:
let
Source = Excel.CurrentWorkbook(){[Name="Transactions"]}[Content],
TypedTransactions = Table.TransformColumnTypes(
Source,
{{"SaleDate", type date}}
),
TH_Buffered = Table.Buffer(
Table.TransformColumnTypes(
Excel.CurrentWorkbook(){[Name="TerritoryHistory"]}[Content],
{{"EffectiveDate", type date}, {"ExpiryDate", type date}}
)
),
AddTerritory = Table.AddColumn(
TypedTransactions,
"TerritoryAtTimeOfSale",
(row) =>
let
matched = Table.SelectRows(
TH_Buffered,
(lkp) =>
lkp[SalesRepID] = row[SalesRepID] and
lkp[EffectiveDate] <= row[SaleDate] and
lkp[ExpiryDate] > row[SaleDate]
)
in
if Table.RowCount(matched) > 0
then matched{0}[TerritoryName]
else "NOT FOUND"
)
in
AddTerritory
Step 5: Verify your results
Your output should show:
That last one is a deliberate test of your attention. Go back and check: SR-33's North record starts 2023-04-01 and TXN-006 is 2023-06-30 — so it correctly returns North, not West. If your query returns West for TXN-006, check your date comparison operators (remember: ExpiryDate > SaleDate, not >=).
Step 6: Load to table and build a summary
Close and load your Transactions query to a table. Create a simple PivotTable to summarize Revenue by TerritoryAtTimeOfSale by year. This is your historically accurate territory revenue report.
Mistake 1: Using the wrong comparison operator for ExpiryDate
Using >= instead of > for the ExpiryDate comparison means a transaction that falls exactly on the ExpiryDate will match both the expiring record and the new record (since the new EffectiveDate equals the old ExpiryDate). You'll get two matches, and {0} will silently return whichever came first. Always use strict > for the ExpiryDate comparison when ExpiryDate is exclusive.
Mistake 2: Forgetting to buffer the lookup table
If your query is unbearably slow — taking minutes to refresh when you'd expect seconds — the first thing to check is whether you're buffering the lookup table. Open the Advanced Editor and confirm Table.Buffer() wraps your reference table before the Table.AddColumn step.
Mistake 3: Date columns typed as text
If your lookups all return "NOT FOUND" or null even when you know matching records exist, check your data types. A SaleDate of "2023-07-22" (text) compared against an EffectiveDate of #date(2023, 6, 1) (date) will never match — M does not coerce types in comparisons. Verify in the query editor that all three date columns show the calendar icon in the column header.
Mistake 4: Reference table not in scope
If you get an error like "CustomerHistory" is not recognized inside your custom column expression, it's because Power Query's row-level functions run in a different scope. In the full Advanced Editor version, explicitly capture the reference table into a named step before the Table.AddColumn step, and reference that step name in your function. Don't reference the query name directly from inside a function body if it's in a different query — instead, use Table.Buffer() on a reference step within the same query.
Mistake 5: Assuming null ExpiryDate means "still active"
If your source data uses null for open-ended records instead of a sentinel date, your ExpiryDate > SaleDate comparison will return null (not true, not false — null) for those records, and they'll be excluded from matches. Either transform null to #date(9999, 12, 31) early in your CustomerHistory query, or modify your filter to explicitly handle null:
(lkp[ExpiryDate] = null or lkp[ExpiryDate] > row[SaleDate])
Both approaches work. The sentinel date approach is cleaner and more consistent.
Mistake 6: Overlapping ranges silently corrupting results
As mentioned earlier, overlapping date ranges in your reference data will cause {0} to return an arbitrary match. The symptom is reports that look slightly wrong but are hard to pin down — some transactions show unexpected segment values, but the data looks correct at a glance. Run the overlap diagnostic query periodically as part of your data quality checks.
You've just implemented one of the more sophisticated data modeling patterns in Power Query — a point-in-time lookup against a slowly changing reference table. Let's recap the key ideas:
EffectiveDate and ExpiryDate columns, using a far-future sentinel date for open records. Use Table.SelectRows inside Table.AddColumn to perform the three-condition range join at the row level.Table.Buffer() the lookup table before the row-level iteration to avoid re-querying the source for every transaction row.Where to go from here:
The slowly changing lookup is a pattern that, once you internalize it, you'll start seeing everywhere. Customer segmentation, product categorization, organizational hierarchies, price lists, exchange rates — any reference data that changes over time is a candidate. The investment you make in getting historical accuracy right now pays dividends every time someone asks "but what did it look like back then?"