
You've built what looks like a clean data model. Sales data on one side, a product table on the other. You run your merge, load it into Power BI or Excel, and suddenly your revenue figures are triple what they should be. Totals are wrong. Row counts are inflated. And when you dig into the merged table, you find rows duplicated in a pattern that makes your stomach drop. Welcome to the many-to-many relationship problem — one of the most common and most damaging data modeling mistakes in Power Query.
Many-to-many (M:N) relationships occur when both sides of a join can contain repeated values for the key you're joining on. Unlike a clean one-to-many relationship — where, say, each CustomerID in a transactions table maps to exactly one record in a customers dimension table — many-to-many relationships create a combinatorial explosion. One row on the left matches three rows on the right. Those three rows each match two rows on the left. Before you know it, your 10,000-row table produces 87,000 rows after the merge, and none of your aggregations make sense anymore.
By the end of this lesson, you'll know how to detect many-to-many relationships before they corrupt your data, and you'll have three practical strategies for handling them correctly: deduplicating before merging, building bridge tables that resolve the relationship cleanly, and using safe merge patterns that protect you from row multiplication. These techniques apply whether you're working in Power BI's Power Query Editor, Excel's Get & Transform, or standalone Power Query.
What you'll learn:
You should already be comfortable with:
Table.SelectRows, Table.Distinct, Table.Group)If you haven't worked with merges before, complete the Power Query Merges lesson in this learning path first.
Before we fix the problem, we need to understand it precisely. Let's work with a realistic scenario.
Imagine you're working at a mid-sized retailer. You have a Sales table that records individual line items — each row is one product sold in one transaction:
Sales
-----------
TransactionID | ProductID | Quantity | Revenue
T001 | P100 | 2 | 49.98
T001 | P200 | 1 | 24.99
T002 | P100 | 1 | 24.99
T003 | P300 | 3 | 89.97
You also have a ProductCategories table that maps products to categories. But here's the catch: your product catalog has been managed by three different teams over five years, and products can belong to multiple categories:
ProductCategories
-----------------
ProductID | Category
P100 | Electronics
P100 | Accessories
P200 | Accessories
P200 | Gifts
P300 | Electronics
Notice that P100 appears in both Electronics and Accessories, and P200 appears in both Accessories and Gifts. Neither side of this join has unique keys. ProductID repeats in both Sales (because the same product can be sold multiple times) and in ProductCategories (because one product maps to multiple categories).
When you merge these two tables on ProductID, here's what actually happens:
The row for T001 / P100 in Sales matches two rows in ProductCategories (Electronics and Accessories). So it becomes two rows in your merged result. The T002 / P100 row does the same. The T001 / P200 row also matches two rows. Your four-row Sales table becomes seven rows, and your revenue sum jumps from $189.93 to $339.87 — because the P100 and P200 revenue amounts have each been counted twice.
This is the many-to-many row multiplication problem. And it's completely silent — Power Query will execute this merge without any warning or error.
The first skill is detection. Before merging any two tables, you should check whether the join key is unique on both sides. Here's how to do that systematically in Power Query.
Checking for duplicates using Table.Group
To check whether ProductID is unique in your ProductCategories table, you can create a diagnostic query. In a blank query, reference your table and group by the join key, counting rows:
let
Source = ProductCategories,
GroupedRows = Table.Group(
Source,
{"ProductID"},
{{"RowCount", each Table.RowCount(_), Int64.Type}}
),
DuplicatesOnly = Table.SelectRows(GroupedRows, each [RowCount] > 1)
in
DuplicatesOnly
If this returns any rows, the table is not unique on ProductID. That's your warning signal. In our example, you'd see:
ProductID | RowCount
P100 | 2
P200 | 2
You can run the same check on the Sales table for ProductID. You'll find every ProductID appears more than once there too (T001 and T002 both contain P100). That confirms a true M:N relationship: neither side is unique on the join key.
Always run this diagnostic before merging tables you didn't build yourself. Source data from ERP systems, CRMs, and legacy databases frequently has unexpected duplicates. Five minutes of checking can save hours of debugging bad aggregations.
Checking key uniqueness with List.Count and List.Distinct
A faster one-liner for quick checks:
let
Source = ProductCategories,
TotalRows = Table.RowCount(Source),
UniqueKeys = List.Count(List.Distinct(Source[ProductID])),
IsUnique = TotalRows = UniqueKeys
in
IsUnique
If IsUnique returns false, you have duplicates. This is useful as a conditional check you can embed inside a larger transformation.
The simplest resolution is often the right one: if you only need one value from the lookup table per key, just deduplicate the lookup table before you merge.
This works when the duplicates in your lookup table represent redundant or irrelevant information for your specific analysis. For example, if you're joining ProductCategories to Sales but you only care about the primary category (and the multiple categories are a data quality issue, not intentional design), you can collapse ProductCategories to one row per ProductID before the merge.
Simple deduplication with Table.Distinct
let
Source = ProductCategories,
Deduped = Table.Distinct(Source, {"ProductID"})
in
Deduped
Table.Distinct with a column list keeps only the first occurrence of each unique key combination. The result:
ProductID | Category
P100 | Electronics
P200 | Accessories
P300 | Electronics
Now this table has a unique ProductID key and you can safely left join it to Sales without row multiplication.
But which row does Table.Distinct keep?
This is the critical question. Table.Distinct keeps whichever row appears first in the current sort order. If your data has no meaningful ordering, this is essentially arbitrary. That might be fine for some use cases, but for many real-world scenarios you need to be deliberate about which value survives deduplication.
Intentional deduplication with Table.Group and aggregation
When you need control over which value survives, use Table.Group to aggregate rather than blindly drop:
let
Source = ProductCategories,
GroupedByProduct = Table.Group(
Source,
{"ProductID"},
{
{"PrimaryCategory", each List.First(List.Sort([Category])), type text},
{"AllCategories", each Text.Combine(List.Sort([Category]), ", "), type text},
{"CategoryCount", each Table.RowCount(_), Int64.Type}
}
)
in
GroupedByProduct
This gives you one row per product, with:
PrimaryCategory: alphabetically first category (deterministic, not random)AllCategories: all categories as a comma-separated string (useful for reporting)CategoryCount: how many categories the product belongs toProductID | PrimaryCategory | AllCategories | CategoryCount
P100 | Accessories | Accessories, Electronics | 2
P200 | Accessories | Accessories, Gifts | 2
P300 | Electronics | Electronics | 1
Now you have something genuinely useful. You can merge PrimaryCategory into Sales for clean aggregations, and keep AllCategories available if someone needs to see the full picture.
Warning: Deduplication is appropriate when the many-to-many relationship represents a data quality problem. When it represents real business complexity — a product genuinely belongs to multiple categories and you need to analyze by category — deduplication destroys information you need. In that case, use a bridge table instead.
A bridge table (also called a junction table or associative table) is the architecturally correct solution to a true many-to-many relationship. Instead of trying to merge two tables with M:N keys directly, you route the relationship through a third table that explicitly maps the relationship.
This is the approach your data model should use when both sides of the relationship are legitimately non-unique. In Power BI's data model, bridge tables enable you to define relationships properly. In Excel or standalone Power Query, they let you build controlled aggregations without row inflation.
When do you actually need a bridge table you build yourself?
Your source data might not come with a bridge table. You might receive two flat files that have been joined and need to be normalized, or you might receive data where the bridge relationship is implied but not explicit. In those cases, you build it.
Let's extend our scenario. Suppose you've been handed a single flat SalesWithCategories export from a legacy system:
SalesWithCategories (flat export)
---
TransactionID | ProductID | Quantity | Revenue | Category
T001 | P100 | 2 | 49.98 | Electronics
T001 | P100 | 2 | 49.98 | Accessories
T001 | P200 | 1 | 24.99 | Accessories
T001 | P200 | 1 | 24.99 | Gifts
T002 | P100 | 1 | 24.99 | Electronics
T002 | P100 | 1 | 24.99 | Accessories
T003 | P300 | 3 | 89.97 | Electronics
The duplicated Revenue figures are already there — this is what the merged M:N data looks like. You need to normalize this back into clean tables.
Step 1: Extract the clean Sales table
let
Source = SalesWithCategories,
DistinctSales = Table.Distinct(Source, {"TransactionID", "ProductID"}),
SalesOnly = Table.SelectColumns(DistinctSales, {"TransactionID", "ProductID", "Quantity", "Revenue"})
in
SalesOnly
Result — four clean rows, no duplicated revenue:
TransactionID | ProductID | Quantity | Revenue
T001 | P100 | 2 | 49.98
T001 | P200 | 1 | 24.99
T002 | P100 | 1 | 24.99
T003 | P300 | 3 | 89.97
Step 2: Extract the clean Products table
let
Source = SalesWithCategories,
ProductsDistinct = Table.Distinct(
Table.SelectColumns(Source, {"ProductID"}),
{"ProductID"}
)
in
ProductsDistinct
Step 3: Extract the bridge table (ProductID to Category mapping)
let
Source = SalesWithCategories,
BridgeColumns = Table.SelectColumns(Source, {"ProductID", "Category"}),
BridgeDistinct = Table.Distinct(BridgeColumns, {"ProductID", "Category"})
in
BridgeDistinct
Result — your clean bridge table:
ProductID | Category
P100 | Electronics
P100 | Accessories
P200 | Accessories
P200 | Gifts
P300 | Electronics
This bridge table is the relationship resolver. It has no duplicates at the combination level — each ProductID + Category pairing is unique, even though ProductID alone repeats.
Step 4: Extract the Categories dimension
let
Source = SalesWithCategories,
CategoriesDistinct = Table.Distinct(
Table.SelectColumns(Source, {"Category"}),
{"Category"}
)
in
CategoriesDistinct
Now you have four clean tables: Sales, Products, ProductCategoryBridge, and Categories. In Power BI, you'd define relationships as:
Sales[ProductID] → Products[ProductID] (many-to-one)Products[ProductID] → ProductCategoryBridge[ProductID] (one-to-many)ProductCategoryBridge[Category] → Categories[Category] (many-to-one)Using the bridge table for controlled aggregations in Power Query itself
If you're working in Excel or need the aggregations in Power Query rather than the data model, here's how to safely aggregate Sales by Category using the bridge:
let
// Reference existing queries
SalesTable = Sales,
BridgeTable = ProductCategoryBridge,
// Join Sales to Bridge on ProductID
// This WILL produce M:N expansion — Sales rows multiply by categories
SalesWithBridge = Table.NestedJoin(
SalesTable,
{"ProductID"},
BridgeTable,
{"ProductID"},
"BridgeData",
JoinKind.Inner
),
Expanded = Table.ExpandTableColumn(
SalesWithBridge,
"BridgeData",
{"Category"}
),
// NOW group by Category and sum revenue
// This is valid because we WANT one revenue row per category assignment
AggByCategory = Table.Group(
Expanded,
{"Category"},
{
{"TotalRevenue", each List.Sum([Revenue]), type number},
{"TotalQuantity", each List.Sum([Quantity]), Int64.Type}
}
)
in
AggByCategory
The critical insight: the join to the bridge table intentionally multiplies rows, but the subsequent aggregation collapses them correctly. You're not double-counting; you're explicitly saying "count P100's revenue once under Electronics and once under Accessories." That's the correct answer when a product genuinely belongs to multiple categories.
Be explicit about what "correct" means in your context. If a product belongs to two categories and its revenue is $100, does each category "get" $100? Or does each get $50? This is a business logic question, not a technical one. Resolve it with stakeholders before writing the query.
Sometimes you can't fully normalize your data upfront, or you're working with tables where the M:N relationship is an edge case rather than the norm — most keys are unique, but some aren't. You need merge patterns that handle this safely.
The anti-join check: validate before merging
Build a query that checks whether your merge will produce more rows than expected, and surfaces the problem explicitly:
let
LeftTable = Sales,
RightTable = ProductCategories,
// How many rows does a merge produce?
MergedResult = Table.NestedJoin(
LeftTable, {"ProductID"},
RightTable, {"ProductID"},
"Joined",
JoinKind.Inner
),
MergedRowCount = Table.RowCount(MergedResult),
LeftRowCount = Table.RowCount(LeftTable),
// If merge produced more rows than the left table, we have M:N expansion
ExpansionFactor = MergedRowCount / LeftRowCount,
// Raise an error if expansion is detected
SafeResult = if ExpansionFactor > 1
then error Error.Record(
"ManyToManyDetected",
"Merge expanded rows by factor " & Number.ToText(ExpansionFactor) & ". Check for duplicates in the lookup table.",
[LeftRows = LeftRowCount, MergedRows = MergedRowCount]
)
else MergedResult
in
SafeResult
This pattern makes your query fail loudly rather than silently producing wrong results. A failed query you notice immediately is far better than inflated data that makes it into a report and gets presented to leadership.
The lookup-value pattern: safe scalar lookups without joins
When you only need a single scalar value from a lookup table (like the primary category or a price), avoid merging entirely and use List.First with Table.SelectRows instead:
let
Source = Sales,
WithCategory = Table.AddColumn(
Source,
"PrimaryCategory",
each
let
ProductID = [ProductID],
MatchingRows = Table.SelectRows(
ProductCategories,
each [ProductID] = ProductID
),
FirstCategory = List.First(MatchingRows[Category], "Unknown")
in
FirstCategory,
type text
)
in
WithCategory
This adds one column to Sales with the first matching category — regardless of how many categories exist in ProductCategories. Row count is always preserved. The result is always one row per Sales row.
Performance note: This lookup pattern runs
Table.SelectRowsonce per row of the left table, which makes it O(n×m) in the worst case. For tables under ~50,000 rows it's usually fine, but for large datasets useTable.Bufferto cache the lookup table in memory first:BufferedLookup = Table.Buffer(ProductCategories),Apply this before the
Table.AddColumnstep and referenceBufferedLookupinside the lambda.
The aggregation-first pattern: collapse the right table before joining
Rather than joining and then cleaning up, pre-aggregate the right table to guarantee uniqueness before the merge:
let
// Step 1: Aggregate ProductCategories to one row per ProductID
ProductCategoriesAgg = Table.Group(
ProductCategories,
{"ProductID"},
{
{"CategoryList", each Text.Combine(List.Sort([Category]), " | "), type text},
{"PrimaryCategory", each List.First(List.Sort([Category])), type text},
{"CategoryCount", each Table.RowCount(_), Int64.Type}
}
),
// Step 2: Now merge safely — ProductCategoriesAgg has unique ProductIDs
MergedSales = Table.NestedJoin(
Sales,
{"ProductID"},
ProductCategoriesAgg,
{"ProductID"},
"CategoryData",
JoinKind.LeftOuter
),
// Step 3: Expand the single-row nested table
Expanded = Table.ExpandTableColumn(
MergedSales,
"CategoryData",
{"PrimaryCategory", "CategoryList", "CategoryCount"}
)
in
Expanded
This is robust and readable. The aggregation step is explicit, you can inspect ProductCategoriesAgg as an intermediate query to verify it's unique, and the merge itself is clean.
Production data adds additional complications that toy examples don't show. Two worth knowing about:
Time-bound bridge tables
In sales operations, products often have different category mappings over time. A product might be reclassified from "Accessories" to "Electronics" partway through a fiscal year. Your bridge table needs a validity date range:
ProductCategoryBridge (with dates)
---
ProductID | Category | ValidFrom | ValidTo
P100 | Accessories | 2020-01-01 | 2022-06-30
P100 | Electronics | 2022-07-01 | 9999-12-31
To join Sales to the correct category based on transaction date:
let
SalesWithCategory = Table.AddColumn(
Sales,
"Category",
each
let
TxnProductID = [ProductID],
TxnDate = [TransactionDate],
BridgeBuffered = Table.Buffer(ProductCategoryBridge),
MatchingRow = Table.SelectRows(
BridgeBuffered,
each [ProductID] = TxnProductID
and [ValidFrom] <= TxnDate
and [ValidTo] >= TxnDate
),
CategoryValue = List.First(MatchingRow[Category], "Unclassified")
in
CategoryValue,
type text
)
in
SalesWithCategory
This is a point-in-time lookup pattern. It's more expensive than a straight merge, so buffer the bridge table. For very large datasets, consider whether this logic belongs in the source database query rather than in Power Query.
Fuzzy M:N relationships (joining on non-exact keys)
Sometimes your M:N problem is compounded by inconsistent key values — the same product appears as "P100", "p100", and "P-100" across different source tables. Use Text.Clean, Text.Upper, and Text.Replace to normalize keys before any join:
let
Source = ProductCategories,
NormalizedKeys = Table.TransformColumns(
Source,
{{"ProductID", each Text.Upper(Text.Replace(Text.Clean(_), "-", "")), type text}}
)
in
NormalizedKeys
Apply the same normalization to your Sales table's ProductID column, then join on the normalized key. This prevents a dirty-key problem from hiding as a missing match problem.
In this exercise, you'll work through a realistic HR analytics scenario. You have two tables:
Employees — 8 rows
EmployeeID | Name | Department
E001 | Maya Patel | Engineering
E002 | James Okonkwo | Engineering
E003 | Sara Chen | Marketing
E004 | Luis Morales | Sales
E005 | Diana Frost | Marketing
E006 | Raj Sharma | Engineering
E007 | Keiko Tanaka | Sales
E008 | Tom Brennan | Finance
ProjectAssignments — employees can work on multiple projects; projects have multiple employees
EmployeeID | ProjectCode | HoursAssigned
E001 | PRJ-Alpha | 40
E001 | PRJ-Beta | 20
E002 | PRJ-Alpha | 60
E003 | PRJ-Beta | 30
E003 | PRJ-Gamma | 15
E004 | PRJ-Alpha | 25
E005 | PRJ-Gamma | 45
E006 | PRJ-Beta | 35
E007 | PRJ-Alpha | 20
E007 | PRJ-Gamma | 10
Your tasks:
Task 1: Write the diagnostic query that confirms this is a many-to-many relationship. Check whether EmployeeID is unique in ProjectAssignments.
Task 2: Build a bridge table that maps EmployeeID to ProjectCode with unique pairs. Confirm it has no duplicate EmployeeID + ProjectCode combinations.
Task 3: Using the aggregation-first strategy, create a query called DepartmentProjectSummary that shows total hours assigned per Department per Project. Your result should look like:
Department | ProjectCode | TotalHours
Engineering | PRJ-Alpha | 100
Engineering | PRJ-Beta | 55
Marketing | PRJ-Beta | 30
Marketing | PRJ-Gamma | 60
Sales | PRJ-Alpha | 45
Sales | PRJ-Gamma | 10
Task 4: Add a safeguard. Before your final output, add a validation step that checks whether any Department+Project combination has suspiciously high hours (over 100). If it does, emit a warning column "ReviewNeeded" with value true.
Expected solution outline for Task 3:
let
// Start with ProjectAssignments — this already has the M:N data correctly structured
Source = ProjectAssignments,
// Join to Employees to get Department
WithDepartment = Table.NestedJoin(
Source,
{"EmployeeID"},
Employees,
{"EmployeeID"},
"EmpData",
JoinKind.LeftOuter
),
ExpandDept = Table.ExpandTableColumn(
WithDepartment,
"EmpData",
{"Department"}
),
// Aggregate by Department + Project
Summary = Table.Group(
ExpandDept,
{"Department", "ProjectCode"},
{{"TotalHours", each List.Sum([HoursAssigned]), Int64.Type}}
),
// Sort for readability
Sorted = Table.Sort(Summary, {{"Department", Order.Ascending}, {"ProjectCode", Order.Ascending}})
in
Sorted
Note how this works:
ProjectAssignmentsis already the bridge table in this scenario. The M:N relationship is between Employees and Projects. We don't merge Employees to Projects directly — we go through the assignments table, which is exactly what a bridge table is for.
Mistake 1: Using Table.Join instead of Table.NestedJoin for diagnostics
Table.Join immediately flattens and expands the result, making it hard to detect row inflation. Table.NestedJoin puts the matched rows into a nested table column, which you can inspect before expanding. Use nested joins during development; you'll see exactly what matched.
Mistake 2: Assuming Table.Distinct solved the problem
After applying Table.Distinct, check your row count before and after. If the before count and after count are identical, Table.Distinct did nothing — your data already had uniqueness at the full-row level, but duplicates may still exist at the key level if other columns differ. You need to specify the columns to deduplicate on: Table.Distinct(Source, {"ProductID"}), not Table.Distinct(Source).
Mistake 3: Buffering too late
When using row-by-row lookup patterns (Table.AddColumn with Table.SelectRows inside), the lookup table gets re-evaluated for every single row unless you buffer it. The buffer must happen before the Table.AddColumn step. If you buffer inside the lambda, you're buffering once per row, which is even worse:
// WRONG — buffering inside the lambda
WithCategory = Table.AddColumn(
Sales,
"Category",
each
let BufferedHere = Table.Buffer(ProductCategories) // runs for every row!
...
)
// RIGHT — buffer once before the AddColumn
BufferedCategories = Table.Buffer(ProductCategories),
WithCategory = Table.AddColumn(
Sales,
"Category",
each List.First(Table.SelectRows(BufferedCategories, ...)["Category"], "Unknown")
)
Mistake 4: Ignoring null keys
When your join key has nulls on either side, Table.NestedJoin will match nulls to nulls by default — meaning all rows with null ProductID in Sales will match all rows with null ProductID in ProductCategories. This is almost never what you want. Filter out null keys before merging:
SalesClean = Table.SelectRows(Sales, each [ProductID] <> null),
LookupClean = Table.SelectRows(ProductCategories, each [ProductID] <> null)
Mistake 5: Confusing row inflation in Power Query vs. DAX/relationships
If you're loading these tables into a Power BI data model and handling the M:N relationship via model relationships rather than in Power Query, do not pre-join the tables in Power Query. Let the data model handle it. Pre-joining in Power Query and then defining relationships on top of that creates double-counting at the model level. Pick one layer to resolve the relationship, and be consistent.
Troubleshooting: My row count is right but totals are still wrong
This usually means you're joining on a non-unique combination of multiple columns where the individual columns look unique. For example, joining on ProductID + RegionID where the combination isn't unique even though each column alone is. Run your duplicate diagnostic on the full composite key:
GroupedRows = Table.Group(Source, {"ProductID", "RegionID"}, {{"RowCount", each Table.RowCount(_), Int64.Type}}),
DuplicatesOnly = Table.SelectRows(GroupedRows, each [RowCount] > 1)
Each of the three strategies has a different performance profile:
Deduplication before merging is the fastest. You're reducing rows before the join happens, which means less data for Power Query to process. This should be your default when the semantics allow it.
Bridge table construction has moderate overhead during the normalization step, but once your tables are clean, subsequent merges are all one-to-many and very fast. This is the right long-term investment for complex data models.
Row-by-row lookup (Table.AddColumn with Table.SelectRows) is the slowest pattern, scaling poorly with large tables. Use it only when you need conditional or time-based lookups that can't be expressed as a static merge, and always buffer the lookup table.
For very large source tables (millions of rows), seriously consider pushing M:N resolution upstream to the database query via SQL or a stored procedure. Power Query is excellent at transforming data, but it's not a query optimizer. A well-indexed SQL join will outperform equivalent Power Query logic significantly.
Many-to-many relationships are one of the most common sources of silent data corruption in Power Query workflows. The row multiplication effect produces wrong totals that look plausible — which makes them especially dangerous in reports and dashboards.
Here's the mental framework to take with you:
Always diagnose first. Check for duplicate keys before you merge, especially with external or legacy data sources. The Table.Group + row count pattern takes 30 seconds to write and can save days of debugging.
Choose your strategy based on business semantics. If the M:N relationship is a data quality problem, deduplicate. If it's genuine business complexity, build or use a bridge table. If it's an edge case, use defensive merge patterns.
Protect your output. Add row count checks or conditional errors to queries that depend on clean key relationships. Fail loudly rather than producing wrong answers quietly.
Keep aggregations at the right layer. Understand whether you want Power Query to resolve the relationship (appropriate for flat reports) or whether the data model layer should handle it (appropriate for interactive Power BI analysis).
Next steps in this learning path:
Learning Path: Power Query Essentials