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

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

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
Power Query

Managing Many-to-Many Relationships in Power Query: Deduplication, Bridge Tables, and Safe Merge Strategies

Many-to-many joins are the most common source of silent data corruption in Power Query — and they produce no error messages. Learn how to detect M:N relationships before they inflate your totals, and master three strategies to handle them correctly: deduplication, bridge tables, and defensive merge patterns.

⚡ Practitioner21 min readJul 17, 2026Updated Sep 23, 2026
Managing Many-to-Many Relationships in Power Query: Deduplication, Bridge Tables, and Safe Merge Strategies
On this page
  • Introduction
  • Prerequisites
  • Understanding the Problem: What Makes a Relationship Many-to-Many
  • Step 1: Diagnosing a Many-to-Many Relationship Before It Bites You
  • Strategy 1: Deduplication Before Merging
  • Strategy 2: Building a Bridge Table in Power Query
  • Strategy 3: Safe Merge Patterns That Protect Against Row Inflation
  • Handling Real-World Complexity: Temporal and Conditional M:N Relationships
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Performance Considerations: Choosing the Right Strategy
  • Summary & Next Steps
  • Managing Many-to-Many Relationships in Power Query: Deduplication, Bridge Tables, and Safe Merge Strategies

    Introduction

    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:

    • How to diagnose a many-to-many relationship in your data and measure its damage
    • When and how to deduplicate a lookup table before merging
    • How to construct a bridge table from scratch in Power Query to resolve M:N relationships
    • Safe merge strategies that prevent row inflation regardless of data quality
    • Performance considerations and when each approach is most appropriate

    Prerequisites

    You should already be comfortable with:

    • Basic Power Query merges (left outer, inner, full outer join types)
    • Expanding merged columns after a join
    • Writing basic M code (Table.SelectRows, Table.Distinct, Table.Group)
    • Understanding of what a primary key is and why it matters for joins

    If you haven't worked with merges before, complete the Power Query Merges lesson in this learning path first.


    Understanding the Problem: What Makes a Relationship Many-to-Many

    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.


    Step 1: Diagnosing a Many-to-Many Relationship Before It Bites You

    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.


    Strategy 1: Deduplication Before Merging

    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 to
    ProductID | 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.


    Strategy 2: Building a Bridge Table in Power Query

    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.


    Strategy 3: Safe Merge Patterns That Protect Against Row Inflation

    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.SelectRows once 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 use Table.Buffer to cache the lookup table in memory first:

    BufferedLookup = Table.Buffer(ProductCategories),
    

    Apply this before the Table.AddColumn step and reference BufferedLookup inside 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.


    Handling Real-World Complexity: Temporal and Conditional M:N Relationships

    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.


    Hands-On Exercise

    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: ProjectAssignments is 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.


    Common Mistakes & Troubleshooting

    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)
    

    Performance Considerations: Choosing the Right Strategy

    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.


    Summary & Next Steps

    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:

    1. 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.

    2. 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.

    3. 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.

    4. 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:

    • Slowly Changing Dimensions in Power Query — handling historical lookup data where the right record depends on a date range
    • Query Folding and Performance — understanding when Power Query pushes logic to the source and how to structure your transformations to maximize folding
    • Advanced M Code: Custom Functions and Recursion — building reusable transformation logic you can apply across multiple queries and projects
    Work With Us

    From insight to implementation

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

    Let's Build

    Power Query Essentials

    Previous

    Reshaping Data with Transpose, Fill Down, and Fill Up in Power Query

    Next

    Implementing Row-Level Security Data Preparation in Power Query: Filtering and Shaping Data for Role-Based Access Models

    Related Insights

    Power QueryFoundation

    Implementing Custom Lookup and Substitution Tables in Power Query M: Building Reusable Value Mapping Pipelines

    15 min
    Power QueryFoundation

    Removing Duplicates and Keeping Unique Rows in Power Query: Practical Techniques for Data Deduplication

    17 min
    Power QueryExpert

    Implementing Custom Calendar and Time Zone Normalization Pipelines in Power Query M: UTC Conversion, DST Handling, and Cross-Region Timestamp Alignment for Multi-Source Data

    26 min

    On this page

    • Introduction
    • Prerequisites
    • Understanding the Problem: What Makes a Relationship Many-to-Many
    • Step 1: Diagnosing a Many-to-Many Relationship Before It Bites You
    • Strategy 1: Deduplication Before Merging
    • Strategy 2: Building a Bridge Table in Power Query
    • Strategy 3: Safe Merge Patterns That Protect Against Row Inflation
    • Handling Real-World Complexity: Temporal and Conditional M:N Relationships
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Performance Considerations: Choosing the Right Strategy
    • Summary & Next Steps