How-to
Advanced Data Transformation And CleaningDeep dive

Handle Many-to-Many Lookups Without Duplicating Rows

When a lookup key matches several rows — a product in two promotions, a customer with several account managers — a pandas merge multiplies the left rows and every total built on them is inflated. Detect the fan-out, then choose deliberately: collapse the lookup to one row per key, pick one match by a rule, combine matches into a list, or allocate amounts across them so totals still reconcile.

A lookup in Excel returns one value. A merge in pandas returns every match, and when a key appears more than once in the lookup table each left-hand row is copied once per match. Join orders to a promotions table in which a product belongs to two promotions, and those orders appear twice; sum revenue afterwards and it is overstated by exactly the duplicated orders' value — with no error, no warning and a total that looks plausible. This is the most common silent bug in pandas reporting. The fix is not a single function but a decision about what the many matches mean: perhaps only one applies, perhaps all of them should be listed, perhaps the amount should be shared among them. This page shows how the inflation happens and how to detect it, then works through the four deliberate answers — collapse the lookup side, pick one match by a rule, combine matches into a list, or allocate amounts across matches — each with totals that reconcile to the original data. It belongs to Merging and Joining Excel DataFrames.

Prerequisites

Bash
pip install pandas openpyxl

The example joins September orders to a promotions table. Some products are in one promotion, some in two, some in none; promotions have a priority and a start date, which give the rules something to work with.

Step 1 — See the inflation, and detect it

Python
import pandas as pd

orders = pd.DataFrame({"Order": [f"SO-{i}" for i in range(1, 9)],
                       "Product": ["Bolts", "Nuts", "Bolts", "Washers", "Rivets", "Nuts", "Bolts", "Brackets"],
                       "Revenue": [540.0, 120.0, 310.0, 95.0, 410.0, 80.0, 220.0, 150.0]})
promos = pd.DataFrame({"Product": ["Bolts", "Bolts", "Nuts", "Washers", "Washers", "Washers"],
                       "Promo": ["AUTUMN10", "TRADE5", "AUTUMN10", "AUTUMN10", "BULK15", "TRADE5"],
                       "Priority": [2, 1, 2, 2, 3, 1],
                       "Start": pd.to_datetime(["2026-09-01", "2026-06-01", "2026-09-01", "2026-09-01", "2026-09-10", "2026-06-01"])})

joined = orders.merge(promos, on="Product", how="left")
print(f"orders: {len(orders)} rows, revenue {orders['Revenue'].sum():,.0f}")
print(f"after merge: {len(joined)} rows, revenue {joined['Revenue'].sum():,.0f}")
try:
    orders.merge(promos, on="Product", how="left", validate="many_to_one")
except pd.errors.MergeError as e:
    print("validate:", str(e).splitlines()[0])
fanout = promos["Product"].value_counts()
print("keys with several matches:", fanout[fanout > 1].to_dict())

Eight orders become thirteen rows after the merge — each of the three Bolts orders appears twice, the Washers order three times — and total revenue grows from 1,925 to 3,185. Nothing in the result says so: the extra rows look like ordinary data. Two checks catch it. validate="many_to_one" makes the merge raise as soon as a key appears more than once on the right, and a row count before and after makes the fan-out visible in a log. Run at least one of them on every lookup merge, whatever you then decide to do about the duplicates it finds.

How a merge multiplies rows The Bolts order SO-1 matches two promotion rows and becomes two rows, and the Washers order matches three and becomes three. Eight orders become thirteen rows and total revenue rises from 1,925 to 3,185 with no error. A B C 1 Order Product Revenue 2 SO-1 Bolts 540 3 SO-4 Washers 95 A B C 1 Order Promo Revenue 2 SO-1 AUTUMN10 540 3 SO-1 TRADE5 540 4 SO-4 AUTUMN10 95 5 SO-4 BULK15 95 6 SO-4 TRADE5 95 before after merge on Product

Step 2 — Answer one: pick one match by a rule

Often only one of the matches should apply: the highest-priority promotion, or the most recent one. Reduce the lookup table to one row per key before merging:

Python
best = (promos.sort_values(["Product", "Priority", "Start"], ascending=[True, False, False])
              .drop_duplicates("Product"))
one = orders.merge(best[["Product", "Promo", "Priority"]], on="Product", how="left", validate="many_to_one")
print(one.to_string(index=False))
print("revenue:", f"{one['Revenue'].sum():,.0f}")

Sorting by priority (highest first) and start date (latest first) and keeping the first row per product turns the rule into data: Bolts get AUTUMN10, Washers BULK15. The merge is now genuinely many-to-one, the validation passes and revenue is back to 1,925. The rule is explicit and easy to change — and it should be written down, because "which promotion applies" is a business decision, not a technical one. If two rows tie on every sort column, drop_duplicates keeps whichever comes first in the table, so add a final tie-breaker column — the promotion code, say — to make the result stable from one run to the next.

Step 3 — Answer two: combine the matches into one value

Sometimes every match is relevant and the report should show them all, on one row:

