[{"data":1,"prerenderedAt":2247},["ShallowReactive",2],{"doc:\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmerge-excel-data-on-multiple-key-columns":3,"surround:\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmerge-excel-data-on-multiple-key-columns":2214,"ancestors:\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmerge-excel-data-on-multiple-key-columns":2223,"children:\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmerge-excel-data-on-multiple-key-columns":2228,"related:\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmerge-excel-data-on-multiple-key-columns":2229},{"id":4,"title":5,"body":6,"dateModified":2180,"datePublished":2180,"description":2181,"extension":2182,"faq":2183,"keyword":2192,"kind":2193,"meta":2194,"navigation":423,"path":2202,"seo":2203,"slug":2206,"stem":2207,"topics":2208,"type":2212,"__hash__":2213},"docs\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmerge-excel-data-on-multiple-key-columns\u002Findex.md","Merge Excel Data on Multiple Key Columns with pandas",{"type":7,"value":8,"toc":2166},"minimark",[9,28,327,332,359,376,380,395,604,618,622,630,836,853,857,865,1123,1134,1293,1297,1303,1437,1440,1558,1569,1573,1576,1794,1805,1923,1927,1930,2006,2009,2013,2061,2065,2068,2072,2090,2094,2111,2117,2123,2129,2133,2162],[10,11,12,13,17,18,21,22,27],"p",{},"Real lookups are rarely on one clean ID. A price depends on the region, the store and the product; a target on the rep and the month; a stock level on the warehouse and the item. In Excel this is a ",[14,15,16],"code",{},"VLOOKUP"," on a helper column that concatenates the keys, or an ",[14,19,20],{},"INDEX\u002FMATCH"," with several conditions. In pandas it is a merge on a list of columns — which works at once when the two files agree on how every key is written, and silently fails to match when they do not. This page, part of ",[23,24,26],"a",{"href":25},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002F","Merging and Joining Excel DataFrames with Pandas",", merges on a composite key and deals with each way it goes wrong.",[29,30,38,39,38,43,38,47,38,55,38,64,38,68,38,75,38,79,38,83,38,87,38,91,38,95,38,100,38,102,38,106,38,108,38,111,38,113,38,116,38,119,38,123,38,125,38,129,38,131,38,134,38,136,38,139,38,142,38,146,38,148,38,150,38,152,38,155,38,157,38,159,38,162,38,166,38,168,38,171,38,173,38,175,38,177,38,180,38,183,38,187,38,189,38,192,38,194,38,196,38,198,38,201,38,204,38,207,38,210,38,214,38,217,38,219,38,222,38,224,38,226,38,228,38,231,38,233,38,235,38,237,38,239,38,244,38,248,38,250,38,252,38,254,38,256,38,258,38,261,38,263,38,265,38,267,38,269,38,271,38,274,38,276,38,278,38,280,38,282,38,286,38,290,38,296,38,300,38,308,38,313,38,321],"svg",{"viewBox":31,"role":32,"ariaLabelledBy":33,"xmlns":36,"style":37},"71 0 618 220","img",[34,35],"mk-merge-t","mk-merge-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:618px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[40,41,42],"title",{"id":34},"Sales matched to prices on region, store and product",[44,45,46],"desc",{"id":35},"Left: sales rows with region, store and product. Right: the same rows with a unit price found by matching all three key columns in the price list. North store 7 Widgets gets 2.50, North store 12 Widgets 2.60, South store 7 Gadgets 11.50. A row whose combination is missing from the price list gets no price.",[48,49],"rect",{"x":50,"y":51,"width":52,"height":53,"fill":54},"71","0","618","220","#ffffff",[48,56],{"x":57,"y":58,"width":59,"height":60,"rx":51,"fill":61,"stroke":62,"style":63},"87.3","40.0","32.0","24.0","#e7ebef","var(--line,#cdd5e6)","stroke-width:1px",[48,65],{"x":66,"y":58,"width":67,"height":60,"rx":51,"fill":61,"stroke":62,"style":63},"119.3","63.6",[69,70,74],"text",{"x":71,"y":72,"style":73},"151.1","57.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[48,76],{"x":77,"y":58,"width":78,"height":60,"rx":51,"fill":61,"stroke":62,"style":63},"182.9","56.0",[69,80,82],{"x":81,"y":72,"style":73},"210.9","B",[48,84],{"x":85,"y":58,"width":86,"height":60,"rx":51,"fill":61,"stroke":62,"style":63},"238.9","75.8",[69,88,90],{"x":89,"y":72,"style":73},"276.8","C",[48,92],{"x":57,"y":93,"width":59,"height":94,"rx":51,"fill":61,"stroke":62,"style":63},"64.0","28.0",[69,96,99],{"x":97,"y":98,"style":73},"103.3","83.0","1",[48,101],{"x":66,"y":93,"width":67,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,103,105],{"x":71,"y":98,"style":104},"font-size:12px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","region",[48,107],{"x":77,"y":93,"width":78,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,109,110],{"x":81,"y":98,"style":104},"store",[48,112],{"x":85,"y":93,"width":86,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,114,115],{"x":89,"y":98,"style":104},"product",[48,117],{"x":57,"y":118,"width":59,"height":94,"rx":51,"fill":61,"stroke":62,"style":63},"92.0",[69,120,122],{"x":97,"y":121,"style":73},"111.0","2",[48,124],{"x":66,"y":118,"width":67,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,126,128],{"x":71,"y":121,"style":127},"font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","North",[48,130],{"x":77,"y":118,"width":78,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,132,133],{"x":81,"y":121,"style":127},"7",[48,135],{"x":85,"y":118,"width":86,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,137,138],{"x":89,"y":121,"style":127},"Widgets",[48,140],{"x":57,"y":141,"width":59,"height":94,"rx":51,"fill":61,"stroke":62,"style":63},"120.0",[69,143,145],{"x":97,"y":144,"style":73},"139.0","3",[48,147],{"x":66,"y":141,"width":67,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,149,128],{"x":71,"y":144,"style":127},[48,151],{"x":77,"y":141,"width":78,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,153,154],{"x":81,"y":144,"style":127},"12",[48,156],{"x":85,"y":141,"width":86,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,158,138],{"x":89,"y":144,"style":127},[48,160],{"x":57,"y":161,"width":59,"height":94,"rx":51,"fill":61,"stroke":62,"style":63},"148.0",[69,163,165],{"x":97,"y":164,"style":73},"167.0","4",[48,167],{"x":66,"y":161,"width":67,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,169,170],{"x":71,"y":164,"style":127},"South",[48,172],{"x":77,"y":161,"width":78,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,174,133],{"x":81,"y":164,"style":127},[48,176],{"x":85,"y":161,"width":86,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,178,179],{"x":89,"y":164,"style":127},"Gadgets",[48,181],{"x":57,"y":182,"width":59,"height":94,"rx":51,"fill":61,"stroke":62,"style":63},"176.0",[69,184,186],{"x":97,"y":185,"style":73},"195.0","5",[48,188],{"x":66,"y":182,"width":67,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,190,191],{"x":71,"y":185,"style":127},"East",[48,193],{"x":77,"y":182,"width":78,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,195,186],{"x":81,"y":185,"style":127},[48,197],{"x":85,"y":182,"width":86,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,199,200],{"x":89,"y":185,"style":127},"Gizmos",[48,202],{"x":203,"y":58,"width":59,"height":60,"rx":51,"fill":61,"stroke":62,"style":63},"479.7",[48,205],{"x":206,"y":58,"width":86,"height":60,"rx":51,"fill":61,"stroke":62,"style":63},"511.7",[69,208,74],{"x":209,"y":72,"style":73},"549.6",[48,211],{"x":212,"y":58,"width":213,"height":60,"rx":51,"fill":61,"stroke":62,"style":63},"587.6","85.1",[69,215,82],{"x":216,"y":72,"style":73},"630.1",[48,218],{"x":203,"y":93,"width":59,"height":94,"rx":51,"fill":61,"stroke":62,"style":63},[69,220,99],{"x":221,"y":98,"style":73},"495.7",[48,223],{"x":206,"y":93,"width":86,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,225,115],{"x":209,"y":98,"style":104},[48,227],{"x":212,"y":93,"width":213,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,229,230],{"x":216,"y":98,"style":104},"unit_price",[48,232],{"x":203,"y":118,"width":59,"height":94,"rx":51,"fill":61,"stroke":62,"style":63},[69,234,122],{"x":221,"y":121,"style":73},[48,236],{"x":206,"y":118,"width":86,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,238,138],{"x":209,"y":121,"style":127},[48,240],{"x":212,"y":118,"width":213,"height":94,"rx":51,"fill":241,"stroke":242,"style":243},"#d9f4f1","var(--teal,#0f9488)","stroke-width:1.5px",[69,245,247],{"x":216,"y":121,"style":246},"font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","2.50",[48,249],{"x":203,"y":141,"width":59,"height":94,"rx":51,"fill":61,"stroke":62,"style":63},[69,251,145],{"x":221,"y":144,"style":73},[48,253],{"x":206,"y":141,"width":86,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,255,138],{"x":209,"y":144,"style":127},[48,257],{"x":212,"y":141,"width":213,"height":94,"rx":51,"fill":241,"stroke":242,"style":243},[69,259,260],{"x":216,"y":144,"style":246},"2.60",[48,262],{"x":203,"y":161,"width":59,"height":94,"rx":51,"fill":61,"stroke":62,"style":63},[69,264,165],{"x":221,"y":164,"style":73},[48,266],{"x":206,"y":161,"width":86,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,268,179],{"x":209,"y":164,"style":127},[48,270],{"x":212,"y":161,"width":213,"height":94,"rx":51,"fill":241,"stroke":242,"style":243},[69,272,273],{"x":216,"y":164,"style":246},"11.50",[48,275],{"x":203,"y":182,"width":59,"height":94,"rx":51,"fill":61,"stroke":62,"style":63},[69,277,186],{"x":221,"y":185,"style":73},[48,279],{"x":206,"y":182,"width":86,"height":94,"rx":51,"fill":54,"stroke":62,"style":63},[69,281,200],{"x":209,"y":185,"style":127},[48,283],{"x":212,"y":182,"width":213,"height":94,"rx":51,"fill":284,"stroke":285,"style":243},"#fdefd8","var(--gold,#b4740a)",[69,287,289],{"x":216,"y":185,"style":288},"font-size:12px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","(none)",[69,291,295],{"x":292,"y":293,"style":294},"201.0","29.0","font-size:12.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","sales.xlsx",[69,297,299],{"x":298,"y":293,"style":294},"576.2","with unit_price",[301,302],"line",{"x1":303,"y1":304,"x2":305,"y2":304,"stroke":306,"style":307},"324.8","122.0","465.7","var(--brand,#5b5cf0)","stroke-width:2px",[309,310],"polygon",{"points":311,"fill":312},"469.7,122.0 460.7,127.0 460.7,117.0","#5b5cf0",[48,314],{"x":315,"y":316,"width":317,"height":318,"rx":319,"fill":54,"stroke":62,"style":320},"347.3","89.0","100.0","22.0","11","stroke-width:1.2px",[69,322,326],{"x":323,"y":324,"style":325},"397.2","104.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","on=[3 keys]",[328,329,331],"h2",{"id":330},"prerequisites","Prerequisites",[333,334,339],"pre",{"className":335,"code":336,"language":337,"meta":338,"style":338},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl\n","bash","",[14,340,341],{"__ignoreMap":338},[342,343,345,349,353,356],"span",{"class":301,"line":344},1,[342,346,348],{"class":347},"sMTad","pip",[342,350,352],{"class":351},"srMev"," install",[342,354,355],{"class":351}," pandas",[342,357,358],{"class":351}," openpyxl\n",[10,360,361,363,364,367,368,371,372,375],{},[14,362,295],{}," has six sales lines keyed by region, store number and product. ",[14,365,366],{},"prices.xlsx"," has store-specific prices — but written by another system: the column names are capitalised, store numbers are zero-padded text (",[14,369,370],{},"\"007\"","), and one region has a trailing space. ",[14,373,374],{},"list_prices.xlsx"," has one list price per product.",[328,377,379],{"id":378},"step-1-merge-on-a-list-of-columns","Step 1 — Merge on a list of columns",[10,381,382,383,386,387,390,391,394],{},"When the key columns have the same names in both files, pass them as a list to ",[14,384,385],{},"on",". When the names differ, ",[14,388,389],{},"left_on"," and ",[14,392,393],{},"right_on"," take lists in matching order:",[333,396,400],{"className":397,"code":398,"language":399,"meta":338,"style":338},"language-python shiki shiki-themes github-light github-dark-high-contrast","import pandas as pd\n\nsales = pd.read_excel(\"sales.xlsx\")\nprices = pd.read_excel(\"prices.xlsx\", dtype={\"Store\": str})\n\ntry:\n    sales.merge(prices, left_on=[\"region\", \"store\", \"product\"],\n                right_on=[\"Region\", \"Store\", \"Product\"], how=\"left\")\nexcept ValueError as exc:\n    print(\"ValueError:\", str(exc).split(\".\")[0])\n","python",[14,401,402,418,425,443,481,486,495,524,559,574],{"__ignoreMap":338},[342,403,404,408,412,415],{"class":301,"line":344},[342,405,407],{"class":406},"s-kum","import",[342,409,411],{"class":410},"skGVy"," pandas ",[342,413,414],{"class":406},"as",[342,416,417],{"class":410}," pd\n",[342,419,421],{"class":301,"line":420},2,[342,422,424],{"emptyLinePlaceholder":423},true,"\n",[342,426,428,431,434,437,440],{"class":301,"line":427},3,[342,429,430],{"class":410},"sales ",[342,432,433],{"class":406},"=",[342,435,436],{"class":410}," pd.read_excel(",[342,438,439],{"class":351},"\"sales.xlsx\"",[342,441,442],{"class":410},")\n",[342,444,446,449,451,453,456,459,463,465,468,471,474,478],{"class":301,"line":445},4,[342,447,448],{"class":410},"prices ",[342,450,433],{"class":406},[342,452,436],{"class":410},[342,454,455],{"class":351},"\"prices.xlsx\"",[342,457,458],{"class":410},", ",[342,460,462],{"class":461},"sa561","dtype",[342,464,433],{"class":406},[342,466,467],{"class":410},"{",[342,469,470],{"class":351},"\"Store\"",[342,472,473],{"class":410},": ",[342,475,477],{"class":476},"sP0c6","str",[342,479,480],{"class":410},"})\n",[342,482,484],{"class":301,"line":483},5,[342,485,424],{"emptyLinePlaceholder":423},[342,487,489,492],{"class":301,"line":488},6,[342,490,491],{"class":406},"try",[342,493,494],{"class":410},":\n",[342,496,498,501,503,505,508,511,513,516,518,521],{"class":301,"line":497},7,[342,499,500],{"class":410},"    sales.merge(prices, ",[342,502,389],{"class":461},[342,504,433],{"class":406},[342,506,507],{"class":410},"[",[342,509,510],{"class":351},"\"region\"",[342,512,458],{"class":410},[342,514,515],{"class":351},"\"store\"",[342,517,458],{"class":410},[342,519,520],{"class":351},"\"product\"",[342,522,523],{"class":410},"],\n",[342,525,527,530,532,534,537,539,541,543,546,549,552,554,557],{"class":301,"line":526},8,[342,528,529],{"class":461},"                right_on",[342,531,433],{"class":406},[342,533,507],{"class":410},[342,535,536],{"class":351},"\"Region\"",[342,538,458],{"class":410},[342,540,470],{"class":351},[342,542,458],{"class":410},[342,544,545],{"class":351},"\"Product\"",[342,547,548],{"class":410},"], ",[342,550,551],{"class":461},"how",[342,553,433],{"class":406},[342,555,556],{"class":351},"\"left\"",[342,558,442],{"class":410},[342,560,562,565,568,571],{"class":301,"line":561},9,[342,563,564],{"class":406},"except",[342,566,567],{"class":476}," ValueError",[342,569,570],{"class":406}," as",[342,572,573],{"class":410}," exc:\n",[342,575,577,580,583,586,588,590,593,596,599,601],{"class":301,"line":576},10,[342,578,579],{"class":476},"    print",[342,581,582],{"class":410},"(",[342,584,585],{"class":351},"\"ValueError:\"",[342,587,458],{"class":410},[342,589,477],{"class":476},[342,591,592],{"class":410},"(exc).split(",[342,594,595],{"class":351},"\".\"",[342,597,598],{"class":410},")[",[342,600,51],{"class":476},[342,602,603],{"class":410},"])\n",[10,605,606,607,610,611,390,614,617],{},"pandas refuses: ",[14,608,609],{},"You are trying to merge on int64 and str columns for key 'store'",". That is the helpful case — a type mismatch stops the merge. Two text columns that differ only in formatting, such as ",[14,612,613],{},"\"South\"",[14,615,616],{},"\"South \"",", merge without any error and simply fail to match, which is why the keys need checking before the merge rather than after.",[328,619,621],{"id":620},"step-2-diagnose-the-keys-that-do-not-match","Step 2 — Diagnose the keys that do not match",[10,623,624,625,629],{},"Compare the keys column by column — values ",[626,627,628],"em",{},"and"," types. A small helper lists, for each key, the values that appear on one side only:",[333,631,633],{"className":397,"code":632,"language":399,"meta":338,"style":338},"def key_report(left, right, left_keys, right_keys):\n    for lk, rk in zip(left_keys, right_keys):\n        lv, rv = set(left[lk].map(repr)), set(right[rk].map(repr))\n        print(f\"{lk}\u002F{rk}: dtypes {left[lk].dtype}\u002F{right[rk].dtype}; \"\n              f\"left only {sorted(lv - rv)[:4]}; right only {sorted(rv - lv)[:4]}\")\n\nkey_report(sales, prices, [\"region\", \"store\", \"product\"], [\"Region\", \"Store\", \"Product\"])\n",[14,634,635,647,664,694,748,802,806],{"__ignoreMap":338},[342,636,637,640,644],{"class":301,"line":344},[342,638,639],{"class":406},"def",[342,641,643],{"class":642},"s_Opv"," key_report",[342,645,646],{"class":410},"(left, right, left_keys, right_keys):\n",[342,648,649,652,655,658,661],{"class":301,"line":420},[342,650,651],{"class":406},"    for",[342,653,654],{"class":410}," lk, rk ",[342,656,657],{"class":406},"in",[342,659,660],{"class":476}," zip",[342,662,663],{"class":410},"(left_keys, right_keys):\n",[342,665,666,669,671,674,677,680,683,686,689,691],{"class":301,"line":427},[342,667,668],{"class":410},"        lv, rv ",[342,670,433],{"class":406},[342,672,673],{"class":476}," set",[342,675,676],{"class":410},"(left[lk].map(",[342,678,679],{"class":476},"repr",[342,681,682],{"class":410},")), ",[342,684,685],{"class":476},"set",[342,687,688],{"class":410},"(right[rk].map(",[342,690,679],{"class":476},[342,692,693],{"class":410},"))\n",[342,695,696,699,701,704,707,710,713,716,719,721,724,726,729,731,734,736,738,740,743,745],{"class":301,"line":445},[342,697,698],{"class":476},"        print",[342,700,582],{"class":410},[342,702,703],{"class":406},"f",[342,705,706],{"class":351},"\"",[342,708,467],{"class":709},"sSjpA",[342,711,712],{"class":410},"lk",[342,714,715],{"class":709},"}",[342,717,718],{"class":351},"\u002F",[342,720,467],{"class":709},[342,722,723],{"class":410},"rk",[342,725,715],{"class":709},[342,727,728],{"class":351},": dtypes ",[342,730,467],{"class":709},[342,732,733],{"class":410},"left[lk].dtype",[342,735,715],{"class":709},[342,737,718],{"class":351},[342,739,467],{"class":709},[342,741,742],{"class":410},"right[rk].dtype",[342,744,715],{"class":709},[342,746,747],{"class":351},"; \"\n",[342,749,750,753,756,758,761,764,767,770,772,775,777,780,782,784,787,789,792,794,796,798,800],{"class":301,"line":483},[342,751,752],{"class":406},"              f",[342,754,755],{"class":351},"\"left only ",[342,757,467],{"class":709},[342,759,760],{"class":476},"sorted",[342,762,763],{"class":410},"(lv ",[342,765,766],{"class":406},"-",[342,768,769],{"class":410}," rv)[:",[342,771,165],{"class":476},[342,773,774],{"class":410},"]",[342,776,715],{"class":709},[342,778,779],{"class":351},"; right only ",[342,781,467],{"class":709},[342,783,760],{"class":476},[342,785,786],{"class":410},"(rv ",[342,788,766],{"class":406},[342,790,791],{"class":410}," lv)[:",[342,793,165],{"class":476},[342,795,774],{"class":410},[342,797,715],{"class":709},[342,799,706],{"class":351},[342,801,442],{"class":410},[342,803,804],{"class":301,"line":488},[342,805,424],{"emptyLinePlaceholder":423},[342,807,808,811,813,815,817,819,821,824,826,828,830,832,834],{"class":301,"line":497},[342,809,810],{"class":410},"key_report(sales, prices, [",[342,812,510],{"class":351},[342,814,458],{"class":410},[342,816,515],{"class":351},[342,818,458],{"class":410},[342,820,520],{"class":351},[342,822,823],{"class":410},"], [",[342,825,536],{"class":351},[342,827,458],{"class":410},[342,829,470],{"class":351},[342,831,458],{"class":410},[342,833,545],{"class":351},[342,835,603],{"class":410},[10,837,838,839,841,842,845,846,849,850,852],{},"The report shows the problems at once: the store column is an integer on one side (",[14,840,133],{},") and zero-padded text on the other (",[14,843,844],{},"'007'","), and the region ",[14,847,848],{},"'South '"," has a trailing space. Using ",[14,851,679],{}," makes invisible differences — spaces, types, quotes — visible. Every multi-column merge that matches fewer rows than expected is worth this thirty-second check.",[328,854,856],{"id":855},"step-3-normalise-every-key-the-same-way-on-both-sides","Step 3 — Normalise every key the same way on both sides",[10,858,859,860,390,862,864],{},"Write one function that brings the key columns into a canonical form, and apply it to both frames. Text keys are stripped and case-folded; numeric-looking codes are converted to integers so that ",[14,861,133],{},[14,863,370],{}," agree:",[333,866,868],{"className":397,"code":867,"language":399,"meta":338,"style":338},"def normalise_keys(frame, mapping):\n    out = frame.rename(columns=mapping).copy()\n    for col in [\"region\", \"product\"]:\n        out[col] = out[col].astype(str).str.strip().str.casefold()\n    out[\"store\"] = pd.to_numeric(out[\"store\"], errors=\"raise\").astype(int)\n    return out\n\nsales_k = normalise_keys(sales, {})\nprices_k = normalise_keys(prices, {\"Region\": \"region\", \"Store\": \"store\", \"Product\": \"product\"})\n\nmerged = sales_k.merge(prices_k, on=[\"region\", \"store\", \"product\"], how=\"left\", indicator=True)\nprint(len(sales_k), \"sales ->\", len(merged), \"rows after the merge\")\nprint(merged.to_string(index=False))\n",[14,869,870,880,898,919,934,969,977,981,991,1025,1029,1076,1105],{"__ignoreMap":338},[342,871,872,874,877],{"class":301,"line":344},[342,873,639],{"class":406},[342,875,876],{"class":642}," normalise_keys",[342,878,879],{"class":410},"(frame, mapping):\n",[342,881,882,885,887,890,893,895],{"class":301,"line":420},[342,883,884],{"class":410},"    out ",[342,886,433],{"class":406},[342,888,889],{"class":410}," frame.rename(",[342,891,892],{"class":461},"columns",[342,894,433],{"class":406},[342,896,897],{"class":410},"mapping).copy()\n",[342,899,900,902,905,907,910,912,914,916],{"class":301,"line":427},[342,901,651],{"class":406},[342,903,904],{"class":410}," col ",[342,906,657],{"class":406},[342,908,909],{"class":410}," [",[342,911,510],{"class":351},[342,913,458],{"class":410},[342,915,520],{"class":351},[342,917,918],{"class":410},"]:\n",[342,920,921,924,926,929,931],{"class":301,"line":445},[342,922,923],{"class":410},"        out[col] ",[342,925,433],{"class":406},[342,927,928],{"class":410}," out[col].astype(",[342,930,477],{"class":476},[342,932,933],{"class":410},").str.strip().str.casefold()\n",[342,935,936,939,941,944,946,949,951,953,956,958,961,964,967],{"class":301,"line":483},[342,937,938],{"class":410},"    out[",[342,940,515],{"class":351},[342,942,943],{"class":410},"] ",[342,945,433],{"class":406},[342,947,948],{"class":410}," pd.to_numeric(out[",[342,950,515],{"class":351},[342,952,548],{"class":410},[342,954,955],{"class":461},"errors",[342,957,433],{"class":406},[342,959,960],{"class":351},"\"raise\"",[342,962,963],{"class":410},").astype(",[342,965,966],{"class":476},"int",[342,968,442],{"class":410},[342,970,971,974],{"class":301,"line":488},[342,972,973],{"class":406},"    return",[342,975,976],{"class":410}," out\n",[342,978,979],{"class":301,"line":497},[342,980,424],{"emptyLinePlaceholder":423},[342,982,983,986,988],{"class":301,"line":526},[342,984,985],{"class":410},"sales_k ",[342,987,433],{"class":406},[342,989,990],{"class":410}," normalise_keys(sales, {})\n",[342,992,993,996,998,1001,1003,1005,1007,1009,1011,1013,1015,1017,1019,1021,1023],{"class":301,"line":561},[342,994,995],{"class":410},"prices_k ",[342,997,433],{"class":406},[342,999,1000],{"class":410}," normalise_keys(prices, {",[342,1002,536],{"class":351},[342,1004,473],{"class":410},[342,1006,510],{"class":351},[342,1008,458],{"class":410},[342,1010,470],{"class":351},[342,1012,473],{"class":410},[342,1014,515],{"class":351},[342,1016,458],{"class":410},[342,1018,545],{"class":351},[342,1020,473],{"class":410},[342,1022,520],{"class":351},[342,1024,480],{"class":410},[342,1026,1027],{"class":301,"line":576},[342,1028,424],{"emptyLinePlaceholder":423},[342,1030,1032,1035,1037,1040,1042,1044,1046,1048,1050,1052,1054,1056,1058,1060,1062,1064,1066,1069,1071,1074],{"class":301,"line":1031},11,[342,1033,1034],{"class":410},"merged ",[342,1036,433],{"class":406},[342,1038,1039],{"class":410}," sales_k.merge(prices_k, ",[342,1041,385],{"class":461},[342,1043,433],{"class":406},[342,1045,507],{"class":410},[342,1047,510],{"class":351},[342,1049,458],{"class":410},[342,1051,515],{"class":351},[342,1053,458],{"class":410},[342,1055,520],{"class":351},[342,1057,548],{"class":410},[342,1059,551],{"class":461},[342,1061,433],{"class":406},[342,1063,556],{"class":351},[342,1065,458],{"class":410},[342,1067,1068],{"class":461},"indicator",[342,1070,433],{"class":406},[342,1072,1073],{"class":476},"True",[342,1075,442],{"class":410},[342,1077,1079,1082,1084,1087,1090,1093,1095,1097,1100,1103],{"class":301,"line":1078},12,[342,1080,1081],{"class":476},"print",[342,1083,582],{"class":410},[342,1085,1086],{"class":476},"len",[342,1088,1089],{"class":410},"(sales_k), ",[342,1091,1092],{"class":351},"\"sales ->\"",[342,1094,458],{"class":410},[342,1096,1086],{"class":476},[342,1098,1099],{"class":410},"(merged), ",[342,1101,1102],{"class":351},"\"rows after the merge\"",[342,1104,442],{"class":410},[342,1106,1108,1110,1113,1116,1118,1121],{"class":301,"line":1107},13,[342,1109,1081],{"class":476},[342,1111,1112],{"class":410},"(merged.to_string(",[342,1114,1115],{"class":461},"index",[342,1117,433],{"class":406},[342,1119,1120],{"class":476},"False",[342,1122,693],{"class":410},[10,1124,1125,1126,1129,1130,1133],{},"The keys now match — and the result has seven rows for six sales. The North–7–Widgets sale appears twice, once at 2.50 and once at 2.55, because the price list has two rows for that combination. Nothing warned about it; a total over ",[14,1127,1128],{},"units * unit_price"," would count those 40 units twice. ",[14,1131,1132],{},"errors=\"raise\""," in the store conversion is deliberate, by contrast: a store code that is not a number should stop the job, not become a key that quietly fails to match.",[29,1135,38,1141,38,1144,38,1147,38,1151,38,1156,38,1161,38,1166,38,1169,38,1172,38,1175,38,1179,38,1182,38,1187,38,1189,38,1194,38,1196,38,1199,38,1201,38,1204,38,1207,38,1211,38,1213,38,1215,38,1217,38,1219,38,1221,38,1224,38,1227,38,1231,38,1233,38,1237,38,1239,38,1242,38,1244,38,1247,38,1250,38,1254,38,1256,38,1260,38,1262,38,1265,38,1267,38,1270,38,1273,38,1277,38,1279,38,1283,38,1285,38,1288,38,1290],{"viewBox":1136,"role":32,"ariaLabelledBy":1137,"xmlns":36,"style":1140},"4 0 752 242",[1138,1139],"mk-mismatch-t","mk-mismatch-d","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[40,1142,1143],{"id":1138},"Why keys that look equal do not match",[44,1145,1146],{"id":1139},"A table of key mismatches found in the two files. Store 7 as an integer and 007 as text make pandas refuse the merge until both are converted to the same type. South with a trailing space does not match South until stripped. Capitalised column names do not align until renamed. A duplicated key in the price list doubles a sales row. East store 5 Gizmos is genuinely missing from the price list.",[48,1148],{"x":165,"y":51,"width":1149,"height":1150,"fill":54},"752","242",[48,1152],{"x":1153,"y":1154,"width":1155,"height":58,"rx":51,"fill":54,"stroke":62,"style":63},"20.0","16.0","146.9",[48,1157],{"x":1158,"y":1154,"width":1159,"height":58,"rx":51,"fill":1160,"stroke":306,"style":320},"166.9","191.0","#f0f4ff",[69,1162,295],{"x":1163,"y":1164,"style":1165},"262.4","41.0","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",[48,1167],{"x":1168,"y":1154,"width":1159,"height":58,"rx":51,"fill":1160,"stroke":306,"style":320},"358.0",[69,1170,366],{"x":1171,"y":1164,"style":1165},"453.5",[48,1173],{"x":1174,"y":1154,"width":1159,"height":58,"rx":51,"fill":1160,"stroke":306,"style":320},"549.0",[69,1176,1178],{"x":1177,"y":1164,"style":1165},"644.5","Fix",[48,1180],{"x":1153,"y":78,"width":1155,"height":1181,"rx":51,"fill":61,"stroke":62,"style":63},"34.0",[69,1183,1186],{"x":59,"y":1184,"style":1185},"78.0","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:start","Store number",[48,1188],{"x":1158,"y":78,"width":1159,"height":1181,"rx":51,"fill":54,"stroke":62,"style":63},[69,1190,1193],{"x":1163,"y":1191,"style":1192},"77.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","7 (int)",[48,1195],{"x":1168,"y":78,"width":1159,"height":1181,"rx":51,"fill":54,"stroke":62,"style":63},[69,1197,1198],{"x":1171,"y":1191,"style":1192},"\"007\" (text)",[48,1200],{"x":1174,"y":78,"width":1159,"height":1181,"rx":51,"fill":54,"stroke":62,"style":63},[69,1202,1203],{"x":1177,"y":1191,"style":1192},"to_numeric on both",[48,1205],{"x":1153,"y":1206,"width":1155,"height":1181,"rx":51,"fill":61,"stroke":62,"style":63},"90.0",[69,1208,1210],{"x":59,"y":1209,"style":1185},"112.0","Region",[48,1212],{"x":1158,"y":1206,"width":1159,"height":1181,"rx":51,"fill":54,"stroke":62,"style":63},[69,1214,170],{"x":1163,"y":121,"style":1192},[48,1216],{"x":1168,"y":1206,"width":1159,"height":1181,"rx":51,"fill":54,"stroke":62,"style":63},[69,1218,616],{"x":1171,"y":121,"style":1192},[48,1220],{"x":1174,"y":1206,"width":1159,"height":1181,"rx":51,"fill":54,"stroke":62,"style":63},[69,1222,1223],{"x":1177,"y":121,"style":1192},"strip + casefold",[48,1225],{"x":1153,"y":1226,"width":1155,"height":1181,"rx":51,"fill":61,"stroke":62,"style":63},"124.0",[69,1228,1230],{"x":59,"y":1229,"style":1185},"146.0","Column names",[48,1232],{"x":1158,"y":1226,"width":1159,"height":1181,"rx":51,"fill":54,"stroke":62,"style":63},[69,1234,1236],{"x":1163,"y":1235,"style":1192},"145.0","region, store",[48,1238],{"x":1168,"y":1226,"width":1159,"height":1181,"rx":51,"fill":54,"stroke":62,"style":63},[69,1240,1241],{"x":1171,"y":1235,"style":1192},"Region, Store",[48,1243],{"x":1174,"y":1226,"width":1159,"height":1181,"rx":51,"fill":54,"stroke":62,"style":63},[69,1245,1246],{"x":1177,"y":1235,"style":1192},"rename to one set",[48,1248],{"x":1153,"y":1249,"width":1155,"height":1181,"rx":51,"fill":61,"stroke":62,"style":63},"158.0",[69,1251,1253],{"x":59,"y":1252,"style":1185},"180.0","North · 7 · Widgets",[48,1255],{"x":1158,"y":1249,"width":1159,"height":1181,"rx":51,"fill":54,"stroke":62,"style":63},[69,1257,1259],{"x":1163,"y":1258,"style":1192},"179.0","one sale",[48,1261],{"x":1168,"y":1249,"width":1159,"height":1181,"rx":51,"fill":54,"stroke":62,"style":63},[69,1263,1264],{"x":1171,"y":1258,"style":1192},"two prices",[48,1266],{"x":1174,"y":1249,"width":1159,"height":1181,"rx":51,"fill":54,"stroke":62,"style":63},[69,1268,1269],{"x":1177,"y":1258,"style":1192},"validate, then resolve",[48,1271],{"x":1153,"y":1272,"width":1155,"height":1181,"rx":51,"fill":61,"stroke":62,"style":63},"192.0",[69,1274,1276],{"x":59,"y":1275,"style":1185},"214.0","East · 5 · Gizmos",[48,1278],{"x":1158,"y":1272,"width":1159,"height":1181,"rx":51,"fill":54,"stroke":62,"style":63},[69,1280,1282],{"x":1163,"y":1281,"style":1192},"213.0","present",[48,1284],{"x":1168,"y":1272,"width":1159,"height":1181,"rx":51,"fill":54,"stroke":62,"style":63},[69,1286,1287],{"x":1171,"y":1281,"style":1192},"absent",[48,1289],{"x":1174,"y":1272,"width":1159,"height":1181,"rx":51,"fill":54,"stroke":62,"style":63},[69,1291,1292],{"x":1177,"y":1281,"style":1192},"fallback price",[328,1294,1296],{"id":1295},"step-4-stop-duplicate-keys-from-multiplying-rows","Step 4 — Stop duplicate keys from multiplying rows",[10,1298,1299,1302],{},[14,1300,1301],{},"validate=\"many_to_one\""," checks that the right-hand table is unique on the key and raises if it is not — turning the silent doubling from Step 3 into an error:",[333,1304,1306],{"className":397,"code":1305,"language":399,"meta":338,"style":338},"try:\n    sales_k.merge(prices_k, on=[\"region\", \"store\", \"product\"], how=\"left\", validate=\"many_to_one\")\nexcept pd.errors.MergeError as exc:\n    print(\"MergeError:\", str(exc).splitlines()[0])\n\ndupes = prices_k[prices_k.duplicated([\"region\", \"store\", \"product\"], keep=False)]\nprint(dupes.to_string(index=False))\n",[14,1307,1308,1314,1355,1366,1386,1390,1422],{"__ignoreMap":338},[342,1309,1310,1312],{"class":301,"line":344},[342,1311,491],{"class":406},[342,1313,494],{"class":410},[342,1315,1316,1319,1321,1323,1325,1327,1329,1331,1333,1335,1337,1339,1341,1343,1345,1348,1350,1353],{"class":301,"line":420},[342,1317,1318],{"class":410},"    sales_k.merge(prices_k, ",[342,1320,385],{"class":461},[342,1322,433],{"class":406},[342,1324,507],{"class":410},[342,1326,510],{"class":351},[342,1328,458],{"class":410},[342,1330,515],{"class":351},[342,1332,458],{"class":410},[342,1334,520],{"class":351},[342,1336,548],{"class":410},[342,1338,551],{"class":461},[342,1340,433],{"class":406},[342,1342,556],{"class":351},[342,1344,458],{"class":410},[342,1346,1347],{"class":461},"validate",[342,1349,433],{"class":406},[342,1351,1352],{"class":351},"\"many_to_one\"",[342,1354,442],{"class":410},[342,1356,1357,1359,1362,1364],{"class":301,"line":427},[342,1358,564],{"class":406},[342,1360,1361],{"class":410}," pd.errors.MergeError ",[342,1363,414],{"class":406},[342,1365,573],{"class":410},[342,1367,1368,1370,1372,1375,1377,1379,1382,1384],{"class":301,"line":445},[342,1369,579],{"class":476},[342,1371,582],{"class":410},[342,1373,1374],{"class":351},"\"MergeError:\"",[342,1376,458],{"class":410},[342,1378,477],{"class":476},[342,1380,1381],{"class":410},"(exc).splitlines()[",[342,1383,51],{"class":476},[342,1385,603],{"class":410},[342,1387,1388],{"class":301,"line":483},[342,1389,424],{"emptyLinePlaceholder":423},[342,1391,1392,1395,1397,1400,1402,1404,1406,1408,1410,1412,1415,1417,1419],{"class":301,"line":488},[342,1393,1394],{"class":410},"dupes ",[342,1396,433],{"class":406},[342,1398,1399],{"class":410}," prices_k[prices_k.duplicated([",[342,1401,510],{"class":351},[342,1403,458],{"class":410},[342,1405,515],{"class":351},[342,1407,458],{"class":410},[342,1409,520],{"class":351},[342,1411,548],{"class":410},[342,1413,1414],{"class":461},"keep",[342,1416,433],{"class":406},[342,1418,1120],{"class":476},[342,1420,1421],{"class":410},")]\n",[342,1423,1424,1426,1429,1431,1433,1435],{"class":301,"line":497},[342,1425,1081],{"class":476},[342,1427,1428],{"class":410},"(dupes.to_string(",[342,1430,1115],{"class":461},[342,1432,433],{"class":406},[342,1434,1120],{"class":476},[342,1436,693],{"class":410},[10,1438,1439],{},"The error names the problem and the second print shows the offending rows, so the price list's owner can decide which price is right. Resolving it is a business decision, not a technical one; here, assume the list is in date order and the last entry for a key is current:",[333,1441,1443],{"className":397,"code":1442,"language":399,"meta":338,"style":338},"prices_u = prices_k.drop_duplicates([\"region\", \"store\", \"product\"], keep=\"last\")\nmerged = sales_k.merge(prices_u, on=[\"region\", \"store\", \"product\"], how=\"left\",\n                       validate=\"many_to_one\", indicator=True)\nprint(len(merged), \"rows;\", merged[\"unit_price\"].notna().sum(), \"with a store price\")\n",[14,1444,1445,1476,1512,1531],{"__ignoreMap":338},[342,1446,1447,1450,1452,1455,1457,1459,1461,1463,1465,1467,1469,1471,1474],{"class":301,"line":344},[342,1448,1449],{"class":410},"prices_u ",[342,1451,433],{"class":406},[342,1453,1454],{"class":410}," prices_k.drop_duplicates([",[342,1456,510],{"class":351},[342,1458,458],{"class":410},[342,1460,515],{"class":351},[342,1462,458],{"class":410},[342,1464,520],{"class":351},[342,1466,548],{"class":410},[342,1468,1414],{"class":461},[342,1470,433],{"class":406},[342,1472,1473],{"class":351},"\"last\"",[342,1475,442],{"class":410},[342,1477,1478,1480,1482,1485,1487,1489,1491,1493,1495,1497,1499,1501,1503,1505,1507,1509],{"class":301,"line":420},[342,1479,1034],{"class":410},[342,1481,433],{"class":406},[342,1483,1484],{"class":410}," sales_k.merge(prices_u, ",[342,1486,385],{"class":461},[342,1488,433],{"class":406},[342,1490,507],{"class":410},[342,1492,510],{"class":351},[342,1494,458],{"class":410},[342,1496,515],{"class":351},[342,1498,458],{"class":410},[342,1500,520],{"class":351},[342,1502,548],{"class":410},[342,1504,551],{"class":461},[342,1506,433],{"class":406},[342,1508,556],{"class":351},[342,1510,1511],{"class":410},",\n",[342,1513,1514,1517,1519,1521,1523,1525,1527,1529],{"class":301,"line":427},[342,1515,1516],{"class":461},"                       validate",[342,1518,433],{"class":406},[342,1520,1352],{"class":351},[342,1522,458],{"class":410},[342,1524,1068],{"class":461},[342,1526,433],{"class":406},[342,1528,1073],{"class":476},[342,1530,442],{"class":410},[342,1532,1533,1535,1537,1539,1541,1544,1547,1550,1553,1556],{"class":301,"line":445},[342,1534,1081],{"class":476},[342,1536,582],{"class":410},[342,1538,1086],{"class":476},[342,1540,1099],{"class":410},[342,1542,1543],{"class":351},"\"rows;\"",[342,1545,1546],{"class":410},", merged[",[342,1548,1549],{"class":351},"\"unit_price\"",[342,1551,1552],{"class":410},"].notna().sum(), ",[342,1554,1555],{"class":351},"\"with a store price\"",[342,1557,442],{"class":410},[10,1559,1560,1561,1563,1564,1568],{},"Six rows for six sales, four of them with a store-specific price. Use ",[14,1562,1347],{}," on every lookup merge; it costs almost nothing. ",[23,1565,1567],{"href":1566},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fvalidate-a-merge-to-avoid-duplicate-rows\u002F","Validate a pandas Merge to Avoid Duplicate Rows"," covers the other validation modes.",[328,1570,1572],{"id":1571},"step-5-fall-back-to-a-less-specific-key","Step 5 — Fall back to a less specific key",[10,1574,1575],{},"Two sales still have no store-specific price: East–5–Gizmos and North–7–Gadgets. When the business rule is \"use the store price if there is one, otherwise the product's list price\", merge the list prices on the shorter key and fill the gaps:",[333,1577,1579],{"className":397,"code":1578,"language":399,"meta":338,"style":338},"lists = pd.read_excel(\"list_prices.xlsx\")\nlists[\"product\"] = lists[\"product\"].str.strip().str.casefold()\n\npriced = merged.drop(columns=\"_merge\").merge(lists, on=\"product\", how=\"left\", validate=\"many_to_one\")\npriced[\"price_used\"] = priced[\"unit_price\"].fillna(priced[\"list_price\"])\npriced[\"price_source\"] = priced[\"unit_price\"].notna().map({True: \"store\", False: \"list\"})\npriced[\"value\"] = priced[\"units\"] * priced[\"price_used\"]\nprint(priced[[\"region\", \"store\", \"product\", \"units\", \"price_used\", \"price_source\", \"value\"]].to_string(index=False))\n",[14,1580,1581,1595,1614,1618,1662,1687,1722,1750],{"__ignoreMap":338},[342,1582,1583,1586,1588,1590,1593],{"class":301,"line":344},[342,1584,1585],{"class":410},"lists ",[342,1587,433],{"class":406},[342,1589,436],{"class":410},[342,1591,1592],{"class":351},"\"list_prices.xlsx\"",[342,1594,442],{"class":410},[342,1596,1597,1600,1602,1604,1606,1609,1611],{"class":301,"line":420},[342,1598,1599],{"class":410},"lists[",[342,1601,520],{"class":351},[342,1603,943],{"class":410},[342,1605,433],{"class":406},[342,1607,1608],{"class":410}," lists[",[342,1610,520],{"class":351},[342,1612,1613],{"class":410},"].str.strip().str.casefold()\n",[342,1615,1616],{"class":301,"line":427},[342,1617,424],{"emptyLinePlaceholder":423},[342,1619,1620,1623,1625,1628,1630,1632,1635,1638,1640,1642,1644,1646,1648,1650,1652,1654,1656,1658,1660],{"class":301,"line":445},[342,1621,1622],{"class":410},"priced ",[342,1624,433],{"class":406},[342,1626,1627],{"class":410}," merged.drop(",[342,1629,892],{"class":461},[342,1631,433],{"class":406},[342,1633,1634],{"class":351},"\"_merge\"",[342,1636,1637],{"class":410},").merge(lists, ",[342,1639,385],{"class":461},[342,1641,433],{"class":406},[342,1643,520],{"class":351},[342,1645,458],{"class":410},[342,1647,551],{"class":461},[342,1649,433],{"class":406},[342,1651,556],{"class":351},[342,1653,458],{"class":410},[342,1655,1347],{"class":461},[342,1657,433],{"class":406},[342,1659,1352],{"class":351},[342,1661,442],{"class":410},[342,1663,1664,1667,1670,1672,1674,1677,1679,1682,1685],{"class":301,"line":483},[342,1665,1666],{"class":410},"priced[",[342,1668,1669],{"class":351},"\"price_used\"",[342,1671,943],{"class":410},[342,1673,433],{"class":406},[342,1675,1676],{"class":410}," priced[",[342,1678,1549],{"class":351},[342,1680,1681],{"class":410},"].fillna(priced[",[342,1683,1684],{"class":351},"\"list_price\"",[342,1686,603],{"class":410},[342,1688,1689,1691,1694,1696,1698,1700,1702,1705,1707,1709,1711,1713,1715,1717,1720],{"class":301,"line":488},[342,1690,1666],{"class":410},[342,1692,1693],{"class":351},"\"price_source\"",[342,1695,943],{"class":410},[342,1697,433],{"class":406},[342,1699,1676],{"class":410},[342,1701,1549],{"class":351},[342,1703,1704],{"class":410},"].notna().map({",[342,1706,1073],{"class":476},[342,1708,473],{"class":410},[342,1710,515],{"class":351},[342,1712,458],{"class":410},[342,1714,1120],{"class":476},[342,1716,473],{"class":410},[342,1718,1719],{"class":351},"\"list\"",[342,1721,480],{"class":410},[342,1723,1724,1726,1729,1731,1733,1735,1738,1740,1743,1745,1747],{"class":301,"line":497},[342,1725,1666],{"class":410},[342,1727,1728],{"class":351},"\"value\"",[342,1730,943],{"class":410},[342,1732,433],{"class":406},[342,1734,1676],{"class":410},[342,1736,1737],{"class":351},"\"units\"",[342,1739,943],{"class":410},[342,1741,1742],{"class":406},"*",[342,1744,1676],{"class":410},[342,1746,1669],{"class":351},[342,1748,1749],{"class":410},"]\n",[342,1751,1752,1754,1757,1759,1761,1763,1765,1767,1769,1771,1773,1775,1777,1779,1781,1783,1786,1788,1790,1792],{"class":301,"line":526},[342,1753,1081],{"class":476},[342,1755,1756],{"class":410},"(priced[[",[342,1758,510],{"class":351},[342,1760,458],{"class":410},[342,1762,515],{"class":351},[342,1764,458],{"class":410},[342,1766,520],{"class":351},[342,1768,458],{"class":410},[342,1770,1737],{"class":351},[342,1772,458],{"class":410},[342,1774,1669],{"class":351},[342,1776,458],{"class":410},[342,1778,1693],{"class":351},[342,1780,458],{"class":410},[342,1782,1728],{"class":351},[342,1784,1785],{"class":410},"]].to_string(",[342,1787,1115],{"class":461},[342,1789,433],{"class":406},[342,1791,1120],{"class":476},[342,1793,693],{"class":410},[10,1795,1796,1797,1800,1801,1804],{},"Every sale now has a price, and the ",[14,1798,1799],{},"price_source"," column records which rule supplied it — important when someone questions a figure. The same pattern extends to more levels (store, then region, then product) by chaining ",[14,1802,1803],{},"fillna"," from progressively shorter keys, most specific first.",[29,1806,38,1811,38,1814,38,1817,38,1820,38,1824,38,1835,38,1841,38,1844,38,1848,38,1853,38,1856,38,1859,38,1866,38,1872,38,1876,38,1880,38,1883,38,1886,38,1893,38,1897,38,1900,38,1904,38,1907,38,1910,38,1916,38,1920],{"viewBox":1807,"role":32,"ariaLabelledBy":1808,"xmlns":36,"style":1140},"4 0 752 142",[1809,1810],"mk-fallback-t","mk-fallback-d",[40,1812,1813],{"id":1809},"Looking up a price with a fallback",[44,1815,1816],{"id":1810},"Four steps. Merge on region, store and product for store-specific prices. For rows still missing a price, merge on product alone for the list price. Take the store price when present, otherwise the list price. Record which source was used for each row.",[48,1818],{"x":165,"y":51,"width":1149,"height":1819,"fill":54},"142",[48,1821],{"x":1153,"y":1154,"width":1822,"height":1823,"rx":154,"fill":1160,"stroke":306,"style":307},"153.0","110.5",[1825,1826,1828],"g",{"transform":1827},"translate(84.5,29.0) scale(1.000)",[1829,1830],"path",{"d":1831,"fill":1832,"stroke":1833,"style":1834},"M8 11a4 4 0 1 0 .01 0z M11 13l9-9 M17 7l2 2 M15 9l2 2","none","var(--brand-strong,#4338ca)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[69,1836,1840],{"x":1837,"y":1838,"style":1839},"96.5","75.5","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Region + store +",[69,1842,115],{"x":1837,"y":1843,"style":1839},"93.0",[69,1845,1847],{"x":1837,"y":1846,"style":1192},"112.5","store price",[301,1849],{"x1":1850,"y1":1851,"x2":1852,"y2":1851,"stroke":306,"style":307},"178.0","71.2","200.0",[309,1854],{"points":1855,"fill":312},"204.0,71.2 195.0,76.2 195.0,66.2",[48,1857],{"x":1858,"y":1154,"width":1822,"height":1823,"rx":154,"fill":61,"stroke":62,"style":307},"209.0",[1825,1860,1862],{"transform":1861},"translate(273.5,37.8) scale(1.000)",[1829,1863],{"d":1864,"fill":1832,"stroke":1865,"style":1834},"M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1 M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1","var(--text,#172033)",[69,1867,1871],{"x":1868,"y":1869,"style":1870},"285.5","84.2","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Product only",[69,1873,1875],{"x":1868,"y":1874,"style":1192},"103.8","list price",[301,1877],{"x1":1878,"y1":1851,"x2":1879,"y2":1851,"stroke":306,"style":307},"367.0","389.0",[309,1881],{"points":1882,"fill":312},"393.0,71.2 384.0,76.2 384.0,66.2",[48,1884],{"x":1885,"y":1154,"width":1822,"height":1823,"rx":154,"fill":241,"stroke":242,"style":307},"398.0",[1825,1887,1889],{"transform":1888},"translate(462.5,37.8) scale(1.000)",[1829,1890],{"d":1891,"fill":1832,"stroke":1892,"style":1834},"M12 3l9 5-9 5-9-5z M3 13l9 5 9-5 M3 17l9 5 9-5","var(--teal-ink,#0b6157)",[69,1894,1803],{"x":1895,"y":1869,"style":1896},"474.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle",[69,1898,1899],{"x":1895,"y":1874,"style":1192},"store price first",[301,1901],{"x1":1902,"y1":1851,"x2":1903,"y2":1851,"stroke":306,"style":307},"556.0","578.0",[309,1905],{"points":1906,"fill":312},"582.0,71.2 573.0,76.2 573.0,66.2",[48,1908],{"x":1909,"y":1154,"width":1822,"height":1823,"rx":154,"fill":241,"stroke":242,"style":307},"587.0",[1825,1911,1913],{"transform":1912},"translate(651.5,37.8) scale(1.000)",[1829,1914],{"d":1915,"fill":1832,"stroke":1892,"style":1834},"M5 12.5l4.5 4.5L19 7",[69,1917,1919],{"x":1918,"y":1869,"style":1896},"663.5","Record the source",[69,1921,1922],{"x":1918,"y":1874,"style":1192},"\"store\" or \"list\"",[328,1924,1926],{"id":1925},"write-the-result-with-its-audit-columns","Write the result with its audit columns",[10,1928,1929],{},"Keep the key columns, the price used and its source in the output, so the workbook can be checked by anyone without re-running the code:",[333,1931,1933],{"className":397,"code":1932,"language":399,"meta":338,"style":338},"priced.to_excel(\"sales_priced.xlsx\", index=False,\n                columns=[\"region\", \"store\", \"product\", \"units\", \"price_used\", \"price_source\", \"value\"])\nprint(pd.read_excel(\"sales_priced.xlsx\")[\"price_source\"].value_counts().to_dict())\n",[14,1934,1935,1953,1990],{"__ignoreMap":338},[342,1936,1937,1940,1943,1945,1947,1949,1951],{"class":301,"line":344},[342,1938,1939],{"class":410},"priced.to_excel(",[342,1941,1942],{"class":351},"\"sales_priced.xlsx\"",[342,1944,458],{"class":410},[342,1946,1115],{"class":461},[342,1948,433],{"class":406},[342,1950,1120],{"class":476},[342,1952,1511],{"class":410},[342,1954,1955,1958,1960,1962,1964,1966,1968,1970,1972,1974,1976,1978,1980,1982,1984,1986,1988],{"class":301,"line":420},[342,1956,1957],{"class":461},"                columns",[342,1959,433],{"class":406},[342,1961,507],{"class":410},[342,1963,510],{"class":351},[342,1965,458],{"class":410},[342,1967,515],{"class":351},[342,1969,458],{"class":410},[342,1971,520],{"class":351},[342,1973,458],{"class":410},[342,1975,1737],{"class":351},[342,1977,458],{"class":410},[342,1979,1669],{"class":351},[342,1981,458],{"class":410},[342,1983,1693],{"class":351},[342,1985,458],{"class":410},[342,1987,1728],{"class":351},[342,1989,603],{"class":410},[342,1991,1992,1994,1997,1999,2001,2003],{"class":301,"line":427},[342,1993,1081],{"class":476},[342,1995,1996],{"class":410},"(pd.read_excel(",[342,1998,1942],{"class":351},[342,2000,598],{"class":410},[342,2002,1693],{"class":351},[342,2004,2005],{"class":410},"].value_counts().to_dict())\n",[10,2007,2008],{},"Four prices came from the store list and two from the product list — a count worth printing in the job log, because a sudden rise in fallbacks usually means the store price list was not updated this month.",[328,2010,2012],{"id":2011},"common-pitfalls","Common pitfalls",[2014,2015,2016,2024,2030,2039,2045,2051],"ul",{},[2017,2018,2019,2023],"li",{},[2020,2021,2022],"strong",{},"Mixed key types."," An integer key never equals a text key, and pandas refuses to merge them; convert both sides to the same type before merging.",[2017,2025,2026,2029],{},[2020,2027,2028],{},"Invisible whitespace and case."," Strip and case-fold text keys on both sides.",[2017,2031,2032,2035,2036,2038],{},[2020,2033,2034],{},"Duplicate right-hand keys."," Pass ",[14,2037,1301],{}," so extra rows raise instead of doubling totals.",[2017,2040,2041,2044],{},[2020,2042,2043],{},"Checking the result, not the keys."," When matches are missing, compare the key columns directly.",[2017,2046,2047,2050],{},[2020,2048,2049],{},"Fallbacks without a record."," Always store which rule supplied a looked-up value.",[2017,2052,2053,2056,2057,2060],{},[2020,2054,2055],{},"Resolving duplicates silently."," ",[14,2058,2059],{},"drop_duplicates(keep=\"last\")"," is a business rule; log how many duplicate keys were dropped so the price list gets fixed at the source.",[328,2062,2064],{"id":2063},"performance-notes","Performance notes",[10,2066,2067],{},"Multi-column merges hash the combined key and are as fast as single-column ones for typical report sizes — millions of rows in seconds. Normalising keys adds one vectorised pass per column. If the same large table is merged repeatedly, normalise it once and cache the result rather than cleaning keys on every run.",[328,2069,2071],{"id":2070},"conclusion","Conclusion",[10,2073,2074,2075,2078,2079,390,2081,2083,2084,2086,2087,2089],{},"Merge on several columns with ",[14,2076,2077],{},"on=[...]",", or ",[14,2080,389],{},[14,2082,393],{}," lists when names differ. When rows fail to match, compare each key column's values and types directly, then normalise every key identically on both sides — strip and case-fold text, convert codes to one type. Guard lookups with ",[14,2085,1301],{},", fall back to less specific keys with chained merges and ",[14,2088,1803],{},", and keep a column that records where each looked-up value came from.",[328,2091,2093],{"id":2092},"frequently-asked-questions","Frequently asked questions",[10,2095,2096,2099,2100,2103,2104,2107,2108,2110],{},[2020,2097,2098],{},"How do I merge on more than one column in pandas?","\nPass a list: pd.merge(left, right, on=",[342,2101,2102],{},"\"region\", \"store\", \"product\"","). When the column names differ between the files, use left_on=",[342,2105,2106],{},"..."," and right_on=",[342,2109,2106],{}," with the columns in matching order.",[10,2112,2113,2116],{},[2020,2114,2115],{},"Why do rows not match when the values look the same?","\nUsually because of type or formatting differences in one of the key columns: 7 as a number in one file and \"007\" as text in the other, or \"South \" with a trailing space. Normalise every key column the same way on both sides before merging.",[10,2118,2119,2122],{},[2020,2120,2121],{},"Why does the merge return more rows than the left table had?","\nThe right table has more than one row for some key combination, so each left row matches several right rows. Pass validate=\"many_to_one\" to make pandas raise instead, and remove or resolve the duplicates.",[10,2124,2125,2128],{},[2020,2126,2127],{},"How do I fall back to a less specific key?","\nMerge on the full key first, then fill the rows that did not match from a second merge on fewer columns, for example a product-level list price when no store-specific price exists.",[328,2130,2132],{"id":2131},"related","Related",[2014,2134,2135,2141,2148,2155],{},[2017,2136,2137,2138,2140],{},"Up one level: ",[23,2139,26],{"href":25}," — every kind of join.",[2017,2142,2143,2147],{},[23,2144,2146],{"href":2145},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmerge-two-excel-files-on-common-column-python\u002F","Merge Two Excel Files on a Common Column in Python"," — the single-key case.",[2017,2149,2150,2154],{},[23,2151,2153],{"href":2152},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Ffind-rows-in-one-excel-file-missing-from-another\u002F","Find Rows in One Excel File Missing from Another"," — listing the keys with no partner.",[2017,2156,2157,2161],{},[23,2158,2160],{"href":2159},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fstandardise-category-values-with-a-mapping-table\u002F","Standardise Category Values with a Mapping Table in pandas"," — normalising key values that differ in wording.",[2163,2164,2165],"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 .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":338,"searchDepth":420,"depth":420,"links":2167},[2168,2169,2170,2171,2172,2173,2174,2175,2176,2177,2178,2179],{"id":330,"depth":420,"text":331},{"id":378,"depth":420,"text":379},{"id":620,"depth":420,"text":621},{"id":855,"depth":420,"text":856},{"id":1295,"depth":420,"text":1296},{"id":1571,"depth":420,"text":1572},{"id":1925,"depth":420,"text":1926},{"id":2011,"depth":420,"text":2012},{"id":2063,"depth":420,"text":2064},{"id":2070,"depth":420,"text":2071},{"id":2092,"depth":420,"text":2093},{"id":2131,"depth":420,"text":2132},"2026-09-25","Match rows on region and product, or store and date, rather than on one ID. Merge on several columns at once, find why some rows do not match — a key typed as 7 in one file and \"007\" in another, a trailing space — guard against duplicate keys multiplying rows, and fall back to a less specific key when the exact combination is missing.","md",[2184,2186,2188,2190],{"q":2098,"a":2185},"Pass a list: pd.merge(left, right, on=[\"region\", \"store\", \"product\"]). When the column names differ between the files, use left_on=[...] and right_on=[...] with the columns in matching order.",{"q":2115,"a":2187},"Usually because of type or formatting differences in one of the key columns: 7 as a number in one file and \"007\" as text in the other, or \"South \" with a trailing space. Normalise every key column the same way on both sides before merging.",{"q":2121,"a":2189},"The right table has more than one row for some key combination, so each left row matches several right rows. Pass validate=\"many_to_one\" to make pandas raise instead, and remove or resolve the duplicates.",{"q":2127,"a":2191},"Merge on the full key first, then fill the rows that did not match from a second merge on fewer columns, for example a product-level list price when no store-specific price exists.","merge excel files on multiple key columns",null,{"breadcrumb":2195},[2196,2198,2201],{"name":2197,"item":718},"Home",{"name":2199,"item":2200},"Advanced Data Transformation and Cleaning for Excel Automation","\u002Fadvanced-data-transformation-and-cleaning\u002F",{"name":26,"item":25},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmerge-excel-data-on-multiple-key-columns",{"title":2204,"description":2205},"pandas merge on Multiple Columns (Composite Keys from Excel)","Join two Excel sheets on more than one column with pandas: on=[...] and left_on\u002Fright_on lists, diagnosing unmatched keys with indicator, dtype and whitespace mismatches, validate to stop row explosions, and fallback lookups.","merge-excel-data-on-multiple-key-columns","advanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmerge-excel-data-on-multiple-key-columns\u002Findex",[2209,2210,2211],"joins-and-lookups","pandas","data-cleaning","how-to","TNKSXdSqCECgx2x4Tw6unPamjbEdJAXcArqJ3VFf_Dg",[2215,2219],{"title":2216,"path":2217,"stem":2218,"children":-1},"Match Rows by Nearest Date with pandas merge_asof","\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmatch-rows-by-nearest-date-with-merge-asof","advanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmatch-rows-by-nearest-date-with-merge-asof\u002Findex",{"title":2220,"path":2221,"stem":2222,"children":-1},"Merge Excel Files with Different Column Names in pandas","\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmerge-excel-files-with-different-column-names","advanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmerge-excel-files-with-different-column-names\u002Findex",[2224,2226],{"path":2225,"title":2199},"\u002Fadvanced-data-transformation-and-cleaning",{"path":2227,"title":26},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes",[],[2230,2233,2236,2239,2242,2244],{"path":2231,"title":2232,"kind":2212},"\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":2234,"title":2235,"kind":2212},"\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":2237,"title":2238,"kind":2212},"\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":2240,"title":2241,"kind":2212},"\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":2243,"title":2160,"kind":2212},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fstandardise-category-values-with-a-mapping-table",{"path":2245,"title":2246,"kind":2212},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Fapproximate-match-vlookup-in-pandas","Approximate-Match VLOOKUP for Tiered Rates in pandas",1790972096351]