[{"data":1,"prerenderedAt":1966},["ShallowReactive",2],{"doc:\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fhandle-many-to-many-lookups-without-duplicating-rows":3,"surround:\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fhandle-many-to-many-lookups-without-duplicating-rows":1933,"ancestors:\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fhandle-many-to-many-lookups-without-duplicating-rows":1941,"children:\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fhandle-many-to-many-lookups-without-duplicating-rows":1946,"related:\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fhandle-many-to-many-lookups-without-duplicating-rows":1947},{"id":4,"title":5,"body":6,"dateModified":1899,"datePublished":1899,"description":1900,"extension":1901,"faq":1902,"keyword":1911,"kind":1912,"meta":1913,"navigation":97,"path":1922,"seo":1923,"slug":1926,"stem":1927,"topics":1928,"type":1931,"__hash__":1932},"docs\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fhandle-many-to-many-lookups-without-duplicating-rows\u002Findex.md","Handle Many-to-Many Lookups Without Duplicating Rows",{"type":7,"value":8,"toc":1887},"minimark",[9,28,33,62,65,69,645,652,951,955,961,1119,1126,1130,1133,1284,1287,1431,1435,1442,1668,1679,1756,1760,1767,1771,1808,1812,1818,1822,1832,1838,1844,1850,1854,1883],[10,11,12,13,17,18,21,22,27],"p",{},"A lookup in Excel returns one value. A merge in pandas returns ",[14,15,16],"em",{},"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 ",[14,19,20],{},"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 ",[23,24,26],"a",{"href":25},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002F","Merging and Joining Excel DataFrames",".",[29,30,32],"h2",{"id":31},"prerequisites","Prerequisites",[34,35,40],"pre",{"className":36,"code":37,"language":38,"meta":39,"style":39},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl\n","bash","",[41,42,43],"code",{"__ignoreMap":39},[44,45,48,52,56,59],"span",{"class":46,"line":47},"line",1,[44,49,51],{"class":50},"sMTad","pip",[44,53,55],{"class":54},"srMev"," install",[44,57,58],{"class":54}," pandas",[44,60,61],{"class":54}," openpyxl\n",[10,63,64],{},"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.",[29,66,68],{"id":67},"step-1-see-the-inflation-and-detect-it","Step 1 — See the inflation, and detect it",[34,70,74],{"className":71,"code":72,"language":73,"meta":39,"style":39},"language-python shiki shiki-themes github-light github-dark-high-contrast","import pandas as pd\n\norders = pd.DataFrame({\"Order\": [f\"SO-{i}\" for i in range(1, 9)],\n                       \"Product\": [\"Bolts\", \"Nuts\", \"Bolts\", \"Washers\", \"Rivets\", \"Nuts\", \"Bolts\", \"Brackets\"],\n                       \"Revenue\": [540.0, 120.0, 310.0, 95.0, 410.0, 80.0, 220.0, 150.0]})\npromos = pd.DataFrame({\"Product\": [\"Bolts\", \"Bolts\", \"Nuts\", \"Washers\", \"Washers\", \"Washers\"],\n                       \"Promo\": [\"AUTUMN10\", \"TRADE5\", \"AUTUMN10\", \"AUTUMN10\", \"BULK15\", \"TRADE5\"],\n                       \"Priority\": [2, 1, 2, 2, 3, 1],\n                       \"Start\": pd.to_datetime([\"2026-09-01\", \"2026-06-01\", \"2026-09-01\", \"2026-09-01\", \"2026-09-10\", \"2026-06-01\"])})\n\njoined = orders.merge(promos, on=\"Product\", how=\"left\")\nprint(f\"orders: {len(orders)} rows, revenue {orders['Revenue'].sum():,.0f}\")\nprint(f\"after merge: {len(joined)} rows, revenue {joined['Revenue'].sum():,.0f}\")\ntry:\n    orders.merge(promos, on=\"Product\", how=\"left\", validate=\"many_to_one\")\nexcept pd.errors.MergeError as e:\n    print(\"validate:\", str(e).splitlines()[0])\nfanout = promos[\"Product\"].value_counts()\nprint(\"keys with several matches:\", fanout[fanout > 1].to_dict())\n","python",[41,75,76,92,99,164,210,259,298,333,367,404,409,441,487,527,536,568,582,607,623],{"__ignoreMap":39},[44,77,78,82,86,89],{"class":46,"line":47},[44,79,81],{"class":80},"s-kum","import",[44,83,85],{"class":84},"skGVy"," pandas ",[44,87,88],{"class":80},"as",[44,90,91],{"class":84}," pd\n",[44,93,95],{"class":46,"line":94},2,[44,96,98],{"emptyLinePlaceholder":97},true,"\n",[44,100,102,105,108,111,114,117,120,123,127,130,133,136,139,142,145,149,152,155,158,161],{"class":46,"line":101},3,[44,103,104],{"class":84},"orders ",[44,106,107],{"class":80},"=",[44,109,110],{"class":84}," pd.DataFrame({",[44,112,113],{"class":54},"\"Order\"",[44,115,116],{"class":84},": [",[44,118,119],{"class":80},"f",[44,121,122],{"class":54},"\"SO-",[44,124,126],{"class":125},"sSjpA","{",[44,128,129],{"class":84},"i",[44,131,132],{"class":125},"}",[44,134,135],{"class":54},"\"",[44,137,138],{"class":80}," for",[44,140,141],{"class":84}," i ",[44,143,144],{"class":80},"in",[44,146,148],{"class":147},"sP0c6"," range",[44,150,151],{"class":84},"(",[44,153,154],{"class":147},"1",[44,156,157],{"class":84},", ",[44,159,160],{"class":147},"9",[44,162,163],{"class":84},")],\n",[44,165,167,170,172,175,177,180,182,184,186,189,191,194,196,198,200,202,204,207],{"class":46,"line":166},4,[44,168,169],{"class":54},"                       \"Product\"",[44,171,116],{"class":84},[44,173,174],{"class":54},"\"Bolts\"",[44,176,157],{"class":84},[44,178,179],{"class":54},"\"Nuts\"",[44,181,157],{"class":84},[44,183,174],{"class":54},[44,185,157],{"class":84},[44,187,188],{"class":54},"\"Washers\"",[44,190,157],{"class":84},[44,192,193],{"class":54},"\"Rivets\"",[44,195,157],{"class":84},[44,197,179],{"class":54},[44,199,157],{"class":84},[44,201,174],{"class":54},[44,203,157],{"class":84},[44,205,206],{"class":54},"\"Brackets\"",[44,208,209],{"class":84},"],\n",[44,211,213,216,218,221,223,226,228,231,233,236,238,241,243,246,248,251,253,256],{"class":46,"line":212},5,[44,214,215],{"class":54},"                       \"Revenue\"",[44,217,116],{"class":84},[44,219,220],{"class":147},"540.0",[44,222,157],{"class":84},[44,224,225],{"class":147},"120.0",[44,227,157],{"class":84},[44,229,230],{"class":147},"310.0",[44,232,157],{"class":84},[44,234,235],{"class":147},"95.0",[44,237,157],{"class":84},[44,239,240],{"class":147},"410.0",[44,242,157],{"class":84},[44,244,245],{"class":147},"80.0",[44,247,157],{"class":84},[44,249,250],{"class":147},"220.0",[44,252,157],{"class":84},[44,254,255],{"class":147},"150.0",[44,257,258],{"class":84},"]})\n",[44,260,262,265,267,269,272,274,276,278,280,282,284,286,288,290,292,294,296],{"class":46,"line":261},6,[44,263,264],{"class":84},"promos ",[44,266,107],{"class":80},[44,268,110],{"class":84},[44,270,271],{"class":54},"\"Product\"",[44,273,116],{"class":84},[44,275,174],{"class":54},[44,277,157],{"class":84},[44,279,174],{"class":54},[44,281,157],{"class":84},[44,283,179],{"class":54},[44,285,157],{"class":84},[44,287,188],{"class":54},[44,289,157],{"class":84},[44,291,188],{"class":54},[44,293,157],{"class":84},[44,295,188],{"class":54},[44,297,209],{"class":84},[44,299,301,304,306,309,311,314,316,318,320,322,324,327,329,331],{"class":46,"line":300},7,[44,302,303],{"class":54},"                       \"Promo\"",[44,305,116],{"class":84},[44,307,308],{"class":54},"\"AUTUMN10\"",[44,310,157],{"class":84},[44,312,313],{"class":54},"\"TRADE5\"",[44,315,157],{"class":84},[44,317,308],{"class":54},[44,319,157],{"class":84},[44,321,308],{"class":54},[44,323,157],{"class":84},[44,325,326],{"class":54},"\"BULK15\"",[44,328,157],{"class":84},[44,330,313],{"class":54},[44,332,209],{"class":84},[44,334,336,339,341,344,346,348,350,352,354,356,358,361,363,365],{"class":46,"line":335},8,[44,337,338],{"class":54},"                       \"Priority\"",[44,340,116],{"class":84},[44,342,343],{"class":147},"2",[44,345,157],{"class":84},[44,347,154],{"class":147},[44,349,157],{"class":84},[44,351,343],{"class":147},[44,353,157],{"class":84},[44,355,343],{"class":147},[44,357,157],{"class":84},[44,359,360],{"class":147},"3",[44,362,157],{"class":84},[44,364,154],{"class":147},[44,366,209],{"class":84},[44,368,370,373,376,379,381,384,386,388,390,392,394,397,399,401],{"class":46,"line":369},9,[44,371,372],{"class":54},"                       \"Start\"",[44,374,375],{"class":84},": pd.to_datetime([",[44,377,378],{"class":54},"\"2026-09-01\"",[44,380,157],{"class":84},[44,382,383],{"class":54},"\"2026-06-01\"",[44,385,157],{"class":84},[44,387,378],{"class":54},[44,389,157],{"class":84},[44,391,378],{"class":54},[44,393,157],{"class":84},[44,395,396],{"class":54},"\"2026-09-10\"",[44,398,157],{"class":84},[44,400,383],{"class":54},[44,402,403],{"class":84},"])})\n",[44,405,407],{"class":46,"line":406},10,[44,408,98],{"emptyLinePlaceholder":97},[44,410,412,415,417,420,424,426,428,430,433,435,438],{"class":46,"line":411},11,[44,413,414],{"class":84},"joined ",[44,416,107],{"class":80},[44,418,419],{"class":84}," orders.merge(promos, ",[44,421,423],{"class":422},"sa561","on",[44,425,107],{"class":80},[44,427,271],{"class":54},[44,429,157],{"class":84},[44,431,432],{"class":422},"how",[44,434,107],{"class":80},[44,436,437],{"class":54},"\"left\"",[44,439,440],{"class":84},")\n",[44,442,444,447,449,451,454,456,459,462,464,467,469,472,475,478,481,483,485],{"class":46,"line":443},12,[44,445,446],{"class":147},"print",[44,448,151],{"class":84},[44,450,119],{"class":80},[44,452,453],{"class":54},"\"orders: ",[44,455,126],{"class":125},[44,457,458],{"class":147},"len",[44,460,461],{"class":84},"(orders)",[44,463,132],{"class":125},[44,465,466],{"class":54}," rows, revenue ",[44,468,126],{"class":125},[44,470,471],{"class":84},"orders[",[44,473,474],{"class":54},"'Revenue'",[44,476,477],{"class":84},"].sum()",[44,479,480],{"class":80},":,.0f",[44,482,132],{"class":125},[44,484,135],{"class":54},[44,486,440],{"class":84},[44,488,490,492,494,496,499,501,503,506,508,510,512,515,517,519,521,523,525],{"class":46,"line":489},13,[44,491,446],{"class":147},[44,493,151],{"class":84},[44,495,119],{"class":80},[44,497,498],{"class":54},"\"after merge: ",[44,500,126],{"class":125},[44,502,458],{"class":147},[44,504,505],{"class":84},"(joined)",[44,507,132],{"class":125},[44,509,466],{"class":54},[44,511,126],{"class":125},[44,513,514],{"class":84},"joined[",[44,516,474],{"class":54},[44,518,477],{"class":84},[44,520,480],{"class":80},[44,522,132],{"class":125},[44,524,135],{"class":54},[44,526,440],{"class":84},[44,528,530,533],{"class":46,"line":529},14,[44,531,532],{"class":80},"try",[44,534,535],{"class":84},":\n",[44,537,539,542,544,546,548,550,552,554,556,558,561,563,566],{"class":46,"line":538},15,[44,540,541],{"class":84},"    orders.merge(promos, ",[44,543,423],{"class":422},[44,545,107],{"class":80},[44,547,271],{"class":54},[44,549,157],{"class":84},[44,551,432],{"class":422},[44,553,107],{"class":80},[44,555,437],{"class":54},[44,557,157],{"class":84},[44,559,560],{"class":422},"validate",[44,562,107],{"class":80},[44,564,565],{"class":54},"\"many_to_one\"",[44,567,440],{"class":84},[44,569,571,574,577,579],{"class":46,"line":570},16,[44,572,573],{"class":80},"except",[44,575,576],{"class":84}," pd.errors.MergeError ",[44,578,88],{"class":80},[44,580,581],{"class":84}," e:\n",[44,583,585,588,590,593,595,598,601,604],{"class":46,"line":584},17,[44,586,587],{"class":147},"    print",[44,589,151],{"class":84},[44,591,592],{"class":54},"\"validate:\"",[44,594,157],{"class":84},[44,596,597],{"class":147},"str",[44,599,600],{"class":84},"(e).splitlines()[",[44,602,603],{"class":147},"0",[44,605,606],{"class":84},"])\n",[44,608,610,613,615,618,620],{"class":46,"line":609},18,[44,611,612],{"class":84},"fanout ",[44,614,107],{"class":80},[44,616,617],{"class":84}," promos[",[44,619,271],{"class":54},[44,621,622],{"class":84},"].value_counts()\n",[44,624,626,628,630,633,636,639,642],{"class":46,"line":625},19,[44,627,446],{"class":147},[44,629,151],{"class":84},[44,631,632],{"class":54},"\"keys with several matches:\"",[44,634,635],{"class":84},", fanout[fanout ",[44,637,638],{"class":80},">",[44,640,641],{"class":147}," 1",[44,643,644],{"class":84},"].to_dict())\n",[10,646,647,648,651],{},"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. ",[41,649,650],{},"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.",[653,654,662,663,662,667,662,671,662,678,662,687,662,691,662,698,662,702,662,706,662,710,662,714,662,718,662,722,662,724,662,728,662,730,662,733,662,735,662,738,662,741,662,744,662,746,662,750,662,752,662,755,662,757,662,760,662,762,662,765,662,767,662,770,662,772,662,775,662,777,662,780,662,783,662,786,662,789,662,793,662,796,662,799,662,802,662,804,662,807,662,809,662,811,662,813,662,816,662,818,662,820,662,822,662,824,662,826,662,828,662,830,662,833,662,835,662,837,662,839,662,841,662,846,662,849,662,851,662,854,662,856,662,858,662,861,662,864,662,866,662,868,662,870,662,872,662,874,662,876,662,879,662,883,662,885,662,887,662,889,662,892,662,894,662,896,662,899,662,903,662,905,662,907,662,909,662,911,662,913,662,915,662,921,662,925,662,932,662,937,662,945],"svg",{"viewBox":655,"role":656,"ariaLabelledBy":657,"xmlns":660,"style":661},"4 0 752 248","img",[658,659],"mm-fanout-t","mm-fanout-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[664,665,666],"title",{"id":658},"How a merge multiplies rows",[668,669,670],"desc",{"id":659},"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.",[672,673],"rect",{"x":674,"y":603,"width":675,"height":676,"fill":677},"4","752","248","#ffffff",[672,679],{"x":680,"y":681,"width":682,"height":683,"rx":603,"fill":684,"stroke":685,"style":686},"20.0","40.0","32.0","24.0","#e7ebef","var(--line,#cdd5e6)","stroke-width:1px",[672,688],{"x":689,"y":681,"width":690,"height":683,"rx":603,"fill":684,"stroke":685,"style":686},"52.0","58.9",[692,693,697],"text",{"x":694,"y":695,"style":696},"81.5","57.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[672,699],{"x":700,"y":681,"width":701,"height":683,"rx":603,"fill":684,"stroke":685,"style":686},"110.9","78.3",[692,703,705],{"x":704,"y":695,"style":696},"150.1","B",[672,707],{"x":708,"y":681,"width":709,"height":683,"rx":603,"fill":684,"stroke":685,"style":686},"189.2","77.4",[692,711,713],{"x":712,"y":695,"style":696},"227.9","C",[672,715],{"x":680,"y":716,"width":682,"height":717,"rx":603,"fill":684,"stroke":685,"style":686},"64.0","28.0",[692,719,154],{"x":720,"y":721,"style":696},"36.0","83.0",[672,723],{"x":689,"y":716,"width":690,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,725,727],{"x":694,"y":721,"style":726},"font-size:12px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Order",[672,729],{"x":700,"y":716,"width":701,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,731,732],{"x":704,"y":721,"style":726},"Product",[672,734],{"x":708,"y":716,"width":709,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,736,737],{"x":712,"y":721,"style":726},"Revenue",[672,739],{"x":680,"y":740,"width":682,"height":717,"rx":603,"fill":684,"stroke":685,"style":686},"92.0",[692,742,343],{"x":720,"y":743,"style":696},"111.0",[672,745],{"x":689,"y":740,"width":690,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,747,749],{"x":694,"y":743,"style":748},"font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","SO-1",[672,751],{"x":700,"y":740,"width":701,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,753,754],{"x":704,"y":743,"style":748},"Bolts",[672,756],{"x":708,"y":740,"width":709,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,758,759],{"x":712,"y":743,"style":748},"540",[672,761],{"x":680,"y":225,"width":682,"height":717,"rx":603,"fill":684,"stroke":685,"style":686},[692,763,360],{"x":720,"y":764,"style":696},"139.0",[672,766],{"x":689,"y":225,"width":690,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,768,769],{"x":694,"y":764,"style":748},"SO-4",[672,771],{"x":700,"y":225,"width":701,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,773,774],{"x":704,"y":764,"style":748},"Washers",[672,776],{"x":708,"y":225,"width":709,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,778,779],{"x":712,"y":764,"style":748},"95",[672,781],{"x":782,"y":681,"width":682,"height":683,"rx":603,"fill":684,"stroke":685,"style":686},"477.0",[672,784],{"x":785,"y":681,"width":690,"height":683,"rx":603,"fill":684,"stroke":685,"style":686},"509.0",[692,787,697],{"x":788,"y":695,"style":696},"538.4",[672,790],{"x":791,"y":681,"width":792,"height":683,"rx":603,"fill":684,"stroke":685,"style":686},"567.9","94.7",[692,794,705],{"x":795,"y":695,"style":696},"615.3",[672,797],{"x":798,"y":681,"width":709,"height":683,"rx":603,"fill":684,"stroke":685,"style":686},"662.6",[692,800,713],{"x":801,"y":695,"style":696},"701.3",[672,803],{"x":782,"y":716,"width":682,"height":717,"rx":603,"fill":684,"stroke":685,"style":686},[692,805,154],{"x":806,"y":721,"style":696},"493.0",[672,808],{"x":785,"y":716,"width":690,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,810,727],{"x":788,"y":721,"style":726},[672,812],{"x":791,"y":716,"width":792,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,814,815],{"x":795,"y":721,"style":726},"Promo",[672,817],{"x":798,"y":716,"width":709,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,819,737],{"x":801,"y":721,"style":726},[672,821],{"x":782,"y":740,"width":682,"height":717,"rx":603,"fill":684,"stroke":685,"style":686},[692,823,343],{"x":806,"y":743,"style":696},[672,825],{"x":785,"y":740,"width":690,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,827,749],{"x":788,"y":743,"style":748},[672,829],{"x":791,"y":740,"width":792,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,831,832],{"x":795,"y":743,"style":748},"AUTUMN10",[672,834],{"x":798,"y":740,"width":709,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,836,759],{"x":801,"y":743,"style":748},[672,838],{"x":782,"y":225,"width":682,"height":717,"rx":603,"fill":684,"stroke":685,"style":686},[692,840,360],{"x":806,"y":764,"style":696},[672,842],{"x":785,"y":225,"width":690,"height":717,"rx":603,"fill":843,"stroke":844,"style":845},"#fdefd8","var(--gold,#b4740a)","stroke-width:1.5px",[692,847,749],{"x":788,"y":764,"style":848},"font-size:12px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle",[672,850],{"x":791,"y":225,"width":792,"height":717,"rx":603,"fill":843,"stroke":844,"style":845},[692,852,853],{"x":795,"y":764,"style":848},"TRADE5",[672,855],{"x":798,"y":225,"width":709,"height":717,"rx":603,"fill":843,"stroke":844,"style":845},[692,857,759],{"x":801,"y":764,"style":848},[672,859],{"x":782,"y":860,"width":682,"height":717,"rx":603,"fill":684,"stroke":685,"style":686},"148.0",[692,862,674],{"x":806,"y":863,"style":696},"167.0",[672,865],{"x":785,"y":860,"width":690,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,867,769],{"x":788,"y":863,"style":748},[672,869],{"x":791,"y":860,"width":792,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,871,832],{"x":795,"y":863,"style":748},[672,873],{"x":798,"y":860,"width":709,"height":717,"rx":603,"fill":677,"stroke":685,"style":686},[692,875,779],{"x":801,"y":863,"style":748},[672,877],{"x":782,"y":878,"width":682,"height":717,"rx":603,"fill":684,"stroke":685,"style":686},"176.0",[692,880,882],{"x":806,"y":881,"style":696},"195.0","5",[672,884],{"x":785,"y":878,"width":690,"height":717,"rx":603,"fill":843,"stroke":844,"style":845},[692,886,769],{"x":788,"y":881,"style":848},[672,888],{"x":791,"y":878,"width":792,"height":717,"rx":603,"fill":843,"stroke":844,"style":845},[692,890,891],{"x":795,"y":881,"style":848},"BULK15",[672,893],{"x":798,"y":878,"width":709,"height":717,"rx":603,"fill":843,"stroke":844,"style":845},[692,895,779],{"x":801,"y":881,"style":848},[672,897],{"x":782,"y":898,"width":682,"height":717,"rx":603,"fill":684,"stroke":685,"style":686},"204.0",[692,900,902],{"x":806,"y":901,"style":696},"223.0","6",[672,904],{"x":785,"y":898,"width":690,"height":717,"rx":603,"fill":843,"stroke":844,"style":845},[692,906,769],{"x":788,"y":901,"style":848},[672,908],{"x":791,"y":898,"width":792,"height":717,"rx":603,"fill":843,"stroke":844,"style":845},[692,910,853],{"x":795,"y":901,"style":848},[672,912],{"x":798,"y":898,"width":709,"height":717,"rx":603,"fill":843,"stroke":844,"style":845},[692,914,779],{"x":801,"y":901,"style":848},[692,916,920],{"x":917,"y":918,"style":919},"143.3","29.0","font-size:12.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","before",[692,922,924],{"x":923,"y":918,"style":919},"608.5","after",[46,926],{"x1":927,"y1":928,"x2":929,"y2":928,"stroke":930,"style":931},"276.6","94.0","463.0","var(--brand,#5b5cf0)","stroke-width:2px",[933,934],"polygon",{"points":935,"fill":936},"467.0,94.0 458.0,99.0 458.0,89.0","#5b5cf0",[672,938],{"x":939,"y":940,"width":941,"height":942,"rx":943,"fill":677,"stroke":685,"style":944},"303.6","61.0","136.3","22.0","11","stroke-width:1.2px",[692,946,950],{"x":947,"y":948,"style":949},"371.8","76.0","font-size:11.5px;font-weight:400;fill:var(--brand-strong,#4338ca);text-anchor:middle;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace","merge on Product",[29,952,954],{"id":953},"step-2-answer-one-pick-one-match-by-a-rule","Step 2 — Answer one: pick one match by a rule",[10,956,957,958,960],{},"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 ",[14,959,920],{}," merging:",[34,962,964],{"className":71,"code":963,"language":73,"meta":39,"style":39},"best = (promos.sort_values([\"Product\", \"Priority\", \"Start\"], ascending=[True, False, False])\n              .drop_duplicates(\"Product\"))\none = orders.merge(best[[\"Product\", \"Promo\", \"Priority\"]], on=\"Product\", how=\"left\", validate=\"many_to_one\")\nprint(one.to_string(index=False))\nprint(\"revenue:\", f\"{one['Revenue'].sum():,.0f}\")\n",[41,965,966,1013,1023,1071,1087],{"__ignoreMap":39},[44,967,968,971,973,976,978,980,983,985,988,991,994,996,999,1002,1004,1007,1009,1011],{"class":46,"line":47},[44,969,970],{"class":84},"best ",[44,972,107],{"class":80},[44,974,975],{"class":84}," (promos.sort_values([",[44,977,271],{"class":54},[44,979,157],{"class":84},[44,981,982],{"class":54},"\"Priority\"",[44,984,157],{"class":84},[44,986,987],{"class":54},"\"Start\"",[44,989,990],{"class":84},"], ",[44,992,993],{"class":422},"ascending",[44,995,107],{"class":80},[44,997,998],{"class":84},"[",[44,1000,1001],{"class":147},"True",[44,1003,157],{"class":84},[44,1005,1006],{"class":147},"False",[44,1008,157],{"class":84},[44,1010,1006],{"class":147},[44,1012,606],{"class":84},[44,1014,1015,1018,1020],{"class":46,"line":94},[44,1016,1017],{"class":84},"              .drop_duplicates(",[44,1019,271],{"class":54},[44,1021,1022],{"class":84},"))\n",[44,1024,1025,1028,1030,1033,1035,1037,1040,1042,1044,1047,1049,1051,1053,1055,1057,1059,1061,1063,1065,1067,1069],{"class":46,"line":101},[44,1026,1027],{"class":84},"one ",[44,1029,107],{"class":80},[44,1031,1032],{"class":84}," orders.merge(best[[",[44,1034,271],{"class":54},[44,1036,157],{"class":84},[44,1038,1039],{"class":54},"\"Promo\"",[44,1041,157],{"class":84},[44,1043,982],{"class":54},[44,1045,1046],{"class":84},"]], ",[44,1048,423],{"class":422},[44,1050,107],{"class":80},[44,1052,271],{"class":54},[44,1054,157],{"class":84},[44,1056,432],{"class":422},[44,1058,107],{"class":80},[44,1060,437],{"class":54},[44,1062,157],{"class":84},[44,1064,560],{"class":422},[44,1066,107],{"class":80},[44,1068,565],{"class":54},[44,1070,440],{"class":84},[44,1072,1073,1075,1078,1081,1083,1085],{"class":46,"line":166},[44,1074,446],{"class":147},[44,1076,1077],{"class":84},"(one.to_string(",[44,1079,1080],{"class":422},"index",[44,1082,107],{"class":80},[44,1084,1006],{"class":147},[44,1086,1022],{"class":84},[44,1088,1089,1091,1093,1096,1098,1100,1102,1104,1107,1109,1111,1113,1115,1117],{"class":46,"line":212},[44,1090,446],{"class":147},[44,1092,151],{"class":84},[44,1094,1095],{"class":54},"\"revenue:\"",[44,1097,157],{"class":84},[44,1099,119],{"class":80},[44,1101,135],{"class":54},[44,1103,126],{"class":125},[44,1105,1106],{"class":84},"one[",[44,1108,474],{"class":54},[44,1110,477],{"class":84},[44,1112,480],{"class":80},[44,1114,132],{"class":125},[44,1116,135],{"class":54},[44,1118,440],{"class":84},[10,1120,1121,1122,1125],{},"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, ",[41,1123,1124],{},"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.",[29,1127,1129],{"id":1128},"step-3-answer-two-combine-the-matches-into-one-value","Step 3 — Answer two: combine the matches into one value",[10,1131,1132],{},"Sometimes every match is relevant and the report should show them all, on one row:",[34,1134,1136],{"className":71,"code":1135,"language":73,"meta":39,"style":39},"listed = (promos.sort_values(\"Priority\", ascending=False)\n                .groupby(\"Product\").agg(Promos=(\"Promo\", \", \".join), Count=(\"Promo\", \"size\")).reset_index())\ncombined = orders.merge(listed, on=\"Product\", how=\"left\", validate=\"many_to_one\")\ncombined[\"Count\"] = combined[\"Count\"].fillna(0).astype(int)\nprint(combined.to_string(index=False))\n",[41,1137,1138,1160,1204,1238,1269],{"__ignoreMap":39},[44,1139,1140,1143,1145,1148,1150,1152,1154,1156,1158],{"class":46,"line":47},[44,1141,1142],{"class":84},"listed ",[44,1144,107],{"class":80},[44,1146,1147],{"class":84}," (promos.sort_values(",[44,1149,982],{"class":54},[44,1151,157],{"class":84},[44,1153,993],{"class":422},[44,1155,107],{"class":80},[44,1157,1006],{"class":147},[44,1159,440],{"class":84},[44,1161,1162,1165,1167,1170,1173,1175,1177,1179,1181,1184,1187,1190,1192,1194,1196,1198,1201],{"class":46,"line":94},[44,1163,1164],{"class":84},"                .groupby(",[44,1166,271],{"class":54},[44,1168,1169],{"class":84},").agg(",[44,1171,1172],{"class":422},"Promos",[44,1174,107],{"class":80},[44,1176,151],{"class":84},[44,1178,1039],{"class":54},[44,1180,157],{"class":84},[44,1182,1183],{"class":54},"\", \"",[44,1185,1186],{"class":84},".join), ",[44,1188,1189],{"class":422},"Count",[44,1191,107],{"class":80},[44,1193,151],{"class":84},[44,1195,1039],{"class":54},[44,1197,157],{"class":84},[44,1199,1200],{"class":54},"\"size\"",[44,1202,1203],{"class":84},")).reset_index())\n",[44,1205,1206,1209,1211,1214,1216,1218,1220,1222,1224,1226,1228,1230,1232,1234,1236],{"class":46,"line":101},[44,1207,1208],{"class":84},"combined ",[44,1210,107],{"class":80},[44,1212,1213],{"class":84}," orders.merge(listed, ",[44,1215,423],{"class":422},[44,1217,107],{"class":80},[44,1219,271],{"class":54},[44,1221,157],{"class":84},[44,1223,432],{"class":422},[44,1225,107],{"class":80},[44,1227,437],{"class":54},[44,1229,157],{"class":84},[44,1231,560],{"class":422},[44,1233,107],{"class":80},[44,1235,565],{"class":54},[44,1237,440],{"class":84},[44,1239,1240,1243,1246,1249,1251,1254,1256,1259,1261,1264,1267],{"class":46,"line":166},[44,1241,1242],{"class":84},"combined[",[44,1244,1245],{"class":54},"\"Count\"",[44,1247,1248],{"class":84},"] ",[44,1250,107],{"class":80},[44,1252,1253],{"class":84}," combined[",[44,1255,1245],{"class":54},[44,1257,1258],{"class":84},"].fillna(",[44,1260,603],{"class":147},[44,1262,1263],{"class":84},").astype(",[44,1265,1266],{"class":147},"int",[44,1268,440],{"class":84},[44,1270,1271,1273,1276,1278,1280,1282],{"class":46,"line":212},[44,1272,446],{"class":147},[44,1274,1275],{"class":84},"(combined.to_string(",[44,1277,1080],{"class":422},[44,1279,107],{"class":80},[44,1281,1006],{"class":147},[44,1283,1022],{"class":84},[10,1285,1286],{},"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.",[653,1288,662,1293,662,1296,662,1299,662,1302,662,1310,662,1321,662,1326,662,1330,662,1334,662,1337,662,1340,662,1347,662,1352,662,1359,662,1366,662,1370,662,1375,662,1377,662,1380,662,1384,662,1387,662,1390,662,1396,662,1399,662,1402,662,1404,662,1407,662,1411,662,1414,662,1417,662,1424,662,1428],{"viewBox":1289,"role":656,"ariaLabelledBy":1290,"xmlns":660,"style":661},"4 0 752 272",[1291,1292],"mm-choose-t","mm-choose-d",[664,1294,1295],{"id":1291},"What should several matches mean?",[668,1297,1298],{"id":1292},"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.",[672,1300],{"x":674,"y":603,"width":675,"height":1301,"fill":677},"272",[672,1303],{"x":1304,"y":1305,"width":1306,"height":1307,"rx":1308,"fill":1309,"stroke":930,"style":931},"240.0","16.0","280.0","75.5","12","#f0f4ff",[1311,1312,1314],"g",{"transform":1313},"translate(368.0,29.0) scale(1.000)",[1315,1316],"path",{"d":1317,"fill":1318,"stroke":1319,"style":1320},"M6 4v5a5 5 0 0 0 5 5h7 M6 20v-5 M15 11l3 3-3 3","none","var(--brand-strong,#4338ca)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[692,1322,1325],{"x":1323,"y":1307,"style":1324},"380.0","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","A key matches several rows…",[46,1327],{"x1":1323,"y1":1328,"x2":1323,"y2":1329,"stroke":930,"style":931},"91.5","111.5",[46,1331],{"x1":1332,"y1":1329,"x2":1333,"y2":1329,"stroke":930,"style":931},"134.7","625.3",[46,1335],{"x1":1332,"y1":1329,"x2":1332,"y2":1336,"stroke":930,"style":931},"157.5",[933,1338],{"points":1339,"fill":936},"134.7,161.5 129.7,152.5 139.7,152.5",[672,1341],{"x":1342,"y":1343,"width":1344,"height":1345,"rx":1346,"fill":677,"stroke":685,"style":944},"91.8","121.5","85.7","21.0","10",[692,1348,1351],{"x":1332,"y":1349,"style":1350},"136.0","font-size:11.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","one applies",[672,1353],{"x":680,"y":1354,"width":1355,"height":1356,"rx":1308,"fill":1357,"stroke":1358,"style":931},"163.5","229.3","93.0","#d9f4f1","var(--teal,#0f9488)",[1311,1360,1362],{"transform":1361},"translate(122.7,176.5) scale(1.000)",[1315,1363],{"d":1364,"fill":1318,"stroke":1365,"style":1320},"M3 5h18l-7 8v6l-4 2v-8z","var(--teal-ink,#0b6157)",[692,1367,1369],{"x":1332,"y":901,"style":1368},"font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","pick by rule",[692,1371,1374],{"x":1332,"y":1372,"style":1373},"242.5","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","sort + drop_duplicates",[46,1376],{"x1":1323,"y1":1329,"x2":1323,"y2":1336,"stroke":930,"style":931},[933,1378],{"points":1379,"fill":936},"380.0,161.5 375.0,152.5 385.0,152.5",[672,1381],{"x":1382,"y":1343,"width":1383,"height":1345,"rx":1346,"fill":677,"stroke":685,"style":944},"317.5","125.0",[692,1385,1386],{"x":1323,"y":1349,"style":1350},"all are information",[672,1388],{"x":1389,"y":1354,"width":1355,"height":1356,"rx":1308,"fill":1309,"stroke":930,"style":931},"265.3",[1311,1391,1393],{"transform":1392},"translate(368.0,176.5) scale(1.000)",[1315,1394],{"d":1395,"fill":1318,"stroke":1319,"style":1320},"M12 3l9 5-9 5-9-5z M3 13l9 5 9-5 M3 17l9 5 9-5",[692,1397,1398],{"x":1323,"y":901,"style":1324},"combine",[692,1400,1401],{"x":1323,"y":1372,"style":1373},"list, count, max",[46,1403],{"x1":1333,"y1":1329,"x2":1333,"y2":1336,"stroke":930,"style":931},[933,1405],{"points":1406,"fill":936},"625.3,161.5 620.3,152.5 630.3,152.5",[672,1408],{"x":1409,"y":1343,"width":1410,"height":1345,"rx":1346,"fill":677,"stroke":685,"style":944},"565.5","119.7",[692,1412,1413],{"x":1333,"y":1349,"style":1350},"amount is shared",[672,1415],{"x":1416,"y":1354,"width":1355,"height":1356,"rx":1308,"fill":843,"stroke":844,"style":931},"510.7",[1311,1418,1420],{"transform":1419},"translate(613.3,176.5) scale(1.000)",[1315,1421],{"d":1422,"fill":1318,"stroke":1423,"style":1320},"M4 12h6 M10 12l6-6h4 M10 12l6 6h4 M17 3l3 3-3 3 M17 15l3 3-3 3","var(--gold-ink,#7a4e06)",[692,1425,1427],{"x":1333,"y":901,"style":1426},"font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","allocate",[692,1429,1430],{"x":1333,"y":1372,"style":1373},"totals reconcile",[29,1432,1434],{"id":1433},"step-4-answer-three-allocate-amounts-across-matches","Step 4 — Answer three: allocate amounts across matches",[10,1436,1437,1438,1441],{},"When the question is \"how much revenue did each promotion drive?\", every match genuinely needs its own row — but the revenue must be ",[14,1439,1440],{},"shared",", not copied:",[34,1443,1445],{"className":71,"code":1444,"language":73,"meta":39,"style":39},"alloc = orders.merge(promos[[\"Product\", \"Promo\"]], on=\"Product\", how=\"left\")\nalloc[\"Matches\"] = alloc.groupby(\"Order\")[\"Order\"].transform(\"size\")\nalloc[\"Allocated\"] = alloc[\"Revenue\"] \u002F alloc[\"Matches\"]\nby_promo = alloc.groupby(alloc[\"Promo\"].fillna(\"(no promo)\"))[\"Allocated\"].sum()\nassert abs(by_promo.sum() - orders[\"Revenue\"].sum()) \u003C 1e-6           # check before rounding\nshown = by_promo.round(2)\nprint(shown.to_string())\nprint(\"allocated total:\", f\"{by_promo.sum():,.2f}\", \"| sum of rounded shares:\", f\"{shown.sum():,.2f}\")\n",[41,1446,1447,1481,1510,1539,1564,1596,1610,1617],{"__ignoreMap":39},[44,1448,1449,1452,1454,1457,1459,1461,1463,1465,1467,1469,1471,1473,1475,1477,1479],{"class":46,"line":47},[44,1450,1451],{"class":84},"alloc ",[44,1453,107],{"class":80},[44,1455,1456],{"class":84}," orders.merge(promos[[",[44,1458,271],{"class":54},[44,1460,157],{"class":84},[44,1462,1039],{"class":54},[44,1464,1046],{"class":84},[44,1466,423],{"class":422},[44,1468,107],{"class":80},[44,1470,271],{"class":54},[44,1472,157],{"class":84},[44,1474,432],{"class":422},[44,1476,107],{"class":80},[44,1478,437],{"class":54},[44,1480,440],{"class":84},[44,1482,1483,1486,1489,1491,1493,1496,1498,1501,1503,1506,1508],{"class":46,"line":94},[44,1484,1485],{"class":84},"alloc[",[44,1487,1488],{"class":54},"\"Matches\"",[44,1490,1248],{"class":84},[44,1492,107],{"class":80},[44,1494,1495],{"class":84}," alloc.groupby(",[44,1497,113],{"class":54},[44,1499,1500],{"class":84},")[",[44,1502,113],{"class":54},[44,1504,1505],{"class":84},"].transform(",[44,1507,1200],{"class":54},[44,1509,440],{"class":84},[44,1511,1512,1514,1517,1519,1521,1524,1527,1529,1532,1534,1536],{"class":46,"line":101},[44,1513,1485],{"class":84},[44,1515,1516],{"class":54},"\"Allocated\"",[44,1518,1248],{"class":84},[44,1520,107],{"class":80},[44,1522,1523],{"class":84}," alloc[",[44,1525,1526],{"class":54},"\"Revenue\"",[44,1528,1248],{"class":84},[44,1530,1531],{"class":80},"\u002F",[44,1533,1523],{"class":84},[44,1535,1488],{"class":54},[44,1537,1538],{"class":84},"]\n",[44,1540,1541,1544,1546,1549,1551,1553,1556,1559,1561],{"class":46,"line":166},[44,1542,1543],{"class":84},"by_promo ",[44,1545,107],{"class":80},[44,1547,1548],{"class":84}," alloc.groupby(alloc[",[44,1550,1039],{"class":54},[44,1552,1258],{"class":84},[44,1554,1555],{"class":54},"\"(no promo)\"",[44,1557,1558],{"class":84},"))[",[44,1560,1516],{"class":54},[44,1562,1563],{"class":84},"].sum()\n",[44,1565,1566,1569,1572,1575,1578,1581,1583,1586,1589,1592],{"class":46,"line":212},[44,1567,1568],{"class":80},"assert",[44,1570,1571],{"class":147}," abs",[44,1573,1574],{"class":84},"(by_promo.sum() ",[44,1576,1577],{"class":80},"-",[44,1579,1580],{"class":84}," orders[",[44,1582,1526],{"class":54},[44,1584,1585],{"class":84},"].sum()) ",[44,1587,1588],{"class":80},"\u003C",[44,1590,1591],{"class":147}," 1e-6",[44,1593,1595],{"class":1594},"s-wDw","           # check before rounding\n",[44,1597,1598,1601,1603,1606,1608],{"class":46,"line":261},[44,1599,1600],{"class":84},"shown ",[44,1602,107],{"class":80},[44,1604,1605],{"class":84}," by_promo.round(",[44,1607,343],{"class":147},[44,1609,440],{"class":84},[44,1611,1612,1614],{"class":46,"line":300},[44,1613,446],{"class":147},[44,1615,1616],{"class":84},"(shown.to_string())\n",[44,1618,1619,1621,1623,1626,1628,1630,1632,1634,1637,1640,1642,1644,1646,1649,1651,1653,1655,1657,1660,1662,1664,1666],{"class":46,"line":335},[44,1620,446],{"class":147},[44,1622,151],{"class":84},[44,1624,1625],{"class":54},"\"allocated total:\"",[44,1627,157],{"class":84},[44,1629,119],{"class":80},[44,1631,135],{"class":54},[44,1633,126],{"class":125},[44,1635,1636],{"class":84},"by_promo.sum()",[44,1638,1639],{"class":80},":,.2f",[44,1641,132],{"class":125},[44,1643,135],{"class":54},[44,1645,157],{"class":84},[44,1647,1648],{"class":54},"\"| sum of rounded shares:\"",[44,1650,157],{"class":84},[44,1652,119],{"class":80},[44,1654,135],{"class":54},[44,1656,126],{"class":125},[44,1658,1659],{"class":84},"shown.sum()",[44,1661,1639],{"class":80},[44,1663,132],{"class":125},[44,1665,135],{"class":54},[44,1667,440],{"class":84},[10,1669,1670,1671,1674,1675,27],{},"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 ",[41,1672,1673],{},"1 \u002F 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 ",[23,1676,1678],{"href":1677},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fallocate-shared-costs-across-departments\u002F","Allocate Shared Costs Across Departments with Python",[653,1680,662,1686,662,1689,662,1692,662,1696,662,1700,662,1705,662,1709,662,1714,662,1717,662,1720,662,1723,662,1728,662,1731,662,1734,662,1737,662,1742,662,1744,662,1748,662,1751],{"viewBox":1681,"role":656,"ariaLabelledBy":1682,"xmlns":660,"style":1685},"4 0 742 180",[1683,1684],"mm-alloc-t","mm-alloc-d","width:100%;max-width:742px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[664,1687,1688],{"id":1683},"Revenue allocated to promotions",[668,1690,1691],{"id":1684},"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.",[672,1693],{"x":674,"y":603,"width":1694,"height":1695,"fill":677},"742","180",[672,1697],{"x":680,"y":1305,"width":1698,"height":717,"rx":1699,"fill":684,"stroke":685,"style":686},"102.5","7",[692,1701,832],{"x":1702,"y":1703,"style":1704},"71.3","34.5","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle",[672,1706],{"x":1707,"y":1305,"width":1708,"height":717,"rx":1699,"fill":1309,"stroke":930,"style":845},"132.5","545.8",[692,1710,1713],{"x":1711,"y":1703,"style":1712},"686.3","font-size:12px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:start","766.67",[672,1715],{"x":680,"y":1716,"width":1698,"height":717,"rx":1699,"fill":684,"stroke":685,"style":686},"56.0",[692,1718,853],{"x":1702,"y":1719,"style":1704},"74.5",[672,1721],{"x":1707,"y":1716,"width":1722,"height":717,"rx":1699,"fill":1357,"stroke":1358,"style":845},"403.4",[692,1724,1727],{"x":1725,"y":1719,"style":1726},"543.9","font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:start","566.67",[672,1729],{"x":680,"y":1730,"width":1698,"height":717,"rx":1699,"fill":684,"stroke":685,"style":686},"96.0",[692,1732,891],{"x":1702,"y":1733,"style":1704},"114.5",[672,1735],{"x":1707,"y":1730,"width":1736,"height":717,"rx":1699,"fill":843,"stroke":844,"style":845},"22.5",[692,1738,1741],{"x":1739,"y":1733,"style":1740},"163.1","font-size:12px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:start","31.67",[672,1743],{"x":680,"y":1349,"width":1698,"height":717,"rx":1699,"fill":684,"stroke":685,"style":686},[692,1745,1747],{"x":1702,"y":1746,"style":1704},"154.5","(no promo)",[672,1749],{"x":1707,"y":1349,"width":1750,"height":717,"rx":1699,"fill":684,"stroke":685,"style":845},"398.7",[692,1752,1755],{"x":1753,"y":1746,"style":1754},"539.2","font-size:12px;font-weight:700;fill:var(--text,#172033);text-anchor:start","560.00",[29,1757,1759],{"id":1758},"writing-it-up","Writing it up",[10,1761,1762,1763,27],{},"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 ",[23,1764,1766],{"href":1765},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fvalidate-a-merge-to-avoid-duplicate-rows\u002F","Validate a Merge to Avoid Duplicate Rows",[29,1768,1770],{"id":1769},"common-pitfalls","Common pitfalls",[1772,1773,1774,1782,1788,1796,1802],"ul",{},[1775,1776,1777,1781],"li",{},[1778,1779,1780],"strong",{},"Summing after an unvalidated merge."," Fan-out silently inflates every total built on the result.",[1775,1783,1784,1787],{},[1778,1785,1786],{},"Dropping duplicates after the merge."," Reduce the lookup side first, by an explicit and documented rule.",[1775,1789,1790,1795],{},[1778,1791,1792,1794],{},[41,1793,1124],{}," without sorting."," Which row survives must be decided by a rule, not left to file order.",[1775,1797,1798,1801],{},[1778,1799,1800],{},"Copying amounts to every match."," Allocate shares instead, and check that they add back to the original total.",[1775,1803,1804,1807],{},[1778,1805,1806],{},"No reconciliation in the output."," Show row counts and totals before and after every lookup.",[29,1809,1811],{"id":1810},"conclusion","Conclusion",[10,1813,1814,1815,1817],{},"A merge returns every match, so a lookup key that appears more than once multiplies the rows and inflates totals silently. Detect it with ",[41,1816,650],{}," 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.",[29,1819,1821],{"id":1820},"frequently-asked-questions","Frequently asked questions",[10,1823,1824,1827,1828,1831],{},[1778,1825,1826],{},"Why does my pandas merge create duplicate rows?","\nThe key appears more than once in the right-hand table, so each left row is repeated once per match; check with right",[44,1829,1830],{},"key",".value_counts() or validate=\"many_to_one\".",[10,1833,1834,1837],{},[1778,1835,1836],{},"How do I keep only one match per key in a lookup?","\nSort the lookup table by the rule that decides which match wins, such as priority or date, drop duplicates on the key, then merge.",[10,1839,1840,1843],{},[1778,1841,1842],{},"How do I show all matches on one row?","\nGroup the lookup table by the key and aggregate the matches, for example with \", \".join, before merging.",[10,1845,1846,1849],{},[1778,1847,1848],{},"How do I split an amount across several matches?","\nMerge 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.",[29,1851,1853],{"id":1852},"related","Related",[1772,1855,1856,1862,1869,1876],{},[1775,1857,1858,1859,1861],{},"Up one level: ",[23,1860,26],{"href":25}," — combining tables.",[1775,1863,1864,1868],{},[23,1865,1867],{"href":1866},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Fxlookup-with-multiple-criteria-in-pandas\u002F","XLOOKUP with Multiple Criteria in pandas"," — lookups on several keys.",[1775,1870,1871,1875],{},[23,1872,1874],{"href":1873},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Flook-up-all-matches-for-a-key-instead-of-the-first\u002F","Look Up All Matches for a Key Instead of the First"," — when you want every match.",[1775,1877,1878,1882],{},[23,1879,1881],{"href":1880},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fjoin-rows-to-effective-date-ranges-with-pandas\u002F","Join Rows to Effective Date Ranges with pandas"," — matches that depend on dates.",[1884,1885,1886],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}",{"title":39,"searchDepth":94,"depth":94,"links":1888},[1889,1890,1891,1892,1893,1894,1895,1896,1897,1898],{"id":31,"depth":94,"text":32},{"id":67,"depth":94,"text":68},{"id":953,"depth":94,"text":954},{"id":1128,"depth":94,"text":1129},{"id":1433,"depth":94,"text":1434},{"id":1758,"depth":94,"text":1759},{"id":1769,"depth":94,"text":1770},{"id":1810,"depth":94,"text":1811},{"id":1820,"depth":94,"text":1821},{"id":1852,"depth":94,"text":1853},"2026-10-02","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.","md",[1903,1905,1907,1909],{"q":1826,"a":1904},"The key appears more than once in the right-hand table, so each left row is repeated once per match; check with right[key].value_counts() or validate=\"many_to_one\".",{"q":1836,"a":1906},"Sort the lookup table by the rule that decides which match wins, such as priority or date, drop duplicates on the key, then merge.",{"q":1842,"a":1908},"Group the lookup table by the key and aggregate the matches, for example with \", \".join, before merging.",{"q":1848,"a":1910},"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.","many to many lookup without duplicating rows pandas",null,{"breadcrumb":1914},[1915,1917,1920],{"name":1916,"item":1531},"Home",{"name":1918,"item":1919},"Advanced Data Transformation and Cleaning for Excel Automation","\u002Fadvanced-data-transformation-and-cleaning\u002F",{"name":1921,"item":25},"Merging and Joining Excel DataFrames with Pandas","\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fhandle-many-to-many-lookups-without-duplicating-rows",{"title":1924,"description":1925},"Handle Many-to-Many Lookups in pandas Without Duplicating Rows","Stop merges from inflating totals: detect fan-out with validate and row counts, then aggregate the lookup side, pick one match by priority or date, join matches into a list, or allocate values across matches and reconcile.","handle-many-to-many-lookups-without-duplicating-rows","advanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fhandle-many-to-many-lookups-without-duplicating-rows\u002Findex",[1929,1930],"joins-and-lookups","pandas","how-to","Ot8e8pX-7jsAQZst6BEB8lw4OpNR7u5aMDTLoyR8WHo",[1934,1938],{"title":1935,"path":1936,"stem":1937,"children":-1},"Fuzzy Match Names Between Two Excel Files with Python","\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Ffuzzy-match-names-between-two-excel-files-with-python","advanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Ffuzzy-match-names-between-two-excel-files-with-python\u002Findex",{"title":1881,"path":1939,"stem":1940,"children":-1},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fjoin-rows-to-effective-date-ranges-with-pandas","advanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fjoin-rows-to-effective-date-ranges-with-pandas\u002Findex",[1942,1944],{"path":1943,"title":1918},"\u002Fadvanced-data-transformation-and-cleaning",{"path":1945,"title":1921},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes",[],[1948,1951,1954,1957,1960,1963],{"path":1949,"title":1950,"kind":1931},"\u002Fadvanced-data-transformation-and-cleaning\u002Fvalidating-excel-data-with-python\u002Fcompare-two-excel-files-for-differences-with-python","Compare Two Excel Files for Differences with Python",{"path":1952,"title":1953,"kind":1931},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fconvert-multi-currency-transactions-with-an-fx-rate-table","Convert Multi-Currency Transactions with an FX Rate Table",{"path":1955,"title":1956,"kind":1931},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl\u002Fhighlight-differences-between-two-columns-with-openpyxl","Highlight Differences Between Two Columns with openpyxl",{"path":1958,"title":1959,"kind":1931},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fmap-free-text-job-titles-to-standard-categories","Map Free-Text Job Titles to Standard Categories",{"path":1961,"title":1962,"kind":1931},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fstandardise-category-values-with-a-mapping-table","Standardise Category Values with a Mapping Table in pandas",{"path":1964,"title":1965,"kind":1931},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Fapproximate-match-vlookup-in-pandas","Approximate-Match VLOOKUP for Tiered Rates in pandas",1790972096263]