[{"data":1,"prerenderedAt":2660},["ShallowReactive",2],{"doc:\u002Fgetting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002Fmerge-repeated-group-labels-into-single-cells-with-openpyxl":3,"surround:\u002Fgetting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002Fmerge-repeated-group-labels-into-single-cells-with-openpyxl":2625,"ancestors:\u002Fgetting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002Fmerge-repeated-group-labels-into-single-cells-with-openpyxl":2634,"children:\u002Fgetting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002Fmerge-repeated-group-labels-into-single-cells-with-openpyxl":2639,"related:\u002Fgetting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002Fmerge-repeated-group-labels-into-single-cells-with-openpyxl":2640},{"id":4,"title":5,"body":6,"dateModified":2590,"datePublished":2590,"description":2591,"extension":2592,"faq":2593,"keyword":2602,"kind":2603,"meta":2604,"navigation":104,"path":2613,"seo":2614,"slug":2617,"stem":2618,"topics":2619,"type":2623,"__hash__":2624},"docs\u002Fgetting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002Fmerge-repeated-group-labels-into-single-cells-with-openpyxl\u002Findex.md","Merge Repeated Group Labels into Single Cells with openpyxl",{"type":7,"value":8,"toc":2576},"minimark",[9,29,34,62,72,76,343,362,366,524,531,909,913,920,1086,1089,1376,1380,1383,1665,1676,1680,1683,1778,1798,1802,1805,1988,1995,2133,2137,2144,2435,2449,2453,2502,2506,2512,2516,2522,2528,2534,2540,2544,2573],[10,11,12,13,17,18,22,23,28],"p",{},"A grouped report reads best when each group's label appears once: \"North\" spanning its four product rows, then \"South\" spanning its three. A DataFrame written with ",[14,15,16],"code",{},"to_excel"," repeats the label on every row, which is right for data and noisy for a printed report. Excel users fix this by hand with ",[19,20,21],"em",{},"Merge & Center","; a script can do the same in a few lines of openpyxl by finding each run of identical values and merging it. This page shows the run detection, nested groups, the styling that makes merged labels look deliberate, what merging does to the values underneath — and the alternative that gives the same look without the drawbacks of merged cells. It belongs to ",[24,25,27],"a",{"href":26},"\u002Fgetting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002F","Using openpyxl for Excel File Manipulation",".",[30,31,33],"h2",{"id":32},"prerequisites","Prerequisites",[35,36,41],"pre",{"className":37,"code":38,"language":39,"meta":40,"style":40},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install openpyxl pandas\n","bash","",[14,42,43],{"__ignoreMap":40},[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}," openpyxl",[44,60,61],{"class":54}," pandas\n",[10,63,64,67,68,71],{},[14,65,66],{},"sales_by_region.xlsx"," is a plain export with one row per region, category and product. Rows are already sorted by region and category, which merging depends on: a run is a block of ",[19,69,70],{},"consecutive"," equal values, so sort before you merge.",[30,73,75],{"id":74},"step-1-find-the-runs-of-equal-values","Step 1 — Find the runs of equal values",[35,77,81],{"className":78,"code":79,"language":80,"meta":40,"style":40},"language-python shiki shiki-themes github-light github-dark-high-contrast","from openpyxl import load_workbook\n\nwb = load_workbook(\"sales_by_region.xlsx\")\nws = wb[\"Sales\"]\n\ndef runs_in_column(ws, col, first_row=2, last_row=None):\n    \"\"\"Return (start_row, end_row, value) for each block of consecutive equal values.\"\"\"\n    last_row = last_row or ws.max_row\n    out, start = [], first_row\n    for r in range(first_row + 1, last_row + 2):\n        current = ws.cell(r, col).value if r \u003C= last_row else object()\n        if current != ws.cell(start, col).value:\n            out.append((start, r - 1, ws.cell(start, col).value))\n            start = r\n    return out\n\nprint(runs_in_column(ws, 1))\n","python",[14,82,83,99,106,124,141,146,176,182,199,210,244,274,289,303,314,323,328],{"__ignoreMap":40},[44,84,85,89,93,96],{"class":46,"line":47},[44,86,88],{"class":87},"s-kum","from",[44,90,92],{"class":91},"skGVy"," openpyxl ",[44,94,95],{"class":87},"import",[44,97,98],{"class":91}," load_workbook\n",[44,100,102],{"class":46,"line":101},2,[44,103,105],{"emptyLinePlaceholder":104},true,"\n",[44,107,109,112,115,118,121],{"class":46,"line":108},3,[44,110,111],{"class":91},"wb ",[44,113,114],{"class":87},"=",[44,116,117],{"class":91}," load_workbook(",[44,119,120],{"class":54},"\"sales_by_region.xlsx\"",[44,122,123],{"class":91},")\n",[44,125,127,130,132,135,138],{"class":46,"line":126},4,[44,128,129],{"class":91},"ws ",[44,131,114],{"class":87},[44,133,134],{"class":91}," wb[",[44,136,137],{"class":54},"\"Sales\"",[44,139,140],{"class":91},"]\n",[44,142,144],{"class":46,"line":143},5,[44,145,105],{"emptyLinePlaceholder":104},[44,147,149,152,156,159,161,165,168,170,173],{"class":46,"line":148},6,[44,150,151],{"class":87},"def",[44,153,155],{"class":154},"s_Opv"," runs_in_column",[44,157,158],{"class":91},"(ws, col, first_row",[44,160,114],{"class":87},[44,162,164],{"class":163},"sP0c6","2",[44,166,167],{"class":91},", last_row",[44,169,114],{"class":87},[44,171,172],{"class":163},"None",[44,174,175],{"class":91},"):\n",[44,177,179],{"class":46,"line":178},7,[44,180,181],{"class":54},"    \"\"\"Return (start_row, end_row, value) for each block of consecutive equal values.\"\"\"\n",[44,183,185,188,190,193,196],{"class":46,"line":184},8,[44,186,187],{"class":91},"    last_row ",[44,189,114],{"class":87},[44,191,192],{"class":91}," last_row ",[44,194,195],{"class":87},"or",[44,197,198],{"class":91}," ws.max_row\n",[44,200,202,205,207],{"class":46,"line":201},9,[44,203,204],{"class":91},"    out, start ",[44,206,114],{"class":87},[44,208,209],{"class":91}," [], first_row\n",[44,211,213,216,219,222,225,228,231,234,237,239,242],{"class":46,"line":212},10,[44,214,215],{"class":87},"    for",[44,217,218],{"class":91}," r ",[44,220,221],{"class":87},"in",[44,223,224],{"class":163}," range",[44,226,227],{"class":91},"(first_row ",[44,229,230],{"class":87},"+",[44,232,233],{"class":163}," 1",[44,235,236],{"class":91},", last_row ",[44,238,230],{"class":87},[44,240,241],{"class":163}," 2",[44,243,175],{"class":91},[44,245,247,250,252,255,258,260,263,265,268,271],{"class":46,"line":246},11,[44,248,249],{"class":91},"        current ",[44,251,114],{"class":87},[44,253,254],{"class":91}," ws.cell(r, col).value ",[44,256,257],{"class":87},"if",[44,259,218],{"class":91},[44,261,262],{"class":87},"\u003C=",[44,264,192],{"class":91},[44,266,267],{"class":87},"else",[44,269,270],{"class":163}," object",[44,272,273],{"class":91},"()\n",[44,275,277,280,283,286],{"class":46,"line":276},12,[44,278,279],{"class":87},"        if",[44,281,282],{"class":91}," current ",[44,284,285],{"class":87},"!=",[44,287,288],{"class":91}," ws.cell(start, col).value:\n",[44,290,292,295,298,300],{"class":46,"line":291},13,[44,293,294],{"class":91},"            out.append((start, r ",[44,296,297],{"class":87},"-",[44,299,233],{"class":163},[44,301,302],{"class":91},", ws.cell(start, col).value))\n",[44,304,306,309,311],{"class":46,"line":305},14,[44,307,308],{"class":91},"            start ",[44,310,114],{"class":87},[44,312,313],{"class":91}," r\n",[44,315,317,320],{"class":46,"line":316},15,[44,318,319],{"class":87},"    return",[44,321,322],{"class":91}," out\n",[44,324,326],{"class":46,"line":325},16,[44,327,105],{"emptyLinePlaceholder":104},[44,329,331,334,337,340],{"class":46,"line":330},17,[44,332,333],{"class":163},"print",[44,335,336],{"class":91},"(runs_in_column(ws, ",[44,338,339],{"class":163},"1",[44,341,342],{"class":91},"))\n",[10,344,345,346,349,350,353,354,357,358,361],{},"The function walks down the column and closes a run whenever the value changes; the ",[14,347,348],{},"object()"," sentinel after the last row closes the final run. For the Region column it returns ",[14,351,352],{},"(2, 5, 'North')",", ",[14,355,356],{},"(6, 8, 'South')"," and ",[14,359,360],{},"(9, 9, 'East')"," — the last one is a single row, which needs no merge.",[30,363,365],{"id":364},"step-2-merge-each-run-and-centre-the-label","Step 2 — Merge each run and centre the label",[35,367,369],{"className":78,"code":368,"language":80,"meta":40,"style":40},"from openpyxl.styles import Alignment\n\ndef merge_runs(ws, col, runs):\n    for start, end, value in runs:\n        if end > start:\n            ws.merge_cells(start_row=start, start_column=col, end_row=end, end_column=col)\n        ws.cell(start, col).alignment = Alignment(vertical=\"center\", horizontal=\"left\", indent=1)\n\nmerge_runs(ws, 1, runs_in_column(ws, 1))\nprint(ws.merged_cells.ranges)\n",[14,370,371,383,387,397,409,422,460,499,503,517],{"__ignoreMap":40},[44,372,373,375,378,380],{"class":46,"line":47},[44,374,88],{"class":87},[44,376,377],{"class":91}," openpyxl.styles ",[44,379,95],{"class":87},[44,381,382],{"class":91}," Alignment\n",[44,384,385],{"class":46,"line":101},[44,386,105],{"emptyLinePlaceholder":104},[44,388,389,391,394],{"class":46,"line":108},[44,390,151],{"class":87},[44,392,393],{"class":154}," merge_runs",[44,395,396],{"class":91},"(ws, col, runs):\n",[44,398,399,401,404,406],{"class":46,"line":126},[44,400,215],{"class":87},[44,402,403],{"class":91}," start, end, value ",[44,405,221],{"class":87},[44,407,408],{"class":91}," runs:\n",[44,410,411,413,416,419],{"class":46,"line":143},[44,412,279],{"class":87},[44,414,415],{"class":91}," end ",[44,417,418],{"class":87},">",[44,420,421],{"class":91}," start:\n",[44,423,424,427,431,433,436,439,441,444,447,449,452,455,457],{"class":46,"line":148},[44,425,426],{"class":91},"            ws.merge_cells(",[44,428,430],{"class":429},"sa561","start_row",[44,432,114],{"class":87},[44,434,435],{"class":91},"start, ",[44,437,438],{"class":429},"start_column",[44,440,114],{"class":87},[44,442,443],{"class":91},"col, ",[44,445,446],{"class":429},"end_row",[44,448,114],{"class":87},[44,450,451],{"class":91},"end, ",[44,453,454],{"class":429},"end_column",[44,456,114],{"class":87},[44,458,459],{"class":91},"col)\n",[44,461,462,465,467,470,473,475,478,480,483,485,488,490,493,495,497],{"class":46,"line":178},[44,463,464],{"class":91},"        ws.cell(start, col).alignment ",[44,466,114],{"class":87},[44,468,469],{"class":91}," Alignment(",[44,471,472],{"class":429},"vertical",[44,474,114],{"class":87},[44,476,477],{"class":54},"\"center\"",[44,479,353],{"class":91},[44,481,482],{"class":429},"horizontal",[44,484,114],{"class":87},[44,486,487],{"class":54},"\"left\"",[44,489,353],{"class":91},[44,491,492],{"class":429},"indent",[44,494,114],{"class":87},[44,496,339],{"class":163},[44,498,123],{"class":91},[44,500,501],{"class":46,"line":184},[44,502,105],{"emptyLinePlaceholder":104},[44,504,505,508,510,513,515],{"class":46,"line":201},[44,506,507],{"class":91},"merge_runs(ws, ",[44,509,339],{"class":163},[44,511,512],{"class":91},", runs_in_column(ws, ",[44,514,339],{"class":163},[44,516,342],{"class":91},[44,518,519,521],{"class":46,"line":212},[44,520,333],{"class":163},[44,522,523],{"class":91},"(ws.merged_cells.ranges)\n",[10,525,526,527,530],{},"The merged range takes its value and style from its top-left cell, so the alignment is set there. ",[14,528,529],{},"vertical=\"center\""," places the label in the middle of the group, which is what makes a merged label look intentional; without it, Excel draws the text at the bottom of the block. The single-row East group gets the same alignment without a merge, so every label lines up.",[532,533,541,542,541,546,541,550,541,558,541,567,541,571,541,578,541,582,541,586,541,590,541,594,541,598,541,602,541,607,541,611,541,613,541,616,541,618,541,621,541,624,541,627,541,631,541,635,541,637,541,641,541,643,541,646,541,649,541,653,541,655,541,657,541,659,541,662,541,664,541,667,541,669,541,673,541,675,541,677,541,679,541,682,541,684,541,687,541,690,541,694,541,696,541,698,541,700,541,703,541,705,541,708,541,711,541,715,541,717,541,720,541,722,541,724,541,726,541,729,541,732,541,736,541,738,541,740,541,742,541,744,541,746,541,749,541,752,541,755,541,758,541,761,541,764,541,767,541,770,541,772,541,775,541,777,541,779,541,781,541,783,541,785,541,787,541,789,541,791,541,796,541,800,541,802,541,804,541,806,541,808,541,810,541,812,541,814,541,816,541,818,541,820,541,822,541,824,541,826,541,828,541,830,541,832,541,834,541,836,541,838,541,840,541,842,541,844,541,846,541,848,541,851,541,854,541,856,541,858,541,860,541,862,541,864,541,866,541,868,541,870,541,872,541,874,541,880,541,884,541,890,541,895,541,903],"svg",{"viewBox":534,"role":535,"ariaLabelledBy":536,"xmlns":539,"style":540},"69 0 622 276","img",[537,538],"mg-region-t","mg-region-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:622px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[543,544,545],"title",{"id":537},"The region repeated on every row becomes one label per group",[547,548,549],"desc",{"id":538},"On the left the Region column repeats North four times and South three times. On the right North is one merged cell spanning four rows and South one merged cell spanning three rows, each label vertically centred.",[551,552],"rect",{"x":553,"y":554,"width":555,"height":556,"fill":557},"69","0","622","276","#ffffff",[551,559],{"x":560,"y":561,"width":562,"height":563,"rx":554,"fill":564,"stroke":565,"style":566},"85.3","40.0","32.0","24.0","#e7ebef","var(--line,#cdd5e6)","stroke-width:1px",[551,568],{"x":569,"y":561,"width":570,"height":563,"rx":554,"fill":564,"stroke":565,"style":566},"117.3","61.4",[572,573,577],"text",{"x":574,"y":575,"style":576},"148.0","57.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[551,579],{"x":580,"y":561,"width":581,"height":563,"rx":554,"fill":564,"stroke":565,"style":566},"178.7","66.7",[572,583,585],{"x":584,"y":575,"style":576},"212.0","B",[551,587],{"x":588,"y":561,"width":589,"height":563,"rx":554,"fill":564,"stroke":565,"style":566},"245.4","52.1",[572,591,593],{"x":592,"y":575,"style":576},"271.5","C",[551,595],{"x":560,"y":596,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},"64.0","28.0",[572,599,339],{"x":600,"y":601,"style":576},"101.3","83.0",[551,603],{"x":569,"y":596,"width":570,"height":597,"rx":554,"fill":604,"stroke":605,"style":606},"#f0f4ff","var(--brand,#5b5cf0)","stroke-width:1.5px",[572,608,610],{"x":574,"y":601,"style":609},"font-size:10.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Region",[551,612],{"x":580,"y":596,"width":581,"height":597,"rx":554,"fill":604,"stroke":605,"style":606},[572,614,615],{"x":584,"y":601,"style":609},"Product",[551,617],{"x":588,"y":596,"width":589,"height":597,"rx":554,"fill":604,"stroke":605,"style":606},[572,619,620],{"x":592,"y":601,"style":609},"Units",[551,622],{"x":560,"y":623,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},"92.0",[572,625,164],{"x":600,"y":626,"style":576},"111.0",[551,628],{"x":569,"y":623,"width":570,"height":597,"rx":554,"fill":629,"stroke":630,"style":606},"#fdefd8","var(--gold,#b4740a)",[572,632,634],{"x":574,"y":626,"style":633},"font-size:10.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","North",[551,636],{"x":580,"y":623,"width":581,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,638,640],{"x":584,"y":626,"style":639},"font-size:10.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","Drills",[551,642],{"x":588,"y":623,"width":589,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,644,645],{"x":592,"y":626,"style":639},"120",[551,647],{"x":560,"y":648,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},"120.0",[572,650,652],{"x":600,"y":651,"style":576},"139.0","3",[551,654],{"x":569,"y":648,"width":570,"height":597,"rx":554,"fill":629,"stroke":630,"style":606},[572,656,634],{"x":574,"y":651,"style":633},[551,658],{"x":580,"y":648,"width":581,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,660,661],{"x":584,"y":651,"style":639},"Saws",[551,663],{"x":588,"y":648,"width":589,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,665,666],{"x":592,"y":651,"style":639},"80",[551,668],{"x":560,"y":574,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},[572,670,672],{"x":600,"y":671,"style":576},"167.0","4",[551,674],{"x":569,"y":574,"width":570,"height":597,"rx":554,"fill":629,"stroke":630,"style":606},[572,676,634],{"x":574,"y":671,"style":633},[551,678],{"x":580,"y":574,"width":581,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,680,681],{"x":584,"y":671,"style":639},"Hoses",[551,683],{"x":588,"y":574,"width":589,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,685,686],{"x":592,"y":671,"style":639},"210",[551,688],{"x":560,"y":689,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},"176.0",[572,691,693],{"x":600,"y":692,"style":576},"195.0","5",[551,695],{"x":569,"y":689,"width":570,"height":597,"rx":554,"fill":629,"stroke":630,"style":606},[572,697,634],{"x":574,"y":692,"style":633},[551,699],{"x":580,"y":689,"width":581,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,701,702],{"x":584,"y":692,"style":639},"Rakes",[551,704],{"x":588,"y":689,"width":589,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,706,707],{"x":592,"y":692,"style":639},"95",[551,709],{"x":560,"y":710,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},"204.0",[572,712,714],{"x":600,"y":713,"style":576},"223.0","6",[551,716],{"x":569,"y":710,"width":570,"height":597,"rx":554,"fill":629,"stroke":630,"style":606},[572,718,719],{"x":574,"y":713,"style":633},"South",[551,721],{"x":580,"y":710,"width":581,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,723,640],{"x":584,"y":713,"style":639},[551,725],{"x":588,"y":710,"width":589,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,727,728],{"x":592,"y":713,"style":639},"90",[551,730],{"x":560,"y":731,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},"232.0",[572,733,735],{"x":600,"y":734,"style":576},"251.0","7",[551,737],{"x":569,"y":731,"width":570,"height":597,"rx":554,"fill":629,"stroke":630,"style":606},[572,739,719],{"x":574,"y":734,"style":633},[551,741],{"x":580,"y":731,"width":581,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,743,681],{"x":584,"y":734,"style":639},[551,745],{"x":588,"y":731,"width":589,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,747,748],{"x":592,"y":734,"style":639},"160",[551,750],{"x":751,"y":561,"width":562,"height":563,"rx":554,"fill":564,"stroke":565,"style":566},"462.5",[551,753],{"x":754,"y":561,"width":570,"height":563,"rx":554,"fill":564,"stroke":565,"style":566},"494.5",[572,756,577],{"x":757,"y":575,"style":576},"525.2",[551,759],{"x":760,"y":561,"width":581,"height":563,"rx":554,"fill":564,"stroke":565,"style":566},"555.9",[572,762,585],{"x":763,"y":575,"style":576},"589.2",[551,765],{"x":766,"y":561,"width":589,"height":563,"rx":554,"fill":564,"stroke":565,"style":566},"622.6",[572,768,593],{"x":769,"y":575,"style":576},"648.7",[551,771],{"x":751,"y":596,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},[572,773,339],{"x":774,"y":601,"style":576},"478.5",[551,776],{"x":754,"y":596,"width":570,"height":597,"rx":554,"fill":604,"stroke":605,"style":606},[572,778,610],{"x":757,"y":601,"style":609},[551,780],{"x":760,"y":596,"width":581,"height":597,"rx":554,"fill":604,"stroke":605,"style":606},[572,782,615],{"x":763,"y":601,"style":609},[551,784],{"x":766,"y":596,"width":589,"height":597,"rx":554,"fill":604,"stroke":605,"style":606},[572,786,620],{"x":769,"y":601,"style":609},[551,788],{"x":751,"y":623,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},[572,790,164],{"x":774,"y":626,"style":576},[551,792],{"x":754,"y":623,"width":570,"height":793,"rx":554,"fill":794,"stroke":795,"style":606},"112.0","#d9f4f1","var(--teal,#0f9488)",[572,797,634],{"x":757,"y":798,"style":799},"153.0","font-size:10.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle",[551,801],{"x":760,"y":623,"width":581,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,803,640],{"x":763,"y":626,"style":639},[551,805],{"x":766,"y":623,"width":589,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,807,645],{"x":769,"y":626,"style":639},[551,809],{"x":751,"y":648,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},[572,811,652],{"x":774,"y":651,"style":576},[551,813],{"x":760,"y":648,"width":581,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,815,661],{"x":763,"y":651,"style":639},[551,817],{"x":766,"y":648,"width":589,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,819,666],{"x":769,"y":651,"style":639},[551,821],{"x":751,"y":574,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},[572,823,672],{"x":774,"y":671,"style":576},[551,825],{"x":760,"y":574,"width":581,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,827,681],{"x":763,"y":671,"style":639},[551,829],{"x":766,"y":574,"width":589,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,831,686],{"x":769,"y":671,"style":639},[551,833],{"x":751,"y":689,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},[572,835,693],{"x":774,"y":692,"style":576},[551,837],{"x":760,"y":689,"width":581,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,839,702],{"x":763,"y":692,"style":639},[551,841],{"x":766,"y":689,"width":589,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,843,707],{"x":769,"y":692,"style":639},[551,845],{"x":751,"y":710,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},[572,847,714],{"x":774,"y":713,"style":576},[551,849],{"x":754,"y":710,"width":570,"height":850,"rx":554,"fill":794,"stroke":795,"style":606},"56.0",[572,852,719],{"x":757,"y":853,"style":799},"237.0",[551,855],{"x":760,"y":710,"width":581,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,857,640],{"x":763,"y":713,"style":639},[551,859],{"x":766,"y":710,"width":589,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,861,728],{"x":769,"y":713,"style":639},[551,863],{"x":751,"y":731,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},[572,865,735],{"x":774,"y":734,"style":576},[551,867],{"x":760,"y":731,"width":581,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,869,681],{"x":763,"y":734,"style":639},[551,871],{"x":766,"y":731,"width":589,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,873,748],{"x":769,"y":734,"style":639},[572,875,879],{"x":876,"y":877,"style":878},"191.4","29.0","font-size:12.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","before",[572,881,883],{"x":882,"y":877,"style":878},"568.6","after",[46,885],{"x1":886,"y1":887,"x2":888,"y2":887,"stroke":605,"style":889},"307.5","150.0","448.5","stroke-width:2px",[891,892],"polygon",{"points":893,"fill":894},"452.5,150.0 443.5,155.0 443.5,145.0","#5b5cf0",[551,896],{"x":897,"y":898,"width":899,"height":900,"rx":901,"fill":557,"stroke":565,"style":902},"330.0","117.0","100.0","22.0","11","stroke-width:1.2px",[572,904,908],{"x":905,"y":906,"style":907},"380.0","132.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_cells",[30,910,912],{"id":911},"step-3-merge-nested-groups-without-crossing-the-outer-ones","Step 3 — Merge nested groups without crossing the outer ones",[10,914,915,916,919],{},"Category sits inside Region. Running the same function on column B would merge \"Hardware\" across the end of North and the start of South if both happened to be Hardware — here row 5 is North · Garden and row 6 is South · Hardware, but in other data the values could line up. Detect inner runs ",[19,917,918],{},"within"," each outer run instead:",[35,921,923],{"className":78,"code":922,"language":80,"meta":40,"style":40},"wb = load_workbook(\"sales_by_region.xlsx\")\nws = wb[\"Sales\"]\nregion_runs = runs_in_column(ws, 1)\ncategory_runs = []\nfor start, end, _ in region_runs:\n    category_runs += runs_in_column(ws, 2, first_row=start, last_row=end)\nmerge_runs(ws, 1, region_runs)\nmerge_runs(ws, 2, category_runs)\nprint([f\"{s}-{e} {v}\" for s, e, v in category_runs])\n",[14,924,925,937,949,963,973,986,1015,1024,1033],{"__ignoreMap":40},[44,926,927,929,931,933,935],{"class":46,"line":47},[44,928,111],{"class":91},[44,930,114],{"class":87},[44,932,117],{"class":91},[44,934,120],{"class":54},[44,936,123],{"class":91},[44,938,939,941,943,945,947],{"class":46,"line":101},[44,940,129],{"class":91},[44,942,114],{"class":87},[44,944,134],{"class":91},[44,946,137],{"class":54},[44,948,140],{"class":91},[44,950,951,954,956,959,961],{"class":46,"line":108},[44,952,953],{"class":91},"region_runs ",[44,955,114],{"class":87},[44,957,958],{"class":91}," runs_in_column(ws, ",[44,960,339],{"class":163},[44,962,123],{"class":91},[44,964,965,968,970],{"class":46,"line":126},[44,966,967],{"class":91},"category_runs ",[44,969,114],{"class":87},[44,971,972],{"class":91}," []\n",[44,974,975,978,981,983],{"class":46,"line":143},[44,976,977],{"class":87},"for",[44,979,980],{"class":91}," start, end, _ ",[44,982,221],{"class":87},[44,984,985],{"class":91}," region_runs:\n",[44,987,988,991,994,996,998,1000,1003,1005,1007,1010,1012],{"class":46,"line":148},[44,989,990],{"class":91},"    category_runs ",[44,992,993],{"class":87},"+=",[44,995,958],{"class":91},[44,997,164],{"class":163},[44,999,353],{"class":91},[44,1001,1002],{"class":429},"first_row",[44,1004,114],{"class":87},[44,1006,435],{"class":91},[44,1008,1009],{"class":429},"last_row",[44,1011,114],{"class":87},[44,1013,1014],{"class":91},"end)\n",[44,1016,1017,1019,1021],{"class":46,"line":178},[44,1018,507],{"class":91},[44,1020,339],{"class":163},[44,1022,1023],{"class":91},", region_runs)\n",[44,1025,1026,1028,1030],{"class":46,"line":184},[44,1027,507],{"class":91},[44,1029,164],{"class":163},[44,1031,1032],{"class":91},", category_runs)\n",[44,1034,1035,1037,1040,1043,1046,1050,1053,1056,1058,1060,1063,1065,1068,1071,1073,1075,1078,1081,1083],{"class":46,"line":201},[44,1036,333],{"class":163},[44,1038,1039],{"class":91},"([",[44,1041,1042],{"class":87},"f",[44,1044,1045],{"class":54},"\"",[44,1047,1049],{"class":1048},"sSjpA","{",[44,1051,1052],{"class":91},"s",[44,1054,1055],{"class":1048},"}",[44,1057,297],{"class":54},[44,1059,1049],{"class":1048},[44,1061,1062],{"class":91},"e",[44,1064,1055],{"class":1048},[44,1066,1067],{"class":1048}," {",[44,1069,1070],{"class":91},"v",[44,1072,1055],{"class":1048},[44,1074,1045],{"class":54},[44,1076,1077],{"class":87}," for",[44,1079,1080],{"class":91}," s, e, v ",[44,1082,221],{"class":87},[44,1084,1085],{"class":91}," category_runs])\n",[10,1087,1088],{},"Each Region block is scanned separately for Category runs, so an inner label can never span two regions — the same rule a pivot table follows. The approach extends to any depth: scan level three inside each level-two run, and so on.",[532,1090,541,1096,541,1099,541,1102,541,1107,541,1111,541,1115,541,1119,541,1123,541,1126,541,1130,541,1133,541,1137,541,1141,541,1145,541,1149,541,1151,541,1155,541,1157,541,1160,541,1162,541,1165,541,1167,541,1169,541,1171,541,1173,541,1175,541,1178,541,1181,541,1184,541,1186,541,1190,541,1192,541,1197,541,1199,541,1201,541,1203,541,1205,541,1207,541,1210,541,1213,541,1216,541,1218,541,1220,541,1222,541,1224,541,1226,541,1229,541,1232,541,1235,541,1237,541,1241,541,1243,541,1245,541,1247,541,1249,541,1251,541,1254,541,1257,541,1260,541,1262,541,1264,541,1266,541,1268,541,1270,541,1273,541,1276,541,1279,541,1282,541,1285,541,1287,541,1289,541,1291,541,1293,541,1295,541,1297,541,1299,541,1302,541,1305,541,1307,541,1309,541,1312,541,1314,541,1316,541,1318,541,1320,541,1322,541,1325,541,1328,541,1332,541,1334,541,1336,541,1338,541,1341,541,1343,541,1346,541,1349,541,1353,541,1355,541,1358,541,1360,541,1362,541,1364,541,1366,541,1368,541,1371,541,1373],{"viewBox":1091,"role":535,"ariaLabelledBy":1092,"xmlns":539,"style":1095},"178 0 403 308",[1093,1094],"mg-nested-t","mg-nested-d","width:100%;max-width:403px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[543,1097,1098],{"id":1093},"Region and category merged as two nested levels",[547,1100,1101],{"id":1094},"The finished sheet. North spans rows 2 to 5 with Hardware merged over rows 2 and 3 and Garden over rows 4 and 5. South spans rows 6 to 8 with Hardware on row 6 and Garden merged over rows 7 and 8. East is a single row.",[551,1103],{"x":1104,"y":554,"width":1105,"height":1106,"fill":557},"178","403","308",[551,1108],{"x":1109,"y":1110,"width":562,"height":563,"rx":554,"fill":564,"stroke":565,"style":566},"194.8","16.0",[551,1112],{"x":1113,"y":1110,"width":1114,"height":563,"rx":554,"fill":564,"stroke":565,"style":566},"226.8","63.2",[572,1116,577],{"x":1117,"y":1118,"style":576},"258.4","33.0",[551,1120],{"x":1121,"y":1110,"width":1122,"height":563,"rx":554,"fill":564,"stroke":565,"style":566},"290.0","79.4",[572,1124,585],{"x":1125,"y":1118,"style":576},"329.7",[551,1127],{"x":1128,"y":1110,"width":1129,"height":563,"rx":554,"fill":564,"stroke":565,"style":566},"369.4","68.8",[572,1131,593],{"x":1132,"y":1118,"style":576},"403.8",[551,1134],{"x":1135,"y":1110,"width":1136,"height":563,"rx":554,"fill":564,"stroke":565,"style":566},"438.2","53.5",[572,1138,1140],{"x":1139,"y":1118,"style":576},"464.9","D",[551,1142],{"x":1143,"y":1110,"width":1144,"height":563,"rx":554,"fill":564,"stroke":565,"style":566},"491.6","73.6",[572,1146,1148],{"x":1147,"y":1118,"style":576},"528.4","E",[551,1150],{"x":1109,"y":561,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},[572,1152,339],{"x":1153,"y":1154,"style":576},"210.8","59.0",[551,1156],{"x":1113,"y":561,"width":1114,"height":597,"rx":554,"fill":604,"stroke":605,"style":606},[572,1158,610],{"x":1117,"y":1154,"style":1159},"font-size:11px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",[551,1161],{"x":1121,"y":561,"width":1122,"height":597,"rx":554,"fill":604,"stroke":605,"style":606},[572,1163,1164],{"x":1125,"y":1154,"style":1159},"Category",[551,1166],{"x":1128,"y":561,"width":1129,"height":597,"rx":554,"fill":604,"stroke":605,"style":606},[572,1168,615],{"x":1132,"y":1154,"style":1159},[551,1170],{"x":1135,"y":561,"width":1136,"height":597,"rx":554,"fill":604,"stroke":605,"style":606},[572,1172,620],{"x":1139,"y":1154,"style":1159},[551,1174],{"x":1143,"y":561,"width":1144,"height":597,"rx":554,"fill":604,"stroke":605,"style":606},[572,1176,1177],{"x":1147,"y":1154,"style":1159},"Revenue",[551,1179],{"x":1109,"y":1180,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},"68.0",[572,1182,164],{"x":1153,"y":1183,"style":576},"87.0",[551,1185],{"x":1113,"y":1180,"width":1114,"height":793,"rx":554,"fill":794,"stroke":795,"style":606},[572,1187,634],{"x":1117,"y":1188,"style":1189},"129.0","font-size:11px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle",[551,1191],{"x":1121,"y":1180,"width":1122,"height":850,"rx":554,"fill":557,"stroke":565,"style":566},[572,1193,1196],{"x":1125,"y":1194,"style":1195},"101.0","font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","Hardware",[551,1198],{"x":1128,"y":1180,"width":1129,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1200,640],{"x":1132,"y":1183,"style":1195},[551,1202],{"x":1135,"y":1180,"width":1136,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1204,645],{"x":1139,"y":1183,"style":1195},[551,1206],{"x":1143,"y":1180,"width":1144,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1208,1209],{"x":1147,"y":1183,"style":1195},"9,600",[551,1211],{"x":1109,"y":1212,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},"96.0",[572,1214,652],{"x":1153,"y":1215,"style":576},"115.0",[551,1217],{"x":1128,"y":1212,"width":1129,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1219,661],{"x":1132,"y":1215,"style":1195},[551,1221],{"x":1135,"y":1212,"width":1136,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1223,666],{"x":1139,"y":1215,"style":1195},[551,1225],{"x":1143,"y":1212,"width":1144,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1227,1228],{"x":1147,"y":1215,"style":1195},"4,000",[551,1230],{"x":1109,"y":1231,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},"124.0",[572,1233,672],{"x":1153,"y":1234,"style":576},"143.0",[551,1236],{"x":1121,"y":1231,"width":1122,"height":850,"rx":554,"fill":557,"stroke":565,"style":566},[572,1238,1240],{"x":1125,"y":1239,"style":1195},"157.0","Garden",[551,1242],{"x":1128,"y":1231,"width":1129,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1244,681],{"x":1132,"y":1234,"style":1195},[551,1246],{"x":1135,"y":1231,"width":1136,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1248,686],{"x":1139,"y":1234,"style":1195},[551,1250],{"x":1143,"y":1231,"width":1144,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1252,1253],{"x":1147,"y":1234,"style":1195},"3,150",[551,1255],{"x":1109,"y":1256,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},"152.0",[572,1258,693],{"x":1153,"y":1259,"style":576},"171.0",[551,1261],{"x":1128,"y":1256,"width":1129,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1263,702],{"x":1132,"y":1259,"style":1195},[551,1265],{"x":1135,"y":1256,"width":1136,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1267,707],{"x":1139,"y":1259,"style":1195},[551,1269],{"x":1143,"y":1256,"width":1144,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1271,1272],{"x":1147,"y":1259,"style":1195},"1,425",[551,1274],{"x":1109,"y":1275,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},"180.0",[572,1277,714],{"x":1153,"y":1278,"style":576},"199.0",[551,1280],{"x":1113,"y":1275,"width":1114,"height":1281,"rx":554,"fill":794,"stroke":795,"style":606},"84.0",[572,1283,719],{"x":1117,"y":1284,"style":1189},"227.0",[551,1286],{"x":1121,"y":1275,"width":1122,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1288,1196],{"x":1125,"y":1278,"style":1195},[551,1290],{"x":1128,"y":1275,"width":1129,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1292,640],{"x":1132,"y":1278,"style":1195},[551,1294],{"x":1135,"y":1275,"width":1136,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1296,728],{"x":1139,"y":1278,"style":1195},[551,1298],{"x":1143,"y":1275,"width":1144,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1300,1301],{"x":1147,"y":1278,"style":1195},"7,200",[551,1303],{"x":1109,"y":1304,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},"208.0",[572,1306,735],{"x":1153,"y":1284,"style":576},[551,1308],{"x":1121,"y":1304,"width":1122,"height":850,"rx":554,"fill":557,"stroke":565,"style":566},[572,1310,1240],{"x":1125,"y":1311,"style":1195},"241.0",[551,1313],{"x":1128,"y":1304,"width":1129,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1315,681],{"x":1132,"y":1284,"style":1195},[551,1317],{"x":1135,"y":1304,"width":1136,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1319,748],{"x":1139,"y":1284,"style":1195},[551,1321],{"x":1143,"y":1304,"width":1144,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1323,1324],{"x":1147,"y":1284,"style":1195},"2,400",[551,1326],{"x":1109,"y":1327,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},"236.0",[572,1329,1331],{"x":1153,"y":1330,"style":576},"255.0","8",[551,1333],{"x":1128,"y":1327,"width":1129,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1335,702],{"x":1132,"y":1330,"style":1195},[551,1337],{"x":1135,"y":1327,"width":1136,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1339,1340],{"x":1139,"y":1330,"style":1195},"70",[551,1342],{"x":1143,"y":1327,"width":1144,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1344,1345],{"x":1147,"y":1330,"style":1195},"1,050",[551,1347],{"x":1109,"y":1348,"width":562,"height":597,"rx":554,"fill":564,"stroke":565,"style":566},"264.0",[572,1350,1352],{"x":1153,"y":1351,"style":576},"283.0","9",[551,1354],{"x":1113,"y":1348,"width":1114,"height":597,"rx":554,"fill":794,"stroke":795,"style":606},[572,1356,1357],{"x":1117,"y":1351,"style":1189},"East",[551,1359],{"x":1121,"y":1348,"width":1122,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1361,1240],{"x":1125,"y":1351,"style":1195},[551,1363],{"x":1128,"y":1348,"width":1129,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1365,681],{"x":1132,"y":1351,"style":1195},[551,1367],{"x":1135,"y":1348,"width":1136,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1369,1370],{"x":1139,"y":1351,"style":1195},"130",[551,1372],{"x":1143,"y":1348,"width":1144,"height":597,"rx":554,"fill":557,"stroke":565,"style":566},[572,1374,1375],{"x":1147,"y":1351,"style":1195},"1,950",[30,1377,1379],{"id":1378},"step-4-draw-borders-that-follow-the-groups","Step 4 — Draw borders that follow the groups",[10,1381,1382],{},"Gridlines inside a merged block disappear, but borders on the neighbouring columns still cut across each group row by row. A heavier line under the last row of every outer group makes the structure obvious:",[35,1384,1386],{"className":78,"code":1385,"language":80,"meta":40,"style":40},"from openpyxl.styles import Border, Side, Font\n\nthin, heavy = Side(style=\"thin\", color=\"CDD5E6\"), Side(style=\"medium\", color=\"5B5CF0\")\nfor start, end, _ in region_runs:\n    for r in range(start, end + 1):\n        for c in range(1, ws.max_column + 1):\n            cell = ws.cell(r, c)\n            if type(cell).__name__ == \"MergedCell\":\n                continue\n            cell.border = Border(bottom=heavy if r == end else thin)\nfor c in ws[1]:\n    c.font = Font(bold=True)\nws.column_dimensions[\"A\"].width = 12\nws.column_dimensions[\"B\"].width = 13\nwb.save(\"sales_merged.xlsx\")\nprint(\"saved\", ws.merged_cells.ranges)\n",[14,1387,1388,1399,1403,1452,1462,1481,1507,1517,1540,1545,1577,1593,1613,1629,1643,1653],{"__ignoreMap":40},[44,1389,1390,1392,1394,1396],{"class":46,"line":47},[44,1391,88],{"class":87},[44,1393,377],{"class":91},[44,1395,95],{"class":87},[44,1397,1398],{"class":91}," Border, Side, Font\n",[44,1400,1401],{"class":46,"line":101},[44,1402,105],{"emptyLinePlaceholder":104},[44,1404,1405,1408,1410,1413,1416,1418,1421,1423,1426,1428,1431,1434,1436,1438,1441,1443,1445,1447,1450],{"class":46,"line":108},[44,1406,1407],{"class":91},"thin, heavy ",[44,1409,114],{"class":87},[44,1411,1412],{"class":91}," Side(",[44,1414,1415],{"class":429},"style",[44,1417,114],{"class":87},[44,1419,1420],{"class":54},"\"thin\"",[44,1422,353],{"class":91},[44,1424,1425],{"class":429},"color",[44,1427,114],{"class":87},[44,1429,1430],{"class":54},"\"CDD5E6\"",[44,1432,1433],{"class":91},"), Side(",[44,1435,1415],{"class":429},[44,1437,114],{"class":87},[44,1439,1440],{"class":54},"\"medium\"",[44,1442,353],{"class":91},[44,1444,1425],{"class":429},[44,1446,114],{"class":87},[44,1448,1449],{"class":54},"\"5B5CF0\"",[44,1451,123],{"class":91},[44,1453,1454,1456,1458,1460],{"class":46,"line":126},[44,1455,977],{"class":87},[44,1457,980],{"class":91},[44,1459,221],{"class":87},[44,1461,985],{"class":91},[44,1463,1464,1466,1468,1470,1472,1475,1477,1479],{"class":46,"line":143},[44,1465,215],{"class":87},[44,1467,218],{"class":91},[44,1469,221],{"class":87},[44,1471,224],{"class":163},[44,1473,1474],{"class":91},"(start, end ",[44,1476,230],{"class":87},[44,1478,233],{"class":163},[44,1480,175],{"class":91},[44,1482,1483,1486,1489,1491,1493,1496,1498,1501,1503,1505],{"class":46,"line":148},[44,1484,1485],{"class":87},"        for",[44,1487,1488],{"class":91}," c ",[44,1490,221],{"class":87},[44,1492,224],{"class":163},[44,1494,1495],{"class":91},"(",[44,1497,339],{"class":163},[44,1499,1500],{"class":91},", ws.max_column ",[44,1502,230],{"class":87},[44,1504,233],{"class":163},[44,1506,175],{"class":91},[44,1508,1509,1512,1514],{"class":46,"line":178},[44,1510,1511],{"class":91},"            cell ",[44,1513,114],{"class":87},[44,1515,1516],{"class":91}," ws.cell(r, c)\n",[44,1518,1519,1522,1525,1528,1531,1534,1537],{"class":46,"line":184},[44,1520,1521],{"class":87},"            if",[44,1523,1524],{"class":163}," type",[44,1526,1527],{"class":91},"(cell).",[44,1529,1530],{"class":163},"__name__",[44,1532,1533],{"class":87}," ==",[44,1535,1536],{"class":54}," \"MergedCell\"",[44,1538,1539],{"class":91},":\n",[44,1541,1542],{"class":46,"line":201},[44,1543,1544],{"class":87},"                continue\n",[44,1546,1547,1550,1552,1555,1558,1560,1563,1565,1567,1570,1572,1574],{"class":46,"line":212},[44,1548,1549],{"class":91},"            cell.border ",[44,1551,114],{"class":87},[44,1553,1554],{"class":91}," Border(",[44,1556,1557],{"class":429},"bottom",[44,1559,114],{"class":87},[44,1561,1562],{"class":91},"heavy ",[44,1564,257],{"class":87},[44,1566,218],{"class":91},[44,1568,1569],{"class":87},"==",[44,1571,415],{"class":91},[44,1573,267],{"class":87},[44,1575,1576],{"class":91}," thin)\n",[44,1578,1579,1581,1583,1585,1588,1590],{"class":46,"line":246},[44,1580,977],{"class":87},[44,1582,1488],{"class":91},[44,1584,221],{"class":87},[44,1586,1587],{"class":91}," ws[",[44,1589,339],{"class":163},[44,1591,1592],{"class":91},"]:\n",[44,1594,1595,1598,1600,1603,1606,1608,1611],{"class":46,"line":276},[44,1596,1597],{"class":91},"    c.font ",[44,1599,114],{"class":87},[44,1601,1602],{"class":91}," Font(",[44,1604,1605],{"class":429},"bold",[44,1607,114],{"class":87},[44,1609,1610],{"class":163},"True",[44,1612,123],{"class":91},[44,1614,1615,1618,1621,1624,1626],{"class":46,"line":291},[44,1616,1617],{"class":91},"ws.column_dimensions[",[44,1619,1620],{"class":54},"\"A\"",[44,1622,1623],{"class":91},"].width ",[44,1625,114],{"class":87},[44,1627,1628],{"class":163}," 12\n",[44,1630,1631,1633,1636,1638,1640],{"class":46,"line":305},[44,1632,1617],{"class":91},[44,1634,1635],{"class":54},"\"B\"",[44,1637,1623],{"class":91},[44,1639,114],{"class":87},[44,1641,1642],{"class":163}," 13\n",[44,1644,1645,1648,1651],{"class":46,"line":316},[44,1646,1647],{"class":91},"wb.save(",[44,1649,1650],{"class":54},"\"sales_merged.xlsx\"",[44,1652,123],{"class":91},[44,1654,1655,1657,1659,1662],{"class":46,"line":325},[44,1656,333],{"class":163},[44,1658,1495],{"class":91},[44,1660,1661],{"class":54},"\"saved\"",[44,1663,1664],{"class":91},", ws.merged_cells.ranges)\n",[10,1666,1667,1668,1671,1672,28],{},"The loop skips ",[14,1669,1670],{},"MergedCell"," objects, the read-only placeholders that fill the covered positions of a merge; their appearance comes from the top-left cell, and assigning to them raises an error. To give a merged block an outline of its own, style its top-left cell's border and the cells around it, or see ",[24,1673,1675],{"href":1674},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Fadd-borders-to-a-range-with-openpyxl\u002F","Add Borders to a Range with openpyxl",[30,1677,1679],{"id":1678},"step-5-what-merging-does-to-the-values-underneath","Step 5 — What merging does to the values underneath",[10,1681,1682],{},"Merging is not cosmetic. openpyxl clears every covered cell when it creates the merge, exactly as Excel does after its \"only the upper-left value is kept\" warning:",[35,1684,1686],{"className":78,"code":1685,"language":80,"meta":40,"style":40},"check = load_workbook(\"sales_merged.xlsx\")[\"Sales\"]\nprint(repr(check[\"A3\"].value), type(check[\"A3\"]).__name__)\nimport pandas as pd\nprint(pd.read_excel(\"sales_merged.xlsx\").head(4).to_string(index=False))\n",[14,1687,1688,1706,1738,1751],{"__ignoreMap":40},[44,1689,1690,1693,1695,1697,1699,1702,1704],{"class":46,"line":47},[44,1691,1692],{"class":91},"check ",[44,1694,114],{"class":87},[44,1696,117],{"class":91},[44,1698,1650],{"class":54},[44,1700,1701],{"class":91},")[",[44,1703,137],{"class":54},[44,1705,140],{"class":91},[44,1707,1708,1710,1712,1715,1718,1721,1724,1727,1729,1731,1734,1736],{"class":46,"line":101},[44,1709,333],{"class":163},[44,1711,1495],{"class":91},[44,1713,1714],{"class":163},"repr",[44,1716,1717],{"class":91},"(check[",[44,1719,1720],{"class":54},"\"A3\"",[44,1722,1723],{"class":91},"].value), ",[44,1725,1726],{"class":163},"type",[44,1728,1717],{"class":91},[44,1730,1720],{"class":54},[44,1732,1733],{"class":91},"]).",[44,1735,1530],{"class":163},[44,1737,123],{"class":91},[44,1739,1740,1742,1745,1748],{"class":46,"line":108},[44,1741,95],{"class":87},[44,1743,1744],{"class":91}," pandas ",[44,1746,1747],{"class":87},"as",[44,1749,1750],{"class":91}," pd\n",[44,1752,1753,1755,1758,1760,1763,1765,1768,1771,1773,1776],{"class":46,"line":126},[44,1754,333],{"class":163},[44,1756,1757],{"class":91},"(pd.read_excel(",[44,1759,1650],{"class":54},[44,1761,1762],{"class":91},").head(",[44,1764,672],{"class":163},[44,1766,1767],{"class":91},").to_string(",[44,1769,1770],{"class":429},"index",[44,1772,114],{"class":87},[44,1774,1775],{"class":163},"False",[44,1777,342],{"class":91},[10,1779,1780,1783,1784,1786,1787,1789,1790,1793,1794,28],{},[14,1781,1782],{},"A3"," is now a ",[14,1785,1670],{}," with value ",[14,1788,172],{},", and reading the file back gives ",[14,1791,1792],{},"NaN"," in the Region column for every row except the first of each group. Anyone who sorts, filters or builds a pivot from the merged sheet gets broken results — Excel refuses to sort a range containing merged cells of different sizes, and a filter on \"North\" returns one row. Keep the unmerged data in a separate sheet or file, and treat the merged sheet as presentation only. When a merged sheet arrives as input, undo it with ",[24,1795,1797],{"href":1796},"\u002Fgetting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002Funmerge-cells-and-fill-values-with-openpyxl\u002F","Unmerge Cells and Fill Values with openpyxl",[30,1799,1801],{"id":1800},"step-6-the-filter-friendly-alternative-hide-repeats-instead","Step 6 — The filter-friendly alternative: hide repeats instead",[10,1803,1804],{},"To keep every value in place yet show each label once, leave the cells unmerged and hide repeated labels with a conditional format whose font colour matches the background:",[35,1806,1808],{"className":78,"code":1807,"language":80,"meta":40,"style":40},"from openpyxl.formatting.rule import FormulaRule\n\nwb = load_workbook(\"sales_by_region.xlsx\")\nws = wb[\"Sales\"]\nhide = Font(color=\"FFFFFF\")\nlast = ws.max_row\nws.conditional_formatting.add(f\"A3:A{last}\", FormulaRule(formula=[\"A3=A2\"], font=hide))\nws.conditional_formatting.add(f\"B3:B{last}\", FormulaRule(formula=[\"AND(A3=A2,B3=B2)\"], font=hide))\nwb.save(\"sales_hidden_repeats.xlsx\")\nprint([str(cf.sqref) for cf in ws.conditional_formatting])\n",[14,1809,1810,1822,1826,1838,1850,1868,1877,1921,1957,1966],{"__ignoreMap":40},[44,1811,1812,1814,1817,1819],{"class":46,"line":47},[44,1813,88],{"class":87},[44,1815,1816],{"class":91}," openpyxl.formatting.rule ",[44,1818,95],{"class":87},[44,1820,1821],{"class":91}," FormulaRule\n",[44,1823,1824],{"class":46,"line":101},[44,1825,105],{"emptyLinePlaceholder":104},[44,1827,1828,1830,1832,1834,1836],{"class":46,"line":108},[44,1829,111],{"class":91},[44,1831,114],{"class":87},[44,1833,117],{"class":91},[44,1835,120],{"class":54},[44,1837,123],{"class":91},[44,1839,1840,1842,1844,1846,1848],{"class":46,"line":126},[44,1841,129],{"class":91},[44,1843,114],{"class":87},[44,1845,134],{"class":91},[44,1847,137],{"class":54},[44,1849,140],{"class":91},[44,1851,1852,1855,1857,1859,1861,1863,1866],{"class":46,"line":143},[44,1853,1854],{"class":91},"hide ",[44,1856,114],{"class":87},[44,1858,1602],{"class":91},[44,1860,1425],{"class":429},[44,1862,114],{"class":87},[44,1864,1865],{"class":54},"\"FFFFFF\"",[44,1867,123],{"class":91},[44,1869,1870,1873,1875],{"class":46,"line":148},[44,1871,1872],{"class":91},"last ",[44,1874,114],{"class":87},[44,1876,198],{"class":91},[44,1878,1879,1882,1884,1887,1889,1892,1894,1896,1899,1902,1904,1907,1910,1913,1916,1918],{"class":46,"line":178},[44,1880,1881],{"class":91},"ws.conditional_formatting.add(",[44,1883,1042],{"class":87},[44,1885,1886],{"class":54},"\"A3:A",[44,1888,1049],{"class":1048},[44,1890,1891],{"class":91},"last",[44,1893,1055],{"class":1048},[44,1895,1045],{"class":54},[44,1897,1898],{"class":91},", FormulaRule(",[44,1900,1901],{"class":429},"formula",[44,1903,114],{"class":87},[44,1905,1906],{"class":91},"[",[44,1908,1909],{"class":54},"\"A3=A2\"",[44,1911,1912],{"class":91},"], ",[44,1914,1915],{"class":429},"font",[44,1917,114],{"class":87},[44,1919,1920],{"class":91},"hide))\n",[44,1922,1923,1925,1927,1930,1932,1934,1936,1938,1940,1942,1944,1946,1949,1951,1953,1955],{"class":46,"line":184},[44,1924,1881],{"class":91},[44,1926,1042],{"class":87},[44,1928,1929],{"class":54},"\"B3:B",[44,1931,1049],{"class":1048},[44,1933,1891],{"class":91},[44,1935,1055],{"class":1048},[44,1937,1045],{"class":54},[44,1939,1898],{"class":91},[44,1941,1901],{"class":429},[44,1943,114],{"class":87},[44,1945,1906],{"class":91},[44,1947,1948],{"class":54},"\"AND(A3=A2,B3=B2)\"",[44,1950,1912],{"class":91},[44,1952,1915],{"class":429},[44,1954,114],{"class":87},[44,1956,1920],{"class":91},[44,1958,1959,1961,1964],{"class":46,"line":201},[44,1960,1647],{"class":91},[44,1962,1963],{"class":54},"\"sales_hidden_repeats.xlsx\"",[44,1965,123],{"class":91},[44,1967,1968,1970,1972,1975,1978,1980,1983,1985],{"class":46,"line":212},[44,1969,333],{"class":163},[44,1971,1039],{"class":91},[44,1973,1974],{"class":163},"str",[44,1976,1977],{"class":91},"(cf.sqref) ",[44,1979,977],{"class":87},[44,1981,1982],{"class":91}," cf ",[44,1984,221],{"class":87},[44,1986,1987],{"class":91}," ws.conditional_formatting])\n",[10,1989,1990,1991,28],{},"The rule for column A hides a label when it equals the one above it; the rule for column B also requires the region to be the same, which gives the nested behaviour of Step 3. Because the rules are formulas, the display updates when someone sorts or filters: the first visible row of each group always shows its label. The values are all still there for pivots and lookups. Conditional formats are explained in ",[24,1992,1994],{"href":1993},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl\u002F","Applying Conditional Formatting with openpyxl",[532,1996,541,2002,541,2005,541,2008,541,2012,541,2018,541,2029,541,2034,541,2039,541,2044,541,2051,541,2055,541,2060,541,2064,541,2071,541,2075,541,2080,541,2083,541,2090,541,2095,541,2099,541,2103,541,2108,541,2111,541,2116,541,2119,541,2124,541,2127],{"viewBox":1997,"role":535,"ariaLabelledBy":1998,"xmlns":539,"style":2001},"4 0 752 233",[1999,2000],"mg-choice-t","mg-choice-d","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[543,2003,2004],{"id":1999},"Merge the labels or hide the repeats",[547,2006,2007],{"id":2000},"Merging gives a centred label per group but clears the covered values and breaks sorting and filtering. Hiding repeats with a conditional format keeps every value, survives sorting and filtering, but leaves the label at the top of each group rather than centred.",[551,2009],{"x":672,"y":554,"width":2010,"height":2011,"fill":557},"752","233",[551,2013],{"x":2014,"y":1110,"width":2015,"height":2016,"rx":2017,"fill":604,"stroke":605,"style":889},"20.0","351.0","201.0","14",[2019,2020,2022],"g",{"transform":2021},"translate(182.5,29.0) scale(1.083)",[2023,2024],"path",{"d":2025,"fill":2026,"stroke":2027,"style":2028},"M6 4v5a5 5 0 0 0 5 5h7 M6 20v-5 M15 11l3 3-3 3","none","var(--brand-strong,#4338ca)","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[572,2030,908],{"x":2031,"y":2032,"style":2033},"195.5","78.0","font-size:14px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",[46,2035],{"x1":2036,"y1":2037,"x2":2038,"y2":2037,"stroke":605,"style":566},"36.0","91.0","355.0",[572,2040,2043],{"x":2041,"y":626,"style":2042},"60.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:start","One centred label per group",[2019,2045,2047],{"transform":2046},"translate(38.5,99.5) scale(0.625)",[2023,2048],{"d":2049,"fill":2026,"stroke":795,"style":2050},"M5 12.5l4.5 4.5L19 7","stroke-width:2.88px;stroke-linecap:round;stroke-linejoin:round",[572,2052,2054],{"x":2041,"y":2053,"style":2042},"137.0","Classic printed-report look",[2019,2056,2058],{"transform":2057},"translate(38.5,125.5) scale(0.625)",[2023,2059],{"d":2049,"fill":2026,"stroke":795,"style":2050},[572,2061,2063],{"x":2041,"y":2062,"style":2042},"163.0","Covered cells lose their values",[2019,2065,2067],{"transform":2066},"translate(38.5,151.5) scale(0.625)",[2023,2068],{"d":2069,"fill":2026,"stroke":2070,"style":2050},"M6 6l12 12 M18 6L6 18","var(--accent,#f43f8f)",[572,2072,2074],{"x":2041,"y":2073,"style":2042},"189.0","Sorting, filtering and pivots break",[2019,2076,2078],{"transform":2077},"translate(38.5,177.5) scale(0.625)",[2023,2079],{"d":2069,"fill":2026,"stroke":2070,"style":2050},[551,2081],{"x":2082,"y":1110,"width":2015,"height":2016,"rx":2017,"fill":794,"stroke":795,"style":889},"389.0",[2019,2084,2086],{"transform":2085},"translate(551.5,29.0) scale(1.083)",[2023,2087],{"d":2088,"fill":2026,"stroke":2089,"style":2028},"M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z M12 9a3 3 0 1 0 .01 0z","var(--teal-ink,#0b6157)",[572,2091,2094],{"x":2092,"y":2032,"style":2093},"564.5","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Conditional format",[46,2096],{"x1":2097,"y1":2037,"x2":2098,"y2":2037,"stroke":795,"style":566},"405.0","724.0",[572,2100,2102],{"x":2101,"y":626,"style":2042},"429.0","Every value stays in its cell",[2019,2104,2106],{"transform":2105},"translate(407.5,99.5) scale(0.625)",[2023,2107],{"d":2049,"fill":2026,"stroke":795,"style":2050},[572,2109,2110],{"x":2101,"y":2053,"style":2042},"Sort and filter still work",[2019,2112,2114],{"transform":2113},"translate(407.5,125.5) scale(0.625)",[2023,2115],{"d":2049,"fill":2026,"stroke":795,"style":2050},[572,2117,2118],{"x":2101,"y":2062,"style":2042},"Labels reappear after filtering",[2019,2120,2122],{"transform":2121},"translate(407.5,151.5) scale(0.625)",[2023,2123],{"d":2049,"fill":2026,"stroke":795,"style":2050},[572,2125,2126],{"x":2101,"y":2073,"style":2042},"Label sits at the top, not centred",[2019,2128,2130],{"transform":2129},"translate(407.5,177.5) scale(0.625)",[2023,2131],{"d":2132,"fill":2026,"stroke":630,"style":2050},"M12 3.5l9.5 17h-19z M12 10v5 M12 17.8v.4",[30,2134,2136],{"id":2135},"step-7-merge-while-writing-with-xlsxwriter","Step 7 — Merge while writing with xlsxwriter",[10,2138,2139,2140,2143],{},"When the report is generated from scratch rather than edited, xlsxwriter can write each merged label in the same pass as the data with ",[14,2141,2142],{},"merge_range",", which takes the corner coordinates, the value and a format:",[35,2145,2147],{"className":78,"code":2146,"language":80,"meta":40,"style":40},"import xlsxwriter\n\ndf = pd.read_excel(\"sales_by_region.xlsx\")\nbook = xlsxwriter.Workbook(\"sales_xlsxwriter.xlsx\")\nsheet = book.add_worksheet(\"Sales\")\nlabel = book.add_format({\"valign\": \"vcenter\", \"indent\": 1, \"border\": 1})\nsheet.write_row(0, 0, df.columns, book.add_format({\"bold\": True}))\nfor r, row in enumerate(df.itertuples(index=False), start=1):\n    sheet.write_row(r, 1, row[1:])\nfor region, idx in df.groupby(\"Region\", sort=False).groups.items():\n    first, last = idx.min() + 1, idx.max() + 1\n    if last > first:\n        sheet.merge_range(first, 0, last, 0, region, label)\n    else:\n        sheet.write(first, 0, region, label)\nbook.close()\nprint(\"written\", len(df), \"rows\")\n",[14,2148,2149,2156,2160,2174,2189,2203,2243,2267,2300,2315,2342,2364,2377,2392,2399,2408,2413],{"__ignoreMap":40},[44,2150,2151,2153],{"class":46,"line":47},[44,2152,95],{"class":87},[44,2154,2155],{"class":91}," xlsxwriter\n",[44,2157,2158],{"class":46,"line":101},[44,2159,105],{"emptyLinePlaceholder":104},[44,2161,2162,2165,2167,2170,2172],{"class":46,"line":108},[44,2163,2164],{"class":91},"df ",[44,2166,114],{"class":87},[44,2168,2169],{"class":91}," pd.read_excel(",[44,2171,120],{"class":54},[44,2173,123],{"class":91},[44,2175,2176,2179,2181,2184,2187],{"class":46,"line":126},[44,2177,2178],{"class":91},"book ",[44,2180,114],{"class":87},[44,2182,2183],{"class":91}," xlsxwriter.Workbook(",[44,2185,2186],{"class":54},"\"sales_xlsxwriter.xlsx\"",[44,2188,123],{"class":91},[44,2190,2191,2194,2196,2199,2201],{"class":46,"line":143},[44,2192,2193],{"class":91},"sheet ",[44,2195,114],{"class":87},[44,2197,2198],{"class":91}," book.add_worksheet(",[44,2200,137],{"class":54},[44,2202,123],{"class":91},[44,2204,2205,2208,2210,2213,2216,2219,2222,2224,2227,2229,2231,2233,2236,2238,2240],{"class":46,"line":148},[44,2206,2207],{"class":91},"label ",[44,2209,114],{"class":87},[44,2211,2212],{"class":91}," book.add_format({",[44,2214,2215],{"class":54},"\"valign\"",[44,2217,2218],{"class":91},": ",[44,2220,2221],{"class":54},"\"vcenter\"",[44,2223,353],{"class":91},[44,2225,2226],{"class":54},"\"indent\"",[44,2228,2218],{"class":91},[44,2230,339],{"class":163},[44,2232,353],{"class":91},[44,2234,2235],{"class":54},"\"border\"",[44,2237,2218],{"class":91},[44,2239,339],{"class":163},[44,2241,2242],{"class":91},"})\n",[44,2244,2245,2248,2250,2252,2254,2257,2260,2262,2264],{"class":46,"line":178},[44,2246,2247],{"class":91},"sheet.write_row(",[44,2249,554],{"class":163},[44,2251,353],{"class":91},[44,2253,554],{"class":163},[44,2255,2256],{"class":91},", df.columns, book.add_format({",[44,2258,2259],{"class":54},"\"bold\"",[44,2261,2218],{"class":91},[44,2263,1610],{"class":163},[44,2265,2266],{"class":91},"}))\n",[44,2268,2269,2271,2274,2276,2279,2282,2284,2286,2288,2291,2294,2296,2298],{"class":46,"line":184},[44,2270,977],{"class":87},[44,2272,2273],{"class":91}," r, row ",[44,2275,221],{"class":87},[44,2277,2278],{"class":163}," enumerate",[44,2280,2281],{"class":91},"(df.itertuples(",[44,2283,1770],{"class":429},[44,2285,114],{"class":87},[44,2287,1775],{"class":163},[44,2289,2290],{"class":91},"), ",[44,2292,2293],{"class":429},"start",[44,2295,114],{"class":87},[44,2297,339],{"class":163},[44,2299,175],{"class":91},[44,2301,2302,2305,2307,2310,2312],{"class":46,"line":201},[44,2303,2304],{"class":91},"    sheet.write_row(r, ",[44,2306,339],{"class":163},[44,2308,2309],{"class":91},", row[",[44,2311,339],{"class":163},[44,2313,2314],{"class":91},":])\n",[44,2316,2317,2319,2322,2324,2327,2330,2332,2335,2337,2339],{"class":46,"line":212},[44,2318,977],{"class":87},[44,2320,2321],{"class":91}," region, idx ",[44,2323,221],{"class":87},[44,2325,2326],{"class":91}," df.groupby(",[44,2328,2329],{"class":54},"\"Region\"",[44,2331,353],{"class":91},[44,2333,2334],{"class":429},"sort",[44,2336,114],{"class":87},[44,2338,1775],{"class":163},[44,2340,2341],{"class":91},").groups.items():\n",[44,2343,2344,2347,2349,2352,2354,2356,2359,2361],{"class":46,"line":246},[44,2345,2346],{"class":91},"    first, last ",[44,2348,114],{"class":87},[44,2350,2351],{"class":91}," idx.min() ",[44,2353,230],{"class":87},[44,2355,233],{"class":163},[44,2357,2358],{"class":91},", idx.max() ",[44,2360,230],{"class":87},[44,2362,2363],{"class":163}," 1\n",[44,2365,2366,2369,2372,2374],{"class":46,"line":276},[44,2367,2368],{"class":87},"    if",[44,2370,2371],{"class":91}," last ",[44,2373,418],{"class":87},[44,2375,2376],{"class":91}," first:\n",[44,2378,2379,2382,2384,2387,2389],{"class":46,"line":291},[44,2380,2381],{"class":91},"        sheet.merge_range(first, ",[44,2383,554],{"class":163},[44,2385,2386],{"class":91},", last, ",[44,2388,554],{"class":163},[44,2390,2391],{"class":91},", region, label)\n",[44,2393,2394,2397],{"class":46,"line":305},[44,2395,2396],{"class":87},"    else",[44,2398,1539],{"class":91},[44,2400,2401,2404,2406],{"class":46,"line":316},[44,2402,2403],{"class":91},"        sheet.write(first, ",[44,2405,554],{"class":163},[44,2407,2391],{"class":91},[44,2409,2410],{"class":46,"line":325},[44,2411,2412],{"class":91},"book.close()\n",[44,2414,2415,2417,2419,2422,2424,2427,2430,2433],{"class":46,"line":330},[44,2416,333],{"class":163},[44,2418,1495],{"class":91},[44,2420,2421],{"class":54},"\"written\"",[44,2423,353],{"class":91},[44,2425,2426],{"class":163},"len",[44,2428,2429],{"class":91},"(df), ",[44,2431,2432],{"class":54},"\"rows\"",[44,2434,123],{"class":91},[10,2436,2437,2440,2441,2444,2445,2448],{},[14,2438,2439],{},"groupby(..., sort=False).groups"," maps each region to the DataFrame index positions of its rows, which become the first and last worksheet rows of the merge (plus one for the header). xlsxwriter refuses to merge a single cell, hence the ",[14,2442,2443],{},"write"," branch for one-row groups, and it raises ",[14,2446,2447],{},"OverlappingRange"," if two merges overlap — a useful guard against the crossing-groups mistake from Step 3. The data columns are written first and the label column afterwards, because writing a value into a cell that belongs to a merged range would overwrite part of it.",[30,2450,2452],{"id":2451},"common-pitfalls","Common pitfalls",[2454,2455,2456,2464,2477,2483,2489],"ul",{},[2457,2458,2459,2463],"li",{},[2460,2461,2462],"strong",{},"Unsorted data."," Runs are consecutive equal values; an unsorted column produces many small merges and repeated labels.",[2457,2465,2466,2469,2470,2472,2473,2476],{},[2460,2467,2468],{},"Writing to covered cells."," Assigning a value to a ",[14,2471,1670],{}," raises ",[14,2474,2475],{},"AttributeError: 'MergedCell' object attribute 'value' is read-only","; write to the top-left cell.",[2457,2478,2479,2482],{},[2460,2480,2481],{},"Inner groups crossing outer ones."," Detect inner runs within each outer run, never over the whole column.",[2457,2484,2485,2488],{},[2460,2486,2487],{},"Merging, then writing more data."," If a later step appends rows or rewrites the sheet with pandas, merges are lost or misplaced; merge as the last step before saving.",[2457,2490,2491,2494,2495,357,2498,2501],{},[2460,2492,2493],{},"Equal-looking values that differ."," ",[14,2496,2497],{},"\"North\"",[14,2499,2500],{},"\"North \""," start separate runs; strip text first.",[30,2503,2505],{"id":2504},"conclusion","Conclusion",[10,2507,2508,2509,2511],{},"Find runs of consecutive equal values, merge each with ",[14,2510,908],{},", and centre the label vertically in its top-left cell; for nested groups, look for inner runs within each outer run. Finish with borders under each group, and remember that merging clears the covered values — keep the raw data elsewhere, or use a conditional format that hides repeated labels when the sheet must stay sortable and filterable.",[30,2513,2515],{"id":2514},"frequently-asked-questions","Frequently asked questions",[10,2517,2518,2521],{},[2460,2519,2520],{},"How do I merge cells with the same value in a column using openpyxl?","\nSort the data, walk down the column collecting runs of consecutive equal values, and call ws.merge_cells(start_row=..., start_column=col, end_row=..., end_column=col) for each run longer than one row. Set the alignment on the top-left cell of each run.",[10,2523,2524,2527],{},[2460,2525,2526],{},"How do I vertically centre text in a merged cell?","\nSet the alignment on the merged range's top-left cell: cell.alignment = Alignment(vertical=\"center\"). The merged block takes its formatting from that cell.",[10,2529,2530,2533],{},[2460,2531,2532],{},"Does merging cells delete data in openpyxl?","\nYes. merge_cells clears every cell in the range except the top-left one, which becomes the merged cell's value. Reading the file back gives empty values for the covered rows.",[10,2535,2536,2539],{},[2460,2537,2538],{},"How can I show a group label once without merging cells?","\nLeave every label in place and add a conditional formatting rule such as =A3=A2 on the label column that sets the font colour to the background colour. Repeats are hidden, and sorting and filtering keep working.",[30,2541,2543],{"id":2542},"related","Related",[2454,2545,2546,2552,2559,2566],{},[2457,2547,2548,2549,2551],{},"Up one level: ",[24,2550,27],{"href":26}," — working with cells, rows and sheets.",[2457,2553,2554,2558],{},[24,2555,2557],{"href":2556},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Fmerge-cells-and-centre-a-report-title-with-openpyxl\u002F","Merge Cells and Centre a Report Title with openpyxl"," — merging across columns for headings.",[2457,2560,2561,2565],{},[24,2562,2564],{"href":2563},"\u002Fgetting-started-with-python-excel-automation\u002Fwriting-dataframes-to-excel-with-pandas\u002Fwrite-a-multiindex-dataframe-to-excel\u002F","Write a MultiIndex DataFrame to Excel with pandas"," — pandas merges index levels for you.",[2457,2567,2568,2572],{},[24,2569,2571],{"href":2570},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-excel-tables-and-autofilters-with-python\u002Fgroup-and-outline-rows-in-excel-with-openpyxl\u002F","Group and Outline Rows in Excel with openpyxl"," — collapsible groups as another way to show structure.",[1415,2574,2575],{},"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 .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}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 .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":40,"searchDepth":101,"depth":101,"links":2577},[2578,2579,2580,2581,2582,2583,2584,2585,2586,2587,2588,2589],{"id":32,"depth":101,"text":33},{"id":74,"depth":101,"text":75},{"id":364,"depth":101,"text":365},{"id":911,"depth":101,"text":912},{"id":1378,"depth":101,"text":1379},{"id":1678,"depth":101,"text":1679},{"id":1800,"depth":101,"text":1801},{"id":2135,"depth":101,"text":2136},{"id":2451,"depth":101,"text":2452},{"id":2504,"depth":101,"text":2505},{"id":2514,"depth":101,"text":2515},{"id":2542,"depth":101,"text":2543},"2026-10-02","Turn a report where the region name repeats on every row into one merged, vertically centred label per group: find runs of equal values, merge them with openpyxl, handle nested groups so an inner label never spans two outer groups, keep borders tidy, and know when to hide repeats with conditional formatting instead so the sheet stays sortable.","md",[2594,2596,2598,2600],{"q":2520,"a":2595},"Sort the data, walk down the column collecting runs of consecutive equal values, and call ws.merge_cells(start_row=..., start_column=col, end_row=..., end_column=col) for each run longer than one row. Set the alignment on the top-left cell of each run.",{"q":2526,"a":2597},"Set the alignment on the merged range's top-left cell: cell.alignment = Alignment(vertical=\"center\"). The merged block takes its formatting from that cell.",{"q":2532,"a":2599},"Yes. merge_cells clears every cell in the range except the top-left one, which becomes the merged cell's value. Reading the file back gives empty values for the covered rows.",{"q":2538,"a":2601},"Leave every label in place and add a conditional formatting rule such as =A3=A2 on the label column that sets the font colour to the background colour. Repeats are hidden, and sorting and filtering keep working.","merge repeated group labels into single cells with openpyxl",null,{"breadcrumb":2605},[2606,2609,2612],{"name":2607,"item":2608},"Home","\u002F",{"name":2610,"item":2611},"Getting Started with Python Excel Automation","\u002Fgetting-started-with-python-excel-automation\u002F",{"name":27,"item":26},"\u002Fgetting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002Fmerge-repeated-group-labels-into-single-cells-with-openpyxl",{"title":2615,"description":2616},"Merge Repeated Group Labels in Excel with openpyxl","Merge consecutive duplicate labels in a column into one centred cell with openpyxl: run detection, nested groups, alignment and borders, what merging does to the hidden values, and a filter-friendly alternative that hides repeats instead.","merge-repeated-group-labels-into-single-cells-with-openpyxl","getting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002Fmerge-repeated-group-labels-into-single-cells-with-openpyxl\u002Findex",[2620,2621,2622],"openpyxl","report-layouts","cell-styling","how-to","UIWQWu1iithpFoSi0Vj4zcByq_COGOvhsKnw1fMTOCc",[2626,2630],{"title":2627,"path":2628,"stem":2629,"children":-1},"Iterate over Rows and Columns with openpyxl","\u002Fgetting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002Fiterate-over-rows-and-columns-with-openpyxl","getting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002Fiterate-over-rows-and-columns-with-openpyxl\u002Findex",{"title":2631,"path":2632,"stem":2633,"children":-1},"Move a Range of Cells with openpyxl move_range","\u002Fgetting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002Fmove-a-range-of-cells-with-openpyxl","getting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation\u002Fmove-a-range-of-cells-with-openpyxl\u002Findex",[2635,2637],{"path":2636,"title":2610},"\u002Fgetting-started-with-python-excel-automation",{"path":2638,"title":27},"\u002Fgetting-started-with-python-excel-automation\u002Fusing-openpyxl-for-excel-file-manipulation",[],[2641,2645,2648,2651,2654,2657],{"path":2642,"title":2643,"kind":2644},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl","Applying Conditional Formatting With openpyxl","guide",{"path":2646,"title":2647,"kind":2623},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl\u002Fadd-data-bars-and-colour-scales-with-openpyxl","Add Data Bars and Colour Scales with openpyxl",{"path":2649,"title":2650,"kind":2623},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl\u002Fadd-icon-sets-with-openpyxl","Add Icon Sets to Excel Cells with openpyxl",{"path":2652,"title":2653,"kind":2623},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl\u002Fapply-a-colour-scale-per-row-or-per-column-with-openpyxl","Apply a Colour Scale per Row or per Column with openpyxl",{"path":2655,"title":2656,"kind":2623},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl\u002Fcontrol-conditional-formatting-rule-order-and-stop-if-true","Control Conditional Formatting Rule Order and Stop If True",{"path":2658,"title":2659,"kind":2623},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl\u002Fhighlight-an-entire-row-based-on-a-cell-value-with-openpyxl","Highlight an Entire Row Based on a Cell Value with openpyxl",1790972069965]