Python
listed = (promos.sort_values("Priority", ascending=False)
                .groupby("Product").agg(Promos=("Promo", ", ".join), Count=("Promo", "size")).reset_index())
combined = orders.merge(listed, on="Product", how="left", validate="many_to_one")
combined["Count"] = combined["Count"].fillna(0).astype(int)
print(combined.to_string(index=False))

Aggregating the lookup side first — joining the codes into a string and counting them — gives one row per product, so the orders keep their count and their revenue. The same pattern produces any summary of the matches: the maximum discount, the number of account managers, the earliest start date. It answers "which promotions could apply to this order?" without changing what the order is worth. The Washers order now shows all three of its promotions, highest priority first, and the two orders for products without a promotion show a count of zero rather than disappearing. When the list feeds a filter or a pivot later, a count column is more useful than parsing the text, so keep both.

What should several matches mean? If only one match applies, reduce the lookup to one row per key by a rule such as highest priority. If all matches are information, aggregate them into a list or count. If an amount must be attributed to each match, explode deliberately and allocate the amount so that totals still reconcile. A key matches several rows… one applies pick by rule sort + drop_duplicates all are information combine list, count, max amount is shared allocate totals reconcile

Step 4 — Answer three: allocate amounts across matches

When the question is "how much revenue did each promotion drive?", every match genuinely needs its own row — but the revenue must be shared, not copied:

Python
alloc = orders.merge(promos[["Product", "Promo"]], on="Product", how="left")
alloc["Matches"] = alloc.groupby("Order")["Order"].transform("size")
alloc["Allocated"] = alloc["Revenue"] / alloc["Matches"]
by_promo = alloc.groupby(alloc["Promo"].fillna("(no promo)"))["Allocated"].sum()
assert abs(by_promo.sum() - orders["Revenue"].sum()) < 1e-6           # check before rounding
shown = by_promo.round(2)
print(shown.to_string())
print("allocated total:", f"{by_promo.sum():,.2f}", "| sum of rounded shares:", f"{shown.sum():,.2f}")

Here the fan-out is intended, but each copy carries only its share: an order matched by two promotions contributes half its revenue to each. Equal shares are the simplest allocation; weights — by discount size, say — follow the same pattern with a weighted share instead of 1 / Matches. The assertion is the important line: allocated totals must add back to the original, and checking that is what distinguishes an allocation from an inflation. Orders with no promotion stay in the total under their own label rather than disappearing. The check is made on the unrounded shares: rounded to pennies, the four lines add up to 1,925.01, because thirds of the Washers order do not divide evenly. Round only for display, or, where the rounded figures must add up exactly, give the leftover penny to the largest share. Allocation of shared amounts is covered more generally in Allocate Shared Costs Across Departments with Python.

Revenue allocated to promotions With each order's revenue shared equally among its matching promotions, AUTUMN10 receives 766.67, TRADE5 566.67, BULK15 31.67, and orders with no promotion 560.00, adding back to the original 1,925. AUTUMN10 766.67 TRADE5 566.67 BULK15 31.67 (no promo) 560.00

Writing it up

Whichever answer you choose, put the reconciliation in the output: the original row count and total, and the counts and totals after the merge, side by side. A one-line check sheet — "orders 8, revenue 1,925.00; after lookup 8 rows, 1,925.00" — turns a silent risk into something a reviewer can confirm in a second, and when someone later adds a second promotion for a product, the check catches the change. In code, wrap every lookup merge in a small helper that runs the validation and logs both counts, so the protection does not depend on remembering it each time. The duplicate-key problem in single-key lookups is also covered in Validate a Merge to Avoid Duplicate Rows.

Common pitfalls

  • Summing after an unvalidated merge. Fan-out silently inflates every total built on the result.
  • Dropping duplicates after the merge. Reduce the lookup side first, by an explicit and documented rule.
  • drop_duplicates without sorting. Which row survives must be decided by a rule, not left to file order.
  • Copying amounts to every match. Allocate shares instead, and check that they add back to the original total.
  • No reconciliation in the output. Show row counts and totals before and after every lookup.

Conclusion

A merge returns every match, so a lookup key that appears more than once multiplies the rows and inflates totals silently. Detect it with validate="many_to_one" and row counts, then decide what the matches mean: reduce the lookup table to one row per key by a sorted rule, aggregate the matches into a list or count, or merge deliberately and allocate amounts so they add back to the original. Whichever you choose, reconcile counts and totals before and after.

Frequently asked questions

Why does my pandas merge create duplicate rows? The key appears more than once in the right-hand table, so each left row is repeated once per match; check with rightkey.value_counts() or validate="many_to_one".

How do I keep only one match per key in a lookup? Sort the lookup table by the rule that decides which match wins, such as priority or date, drop duplicates on the key, then merge.

How do I show all matches on one row? Group the lookup table by the key and aggregate the matches, for example with ", ".join, before merging.

How do I split an amount across several matches? Merge deliberately, count matches per original row, divide the amount by the count or a weight, and check that the allocated amounts add up to the original total.

More on joins, lookups & comparisons

More on joins, lookups & comparisons →