[{"data":1,"prerenderedAt":2639},["ShallowReactive",2],{"doc:\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Fmerge-cells-and-centre-a-report-title-with-openpyxl":3,"surround:\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Fmerge-cells-and-centre-a-report-title-with-openpyxl":2632},{"id":4,"title":5,"body":6,"dateModified":2606,"datePublished":2606,"description":2607,"extension":2608,"faq":2609,"meta":2623,"navigation":246,"path":2624,"seo":2625,"slug":2628,"stem":2629,"type":2630,"__hash__":2631},"docs\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Fmerge-cells-and-centre-a-report-title-with-openpyxl\u002Findex.md","Merge Cells and Centre a Report Title with openpyxl",{"type":7,"value":8,"toc":2593},"minimark",[9,19,181,186,215,218,383,387,390,638,649,652,685,692,773,777,780,1105,1115,1119,1122,1703,1709,1713,1716,1800,1879,1889,1895,1899,1958,1961,2037,2040,2075,2079,2206,2210,2213,2223,2232,2247,2250,2485,2491,2495,2504,2508,2521,2527,2533,2539,2549,2553,2590],[10,11,12,13,18],"p",{},"Every generated report wants a title: the report name, the period, maybe an as-at timestamp, spanning the width of the data and centred. In Excel that means merging a range and centring the text — two calls in openpyxl, plus a handful of behaviours that are surprising the first time. Only one cell in a merged range is real, borders do not span the merge on their own, and merging inside a data region breaks sorting for everyone downstream. This guide covers the banner properly, and the alternative that avoids merging altogether. It extends ",[14,15,17],"a",{"href":16},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002F","Styling Excel Cells with openpyxl",".",[20,21,30,31,30,35,30,39,30,47,30,57,30,64,30,69,30,74,30,82,30,88,30,92,30,96,30,99,30,103,30,106,30,110,30,114,30,119,30,122,30,126,30,132,30,139,30,146,30,152,30,157,30,162,30,168,30,173,30,177],"svg",{"viewBox":22,"role":23,"ariaLabel":24,"ariaLabelledBy":25,"xmlns":28,"style":29},"24 14 774 196","img","A merged title banner spanning four columns above a data table, with a subtitle row, showing that only the top-left cell of a merge holds the value.",[26,27],"ban-t","ban-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:774px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[32,33,34],"title",{"id":26},"A title banner above the data, and where its value lives",[36,37,38],"desc",{"id":27},"A report sheet whose first row is a merged range spanning columns A to D holding a centred title, and whose second row is a merged subtitle. Below them sit the ordinary header row and data rows. An annotation marks cell A1 as the anchor: it is the only real cell in the merged range and the only one that can be written to. B1, C1 and D1 are MergedCell objects and are read-only.",[40,41],"rect",{"x":42,"y":43,"width":44,"height":45,"fill":46},"24","14","774","196","#ffffff",[40,48],{"x":49,"y":50,"width":51,"height":52,"rx":53,"fill":54,"stroke":55,"style":56},"40","34","480","38","5","#ebebfd","var(--brand,#5b5cf0)","stroke-width:2px",[58,59,63],"text",{"x":60,"y":61,"style":62},"280","59","font-size:14px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Regional Revenue Report",[40,65],{"x":49,"y":66,"width":51,"height":67,"rx":53,"fill":68,"stroke":55},"76","28","#f0f4ff",[58,70,73],{"x":60,"y":71,"style":72},"95","font-size:11px;fill:var(--muted,#5b6780);text-anchor:middle","August 2026 · generated 2026-08-15 06:00 UTC",[40,75],{"x":49,"y":76,"width":77,"height":78,"rx":79,"fill":80,"stroke":81,"style":56},"112","120","26","4","#d9f4f1","var(--teal,#0f9488)",[58,83,87],{"x":84,"y":85,"style":86},"100","130","font-size:10px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","region",[40,89],{"x":90,"y":76,"width":91,"height":78,"rx":79,"fill":80,"stroke":81,"style":56},"162","118",[58,93,95],{"x":94,"y":85,"style":86},"221","units",[40,97],{"x":98,"y":76,"width":91,"height":78,"rx":79,"fill":80,"stroke":81,"style":56},"282",[58,100,102],{"x":101,"y":85,"style":86},"341","revenue",[40,104],{"x":105,"y":76,"width":91,"height":78,"rx":79,"fill":80,"stroke":81,"style":56},"402",[58,107,109],{"x":108,"y":85,"style":86},"461","target",[40,111],{"x":49,"y":112,"width":51,"height":42,"rx":79,"fill":46,"stroke":113},"142","var(--line,#cdd5e6)",[58,115,118],{"x":60,"y":116,"style":117},"159","font-size:10px;fill:var(--text,#172033);text-anchor:middle","North · 412 · 5,150.00 · 4,500.00",[40,120],{"x":49,"y":121,"width":51,"height":42,"rx":79,"fill":46,"stroke":113},"170",[58,123,125],{"x":60,"y":124,"style":117},"187","South · 388 · 4,268.50 · 4,500.00",[127,128],"path",{"d":129,"fill":130,"stroke":131,"style":56},"M44 44 L 44 30 L 560 30","none","var(--accent,#f43f8f)",[133,134],"circle",{"cx":135,"cy":136,"r":137,"fill":138},"52","53","6","#f43f8f",[40,140],{"x":141,"y":50,"width":142,"height":143,"rx":144,"fill":145,"stroke":131,"style":56},"560","222","70","11","#fee8f2",[58,147,151],{"x":148,"y":149,"style":150},"671","58","font-size:11.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","A1 is the anchor",[58,153,156],{"x":148,"y":154,"style":155},"78","font-size:10.5px;fill:var(--text,#172033);text-anchor:middle","the only writable cell",[58,158,161],{"x":148,"y":159,"style":160},"96","font-size:10.5px;fill:var(--muted,#5b6780);text-anchor:middle","B1, C1, D1 are read-only",[40,163],{"x":141,"y":164,"width":142,"height":165,"rx":144,"fill":166,"stroke":167,"style":56},"128","66","#fdefd8","var(--gold,#b4740a)",[58,169,172],{"x":148,"y":170,"style":171},"152","font-size:11.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","never merge here",[58,174,176],{"x":148,"y":175,"style":155},"172","merges inside the data",[58,178,180],{"x":148,"y":179,"style":160},"188","break sorting and filtering",[182,183,185],"h2",{"id":184},"prerequisites","Prerequisites",[187,188,193],"pre",{"className":189,"code":190,"language":191,"meta":192,"style":192},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install openpyxl pandas\n","bash","",[194,195,196],"code",{"__ignoreMap":192},[197,198,201,205,209,212],"span",{"class":199,"line":200},"line",1,[197,202,204],{"class":203},"sMTad","pip",[197,206,208],{"class":207},"srMev"," install",[197,210,211],{"class":207}," openpyxl",[197,213,214],{"class":207}," pandas\n",[10,216,217],{},"A report to add a banner to, written with the data starting two rows down:",[187,219,223],{"className":220,"code":221,"language":222,"meta":192,"style":192},"language-python shiki shiki-themes github-light github-dark-high-contrast","import pandas as pd\n\npd.DataFrame({\n    \"region\": [\"North\", \"South\", \"West\"],\n    \"units\": [412, 388, 265],\n    \"revenue\": [5150.00, 4268.50, 3511.25],\n    \"target\": [4500.00, 4500.00, 3000.00],\n}).to_excel(\"report.xlsx\", index=False, startrow=2)\n","python",[194,224,225,241,248,254,280,304,327,349],{"__ignoreMap":192},[197,226,227,231,235,238],{"class":199,"line":200},[197,228,230],{"class":229},"s-kum","import",[197,232,234],{"class":233},"skGVy"," pandas ",[197,236,237],{"class":229},"as",[197,239,240],{"class":233}," pd\n",[197,242,244],{"class":199,"line":243},2,[197,245,247],{"emptyLinePlaceholder":246},true,"\n",[197,249,251],{"class":199,"line":250},3,[197,252,253],{"class":233},"pd.DataFrame({\n",[197,255,257,260,263,266,269,272,274,277],{"class":199,"line":256},4,[197,258,259],{"class":207},"    \"region\"",[197,261,262],{"class":233},": [",[197,264,265],{"class":207},"\"North\"",[197,267,268],{"class":233},", ",[197,270,271],{"class":207},"\"South\"",[197,273,268],{"class":233},[197,275,276],{"class":207},"\"West\"",[197,278,279],{"class":233},"],\n",[197,281,283,286,288,292,294,297,299,302],{"class":199,"line":282},5,[197,284,285],{"class":207},"    \"units\"",[197,287,262],{"class":233},[197,289,291],{"class":290},"sP0c6","412",[197,293,268],{"class":233},[197,295,296],{"class":290},"388",[197,298,268],{"class":233},[197,300,301],{"class":290},"265",[197,303,279],{"class":233},[197,305,307,310,312,315,317,320,322,325],{"class":199,"line":306},6,[197,308,309],{"class":207},"    \"revenue\"",[197,311,262],{"class":233},[197,313,314],{"class":290},"5150.00",[197,316,268],{"class":233},[197,318,319],{"class":290},"4268.50",[197,321,268],{"class":233},[197,323,324],{"class":290},"3511.25",[197,326,279],{"class":233},[197,328,330,333,335,338,340,342,344,347],{"class":199,"line":329},7,[197,331,332],{"class":207},"    \"target\"",[197,334,262],{"class":233},[197,336,337],{"class":290},"4500.00",[197,339,268],{"class":233},[197,341,337],{"class":290},[197,343,268],{"class":233},[197,345,346],{"class":290},"3000.00",[197,348,279],{"class":233},[197,350,352,355,358,360,364,367,370,372,375,377,380],{"class":199,"line":351},8,[197,353,354],{"class":233},"}).to_excel(",[197,356,357],{"class":207},"\"report.xlsx\"",[197,359,268],{"class":233},[197,361,363],{"class":362},"sa561","index",[197,365,366],{"class":229},"=",[197,368,369],{"class":290},"False",[197,371,268],{"class":233},[197,373,374],{"class":362},"startrow",[197,376,366],{"class":229},[197,378,379],{"class":290},"2",[197,381,382],{"class":233},")\n",[182,384,386],{"id":385},"step-1-merge-and-centre","Step 1 — Merge and centre",[10,388,389],{},"Write the value to the top-left cell, then merge:",[187,391,393],{"className":220,"code":392,"language":222,"meta":192,"style":192},"from openpyxl import load_workbook\nfrom openpyxl.styles import Font, Alignment, PatternFill\n\nwb = load_workbook(\"report.xlsx\")\nws = wb.active\n\nws[\"A1\"] = \"Regional Revenue Report\"\nws.merge_cells(\"A1:D1\")\n\nanchor = ws[\"A1\"]\nanchor.font = Font(size=15, bold=True, color=\"4338CA\")\nanchor.alignment = Alignment(horizontal=\"center\", vertical=\"center\")\nanchor.fill = PatternFill(\"solid\", start_color=\"EBEBFD\", end_color=\"EBEBFD\")\nws.row_dimensions[1].height = 30\n\nwb.save(\"report_titled.xlsx\")\n",[194,394,395,408,420,424,438,448,452,468,478,483,499,540,570,605,622,627],{"__ignoreMap":192},[197,396,397,400,403,405],{"class":199,"line":200},[197,398,399],{"class":229},"from",[197,401,402],{"class":233}," openpyxl ",[197,404,230],{"class":229},[197,406,407],{"class":233}," load_workbook\n",[197,409,410,412,415,417],{"class":199,"line":243},[197,411,399],{"class":229},[197,413,414],{"class":233}," openpyxl.styles ",[197,416,230],{"class":229},[197,418,419],{"class":233}," Font, Alignment, PatternFill\n",[197,421,422],{"class":199,"line":250},[197,423,247],{"emptyLinePlaceholder":246},[197,425,426,429,431,434,436],{"class":199,"line":256},[197,427,428],{"class":233},"wb ",[197,430,366],{"class":229},[197,432,433],{"class":233}," load_workbook(",[197,435,357],{"class":207},[197,437,382],{"class":233},[197,439,440,443,445],{"class":199,"line":282},[197,441,442],{"class":233},"ws ",[197,444,366],{"class":229},[197,446,447],{"class":233}," wb.active\n",[197,449,450],{"class":199,"line":306},[197,451,247],{"emptyLinePlaceholder":246},[197,453,454,457,460,463,465],{"class":199,"line":329},[197,455,456],{"class":233},"ws[",[197,458,459],{"class":207},"\"A1\"",[197,461,462],{"class":233},"] ",[197,464,366],{"class":229},[197,466,467],{"class":207}," \"Regional Revenue Report\"\n",[197,469,470,473,476],{"class":199,"line":351},[197,471,472],{"class":233},"ws.merge_cells(",[197,474,475],{"class":207},"\"A1:D1\"",[197,477,382],{"class":233},[197,479,481],{"class":199,"line":480},9,[197,482,247],{"emptyLinePlaceholder":246},[197,484,486,489,491,494,496],{"class":199,"line":485},10,[197,487,488],{"class":233},"anchor ",[197,490,366],{"class":229},[197,492,493],{"class":233}," ws[",[197,495,459],{"class":207},[197,497,498],{"class":233},"]\n",[197,500,502,505,507,510,513,515,518,520,523,525,528,530,533,535,538],{"class":199,"line":501},11,[197,503,504],{"class":233},"anchor.font ",[197,506,366],{"class":229},[197,508,509],{"class":233}," Font(",[197,511,512],{"class":362},"size",[197,514,366],{"class":229},[197,516,517],{"class":290},"15",[197,519,268],{"class":233},[197,521,522],{"class":362},"bold",[197,524,366],{"class":229},[197,526,527],{"class":290},"True",[197,529,268],{"class":233},[197,531,532],{"class":362},"color",[197,534,366],{"class":229},[197,536,537],{"class":207},"\"4338CA\"",[197,539,382],{"class":233},[197,541,543,546,548,551,554,556,559,561,564,566,568],{"class":199,"line":542},12,[197,544,545],{"class":233},"anchor.alignment ",[197,547,366],{"class":229},[197,549,550],{"class":233}," Alignment(",[197,552,553],{"class":362},"horizontal",[197,555,366],{"class":229},[197,557,558],{"class":207},"\"center\"",[197,560,268],{"class":233},[197,562,563],{"class":362},"vertical",[197,565,366],{"class":229},[197,567,558],{"class":207},[197,569,382],{"class":233},[197,571,573,576,578,581,584,586,589,591,594,596,599,601,603],{"class":199,"line":572},13,[197,574,575],{"class":233},"anchor.fill ",[197,577,366],{"class":229},[197,579,580],{"class":233}," PatternFill(",[197,582,583],{"class":207},"\"solid\"",[197,585,268],{"class":233},[197,587,588],{"class":362},"start_color",[197,590,366],{"class":229},[197,592,593],{"class":207},"\"EBEBFD\"",[197,595,268],{"class":233},[197,597,598],{"class":362},"end_color",[197,600,366],{"class":229},[197,602,593],{"class":207},[197,604,382],{"class":233},[197,606,608,611,614,617,619],{"class":199,"line":607},14,[197,609,610],{"class":233},"ws.row_dimensions[",[197,612,613],{"class":290},"1",[197,615,616],{"class":233},"].height ",[197,618,366],{"class":229},[197,620,621],{"class":290}," 30\n",[197,623,625],{"class":199,"line":624},15,[197,626,247],{"emptyLinePlaceholder":246},[197,628,630,633,636],{"class":199,"line":629},16,[197,631,632],{"class":233},"wb.save(",[197,634,635],{"class":207},"\"report_titled.xlsx\"",[197,637,382],{"class":233},[10,639,640,644,645,648],{},[641,642,643],"strong",{},"Write the value before or after merging, but always to the anchor."," Merging discards the values in every cell except the top-left, so a value written to ",[194,646,647],{},"B1"," and then merged is simply gone.",[10,650,651],{},"The read-only behaviour catches everyone once:",[187,653,655],{"className":220,"code":654,"language":222,"meta":192,"style":192},"ws.merge_cells(\"A1:D1\")\nws[\"B1\"] = \"anything\"\n# AttributeError: 'MergedCell' object attribute 'value' is read-only\n",[194,656,657,665,679],{"__ignoreMap":192},[197,658,659,661,663],{"class":199,"line":200},[197,660,472],{"class":233},[197,662,475],{"class":207},[197,664,382],{"class":233},[197,666,667,669,672,674,676],{"class":199,"line":243},[197,668,456],{"class":233},[197,670,671],{"class":207},"\"B1\"",[197,673,462],{"class":233},[197,675,366],{"class":229},[197,677,678],{"class":207}," \"anything\"\n",[197,680,681],{"class":199,"line":250},[197,682,684],{"class":683},"s-wDw","# AttributeError: 'MergedCell' object attribute 'value' is read-only\n",[10,686,687,688,691],{},"The cells inside a merge are ",[194,689,690],{},"MergedCell"," placeholders, not real cells. Only the anchor is writable, which also means a loop over the range needs guarding:",[187,693,695],{"className":220,"code":694,"language":222,"meta":192,"style":192},"from openpyxl.cell.cell import MergedCell\n\nfor row in ws[\"A1:D1\"]:\n    for cell in row:\n        if isinstance(cell, MergedCell):\n            continue                       # skip the placeholders\n        cell.value = \"written safely\"\n",[194,696,697,709,713,731,744,755,763],{"__ignoreMap":192},[197,698,699,701,704,706],{"class":199,"line":200},[197,700,399],{"class":229},[197,702,703],{"class":233}," openpyxl.cell.cell ",[197,705,230],{"class":229},[197,707,708],{"class":233}," MergedCell\n",[197,710,711],{"class":199,"line":243},[197,712,247],{"emptyLinePlaceholder":246},[197,714,715,718,721,724,726,728],{"class":199,"line":250},[197,716,717],{"class":229},"for",[197,719,720],{"class":233}," row ",[197,722,723],{"class":229},"in",[197,725,493],{"class":233},[197,727,475],{"class":207},[197,729,730],{"class":233},"]:\n",[197,732,733,736,739,741],{"class":199,"line":256},[197,734,735],{"class":229},"    for",[197,737,738],{"class":233}," cell ",[197,740,723],{"class":229},[197,742,743],{"class":233}," row:\n",[197,745,746,749,752],{"class":199,"line":282},[197,747,748],{"class":229},"        if",[197,750,751],{"class":290}," isinstance",[197,753,754],{"class":233},"(cell, MergedCell):\n",[197,756,757,760],{"class":199,"line":306},[197,758,759],{"class":229},"            continue",[197,761,762],{"class":683},"                       # skip the placeholders\n",[197,764,765,768,770],{"class":199,"line":329},[197,766,767],{"class":233},"        cell.value ",[197,769,366],{"class":229},[197,771,772],{"class":207}," \"written safely\"\n",[182,774,776],{"id":775},"step-2-border-the-whole-range","Step 2 — Border the whole range",[10,778,779],{},"A border set on the anchor draws around that one cell's original bounds — a box a quarter of the way across your banner. Borders have to be applied to every cell of the merge, placeholders included:",[187,781,783],{"className":220,"code":782,"language":222,"meta":192,"style":192},"from openpyxl.styles import Border, Side\nfrom openpyxl.utils import range_boundaries\n\ndef style_merged_range(ws, ref, fill=None, border=None):\n    \"\"\"Apply a fill and border across every cell of a merged range.\"\"\"\n    min_col, min_row, max_col, max_row = range_boundaries(ref)\n    for row in range(min_row, max_row + 1):\n        for col in range(min_col, max_col + 1):\n            cell = ws.cell(row=row, column=col)\n            if fill is not None:\n                cell.fill = fill\n            if border is not None:\n                cell.border = border\n\nthin = Side(style=\"thin\", color=\"CDD5E6\")\nstyle_merged_range(\n    ws, \"A1:D1\",\n    fill=PatternFill(\"solid\", start_color=\"EBEBFD\", end_color=\"EBEBFD\"),\n    border=Border(top=thin, bottom=thin, left=thin, right=thin),\n)\n",[194,784,785,796,808,812,839,844,854,876,897,923,943,953,968,978,982,1011,1016,1027,1059,1100],{"__ignoreMap":192},[197,786,787,789,791,793],{"class":199,"line":200},[197,788,399],{"class":229},[197,790,414],{"class":233},[197,792,230],{"class":229},[197,794,795],{"class":233}," Border, Side\n",[197,797,798,800,803,805],{"class":199,"line":243},[197,799,399],{"class":229},[197,801,802],{"class":233}," openpyxl.utils ",[197,804,230],{"class":229},[197,806,807],{"class":233}," range_boundaries\n",[197,809,810],{"class":199,"line":250},[197,811,247],{"emptyLinePlaceholder":246},[197,813,814,817,821,824,826,829,832,834,836],{"class":199,"line":256},[197,815,816],{"class":229},"def",[197,818,820],{"class":819},"s_Opv"," style_merged_range",[197,822,823],{"class":233},"(ws, ref, fill",[197,825,366],{"class":229},[197,827,828],{"class":290},"None",[197,830,831],{"class":233},", border",[197,833,366],{"class":229},[197,835,828],{"class":290},[197,837,838],{"class":233},"):\n",[197,840,841],{"class":199,"line":282},[197,842,843],{"class":207},"    \"\"\"Apply a fill and border across every cell of a merged range.\"\"\"\n",[197,845,846,849,851],{"class":199,"line":306},[197,847,848],{"class":233},"    min_col, min_row, max_col, max_row ",[197,850,366],{"class":229},[197,852,853],{"class":233}," range_boundaries(ref)\n",[197,855,856,858,860,862,865,868,871,874],{"class":199,"line":329},[197,857,735],{"class":229},[197,859,720],{"class":233},[197,861,723],{"class":229},[197,863,864],{"class":290}," range",[197,866,867],{"class":233},"(min_row, max_row ",[197,869,870],{"class":229},"+",[197,872,873],{"class":290}," 1",[197,875,838],{"class":233},[197,877,878,881,884,886,888,891,893,895],{"class":199,"line":351},[197,879,880],{"class":229},"        for",[197,882,883],{"class":233}," col ",[197,885,723],{"class":229},[197,887,864],{"class":290},[197,889,890],{"class":233},"(min_col, max_col ",[197,892,870],{"class":229},[197,894,873],{"class":290},[197,896,838],{"class":233},[197,898,899,902,904,907,910,912,915,918,920],{"class":199,"line":480},[197,900,901],{"class":233},"            cell ",[197,903,366],{"class":229},[197,905,906],{"class":233}," ws.cell(",[197,908,909],{"class":362},"row",[197,911,366],{"class":229},[197,913,914],{"class":233},"row, ",[197,916,917],{"class":362},"column",[197,919,366],{"class":229},[197,921,922],{"class":233},"col)\n",[197,924,925,928,931,934,937,940],{"class":199,"line":485},[197,926,927],{"class":229},"            if",[197,929,930],{"class":233}," fill ",[197,932,933],{"class":229},"is",[197,935,936],{"class":229}," not",[197,938,939],{"class":290}," None",[197,941,942],{"class":233},":\n",[197,944,945,948,950],{"class":199,"line":501},[197,946,947],{"class":233},"                cell.fill ",[197,949,366],{"class":229},[197,951,952],{"class":233}," fill\n",[197,954,955,957,960,962,964,966],{"class":199,"line":542},[197,956,927],{"class":229},[197,958,959],{"class":233}," border ",[197,961,933],{"class":229},[197,963,936],{"class":229},[197,965,939],{"class":290},[197,967,942],{"class":233},[197,969,970,973,975],{"class":199,"line":572},[197,971,972],{"class":233},"                cell.border ",[197,974,366],{"class":229},[197,976,977],{"class":233}," border\n",[197,979,980],{"class":199,"line":607},[197,981,247],{"emptyLinePlaceholder":246},[197,983,984,987,989,992,995,997,1000,1002,1004,1006,1009],{"class":199,"line":624},[197,985,986],{"class":233},"thin ",[197,988,366],{"class":229},[197,990,991],{"class":233}," Side(",[197,993,994],{"class":362},"style",[197,996,366],{"class":229},[197,998,999],{"class":207},"\"thin\"",[197,1001,268],{"class":233},[197,1003,532],{"class":362},[197,1005,366],{"class":229},[197,1007,1008],{"class":207},"\"CDD5E6\"",[197,1010,382],{"class":233},[197,1012,1013],{"class":199,"line":629},[197,1014,1015],{"class":233},"style_merged_range(\n",[197,1017,1019,1022,1024],{"class":199,"line":1018},17,[197,1020,1021],{"class":233},"    ws, ",[197,1023,475],{"class":207},[197,1025,1026],{"class":233},",\n",[197,1028,1030,1033,1035,1038,1040,1042,1044,1046,1048,1050,1052,1054,1056],{"class":199,"line":1029},18,[197,1031,1032],{"class":362},"    fill",[197,1034,366],{"class":229},[197,1036,1037],{"class":233},"PatternFill(",[197,1039,583],{"class":207},[197,1041,268],{"class":233},[197,1043,588],{"class":362},[197,1045,366],{"class":229},[197,1047,593],{"class":207},[197,1049,268],{"class":233},[197,1051,598],{"class":362},[197,1053,366],{"class":229},[197,1055,593],{"class":207},[197,1057,1058],{"class":233},"),\n",[197,1060,1062,1065,1067,1070,1073,1075,1078,1081,1083,1085,1088,1090,1092,1095,1097],{"class":199,"line":1061},19,[197,1063,1064],{"class":362},"    border",[197,1066,366],{"class":229},[197,1068,1069],{"class":233},"Border(",[197,1071,1072],{"class":362},"top",[197,1074,366],{"class":229},[197,1076,1077],{"class":233},"thin, ",[197,1079,1080],{"class":362},"bottom",[197,1082,366],{"class":229},[197,1084,1077],{"class":233},[197,1086,1087],{"class":362},"left",[197,1089,366],{"class":229},[197,1091,1077],{"class":233},[197,1093,1094],{"class":362},"right",[197,1096,366],{"class":229},[197,1098,1099],{"class":233},"thin),\n",[197,1101,1103],{"class":199,"line":1102},20,[197,1104,382],{"class":233},[10,1106,1107,1108,1110,1111,1114],{},"Assigning a fill or border to a ",[194,1109,690],{}," works — it is only ",[194,1112,1113],{},"value"," that is read-only. That distinction is the thing to remember: styles apply to the placeholders, values do not.",[182,1116,1118],{"id":1117},"step-3-build-the-banner-as-a-function","Step 3 — Build the banner as a function",[10,1120,1121],{},"Titles come in a predictable shape: a heading, a subtitle carrying the period and generation time, then a gap before the data. Wrap it once:",[187,1123,1125],{"className":220,"code":1124,"language":222,"meta":192,"style":192},"from datetime import datetime, timezone\nfrom openpyxl import load_workbook\nfrom openpyxl.styles import Font, Alignment, PatternFill, Border, Side\nfrom openpyxl.utils import get_column_letter, range_boundaries\n\ndef add_banner(ws, title, subtitle=None, columns=4, height=30):\n    \"\"\"Add a merged, centred title (and optional subtitle) above the data.\"\"\"\n    last = get_column_letter(columns)\n    thin = Side(style=\"thin\", color=\"CDD5E6\")\n\n    ws[\"A1\"] = title\n    ws.merge_cells(f\"A1:{last}1\")\n    ws[\"A1\"].font = Font(size=15, bold=True, color=\"4338CA\")\n    ws[\"A1\"].alignment = Alignment(horizontal=\"center\", vertical=\"center\")\n    ws.row_dimensions[1].height = height\n    style_merged_range(\n        ws, f\"A1:{last}1\",\n        fill=PatternFill(\"solid\", start_color=\"EBEBFD\", end_color=\"EBEBFD\"),\n        border=Border(bottom=thin),\n    )\n\n    if subtitle:\n        ws[\"A2\"] = subtitle\n        ws.merge_cells(f\"A2:{last}2\")\n        ws[\"A2\"].font = Font(size=10, italic=True, color=\"5B6780\")\n        ws[\"A2\"].alignment = Alignment(horizontal=\"center\", vertical=\"center\")\n        ws.row_dimensions[2].height = 18\n\n    return ws\n\nwb = load_workbook(\"report.xlsx\")\nws = wb.active\nstamp = datetime.now(timezone.utc).strftime(\"%Y-%m-%d %H:%M UTC\")\nadd_banner(ws, \"Regional Revenue Report\",\n           f\"August 2026 · generated {stamp}\", columns=4)\nws.freeze_panes = \"A4\"\nwb.save(\"report_titled.xlsx\")\n",[194,1126,1127,1139,1149,1160,1171,1175,1206,1211,1221,1246,1250,1264,1290,1327,1356,1370,1375,1394,1423,1438,1443,1448,1457,1473,1495,1535,1564,1579,1584,1593,1598,1611,1620,1642,1653,1683,1694],{"__ignoreMap":192},[197,1128,1129,1131,1134,1136],{"class":199,"line":200},[197,1130,399],{"class":229},[197,1132,1133],{"class":233}," datetime ",[197,1135,230],{"class":229},[197,1137,1138],{"class":233}," datetime, timezone\n",[197,1140,1141,1143,1145,1147],{"class":199,"line":243},[197,1142,399],{"class":229},[197,1144,402],{"class":233},[197,1146,230],{"class":229},[197,1148,407],{"class":233},[197,1150,1151,1153,1155,1157],{"class":199,"line":250},[197,1152,399],{"class":229},[197,1154,414],{"class":233},[197,1156,230],{"class":229},[197,1158,1159],{"class":233}," Font, Alignment, PatternFill, Border, Side\n",[197,1161,1162,1164,1166,1168],{"class":199,"line":256},[197,1163,399],{"class":229},[197,1165,802],{"class":233},[197,1167,230],{"class":229},[197,1169,1170],{"class":233}," get_column_letter, range_boundaries\n",[197,1172,1173],{"class":199,"line":282},[197,1174,247],{"emptyLinePlaceholder":246},[197,1176,1177,1179,1182,1185,1187,1189,1192,1194,1196,1199,1201,1204],{"class":199,"line":306},[197,1178,816],{"class":229},[197,1180,1181],{"class":819}," add_banner",[197,1183,1184],{"class":233},"(ws, title, subtitle",[197,1186,366],{"class":229},[197,1188,828],{"class":290},[197,1190,1191],{"class":233},", columns",[197,1193,366],{"class":229},[197,1195,79],{"class":290},[197,1197,1198],{"class":233},", height",[197,1200,366],{"class":229},[197,1202,1203],{"class":290},"30",[197,1205,838],{"class":233},[197,1207,1208],{"class":199,"line":329},[197,1209,1210],{"class":207},"    \"\"\"Add a merged, centred title (and optional subtitle) above the data.\"\"\"\n",[197,1212,1213,1216,1218],{"class":199,"line":351},[197,1214,1215],{"class":233},"    last ",[197,1217,366],{"class":229},[197,1219,1220],{"class":233}," get_column_letter(columns)\n",[197,1222,1223,1226,1228,1230,1232,1234,1236,1238,1240,1242,1244],{"class":199,"line":480},[197,1224,1225],{"class":233},"    thin ",[197,1227,366],{"class":229},[197,1229,991],{"class":233},[197,1231,994],{"class":362},[197,1233,366],{"class":229},[197,1235,999],{"class":207},[197,1237,268],{"class":233},[197,1239,532],{"class":362},[197,1241,366],{"class":229},[197,1243,1008],{"class":207},[197,1245,382],{"class":233},[197,1247,1248],{"class":199,"line":485},[197,1249,247],{"emptyLinePlaceholder":246},[197,1251,1252,1255,1257,1259,1261],{"class":199,"line":501},[197,1253,1254],{"class":233},"    ws[",[197,1256,459],{"class":207},[197,1258,462],{"class":233},[197,1260,366],{"class":229},[197,1262,1263],{"class":233}," title\n",[197,1265,1266,1269,1272,1275,1279,1282,1285,1288],{"class":199,"line":542},[197,1267,1268],{"class":233},"    ws.merge_cells(",[197,1270,1271],{"class":229},"f",[197,1273,1274],{"class":207},"\"A1:",[197,1276,1278],{"class":1277},"sSjpA","{",[197,1280,1281],{"class":233},"last",[197,1283,1284],{"class":1277},"}",[197,1286,1287],{"class":207},"1\"",[197,1289,382],{"class":233},[197,1291,1292,1294,1296,1299,1301,1303,1305,1307,1309,1311,1313,1315,1317,1319,1321,1323,1325],{"class":199,"line":572},[197,1293,1254],{"class":233},[197,1295,459],{"class":207},[197,1297,1298],{"class":233},"].font ",[197,1300,366],{"class":229},[197,1302,509],{"class":233},[197,1304,512],{"class":362},[197,1306,366],{"class":229},[197,1308,517],{"class":290},[197,1310,268],{"class":233},[197,1312,522],{"class":362},[197,1314,366],{"class":229},[197,1316,527],{"class":290},[197,1318,268],{"class":233},[197,1320,532],{"class":362},[197,1322,366],{"class":229},[197,1324,537],{"class":207},[197,1326,382],{"class":233},[197,1328,1329,1331,1333,1336,1338,1340,1342,1344,1346,1348,1350,1352,1354],{"class":199,"line":607},[197,1330,1254],{"class":233},[197,1332,459],{"class":207},[197,1334,1335],{"class":233},"].alignment ",[197,1337,366],{"class":229},[197,1339,550],{"class":233},[197,1341,553],{"class":362},[197,1343,366],{"class":229},[197,1345,558],{"class":207},[197,1347,268],{"class":233},[197,1349,563],{"class":362},[197,1351,366],{"class":229},[197,1353,558],{"class":207},[197,1355,382],{"class":233},[197,1357,1358,1361,1363,1365,1367],{"class":199,"line":624},[197,1359,1360],{"class":233},"    ws.row_dimensions[",[197,1362,613],{"class":290},[197,1364,616],{"class":233},[197,1366,366],{"class":229},[197,1368,1369],{"class":233}," height\n",[197,1371,1372],{"class":199,"line":629},[197,1373,1374],{"class":233},"    style_merged_range(\n",[197,1376,1377,1380,1382,1384,1386,1388,1390,1392],{"class":199,"line":1018},[197,1378,1379],{"class":233},"        ws, ",[197,1381,1271],{"class":229},[197,1383,1274],{"class":207},[197,1385,1278],{"class":1277},[197,1387,1281],{"class":233},[197,1389,1284],{"class":1277},[197,1391,1287],{"class":207},[197,1393,1026],{"class":233},[197,1395,1396,1399,1401,1403,1405,1407,1409,1411,1413,1415,1417,1419,1421],{"class":199,"line":1029},[197,1397,1398],{"class":362},"        fill",[197,1400,366],{"class":229},[197,1402,1037],{"class":233},[197,1404,583],{"class":207},[197,1406,268],{"class":233},[197,1408,588],{"class":362},[197,1410,366],{"class":229},[197,1412,593],{"class":207},[197,1414,268],{"class":233},[197,1416,598],{"class":362},[197,1418,366],{"class":229},[197,1420,593],{"class":207},[197,1422,1058],{"class":233},[197,1424,1425,1428,1430,1432,1434,1436],{"class":199,"line":1061},[197,1426,1427],{"class":362},"        border",[197,1429,366],{"class":229},[197,1431,1069],{"class":233},[197,1433,1080],{"class":362},[197,1435,366],{"class":229},[197,1437,1099],{"class":233},[197,1439,1440],{"class":199,"line":1102},[197,1441,1442],{"class":233},"    )\n",[197,1444,1446],{"class":199,"line":1445},21,[197,1447,247],{"emptyLinePlaceholder":246},[197,1449,1451,1454],{"class":199,"line":1450},22,[197,1452,1453],{"class":229},"    if",[197,1455,1456],{"class":233}," subtitle:\n",[197,1458,1460,1463,1466,1468,1470],{"class":199,"line":1459},23,[197,1461,1462],{"class":233},"        ws[",[197,1464,1465],{"class":207},"\"A2\"",[197,1467,462],{"class":233},[197,1469,366],{"class":229},[197,1471,1472],{"class":233}," subtitle\n",[197,1474,1476,1479,1481,1484,1486,1488,1490,1493],{"class":199,"line":1475},24,[197,1477,1478],{"class":233},"        ws.merge_cells(",[197,1480,1271],{"class":229},[197,1482,1483],{"class":207},"\"A2:",[197,1485,1278],{"class":1277},[197,1487,1281],{"class":233},[197,1489,1284],{"class":1277},[197,1491,1492],{"class":207},"2\"",[197,1494,382],{"class":233},[197,1496,1498,1500,1502,1504,1506,1508,1510,1512,1515,1517,1520,1522,1524,1526,1528,1530,1533],{"class":199,"line":1497},25,[197,1499,1462],{"class":233},[197,1501,1465],{"class":207},[197,1503,1298],{"class":233},[197,1505,366],{"class":229},[197,1507,509],{"class":233},[197,1509,512],{"class":362},[197,1511,366],{"class":229},[197,1513,1514],{"class":290},"10",[197,1516,268],{"class":233},[197,1518,1519],{"class":362},"italic",[197,1521,366],{"class":229},[197,1523,527],{"class":290},[197,1525,268],{"class":233},[197,1527,532],{"class":362},[197,1529,366],{"class":229},[197,1531,1532],{"class":207},"\"5B6780\"",[197,1534,382],{"class":233},[197,1536,1538,1540,1542,1544,1546,1548,1550,1552,1554,1556,1558,1560,1562],{"class":199,"line":1537},26,[197,1539,1462],{"class":233},[197,1541,1465],{"class":207},[197,1543,1335],{"class":233},[197,1545,366],{"class":229},[197,1547,550],{"class":233},[197,1549,553],{"class":362},[197,1551,366],{"class":229},[197,1553,558],{"class":207},[197,1555,268],{"class":233},[197,1557,563],{"class":362},[197,1559,366],{"class":229},[197,1561,558],{"class":207},[197,1563,382],{"class":233},[197,1565,1567,1570,1572,1574,1576],{"class":199,"line":1566},27,[197,1568,1569],{"class":233},"        ws.row_dimensions[",[197,1571,379],{"class":290},[197,1573,616],{"class":233},[197,1575,366],{"class":229},[197,1577,1578],{"class":290}," 18\n",[197,1580,1582],{"class":199,"line":1581},28,[197,1583,247],{"emptyLinePlaceholder":246},[197,1585,1587,1590],{"class":199,"line":1586},29,[197,1588,1589],{"class":229},"    return",[197,1591,1592],{"class":233}," ws\n",[197,1594,1596],{"class":199,"line":1595},30,[197,1597,247],{"emptyLinePlaceholder":246},[197,1599,1601,1603,1605,1607,1609],{"class":199,"line":1600},31,[197,1602,428],{"class":233},[197,1604,366],{"class":229},[197,1606,433],{"class":233},[197,1608,357],{"class":207},[197,1610,382],{"class":233},[197,1612,1614,1616,1618],{"class":199,"line":1613},32,[197,1615,442],{"class":233},[197,1617,366],{"class":229},[197,1619,447],{"class":233},[197,1621,1623,1626,1628,1631,1634,1637,1640],{"class":199,"line":1622},33,[197,1624,1625],{"class":233},"stamp ",[197,1627,366],{"class":229},[197,1629,1630],{"class":233}," datetime.now(timezone.utc).strftime(",[197,1632,1633],{"class":207},"\"%Y-%m-",[197,1635,1636],{"class":1277},"%d",[197,1638,1639],{"class":207}," %H:%M UTC\"",[197,1641,382],{"class":233},[197,1643,1645,1648,1651],{"class":199,"line":1644},34,[197,1646,1647],{"class":233},"add_banner(ws, ",[197,1649,1650],{"class":207},"\"Regional Revenue Report\"",[197,1652,1026],{"class":233},[197,1654,1656,1659,1662,1664,1667,1669,1672,1674,1677,1679,1681],{"class":199,"line":1655},35,[197,1657,1658],{"class":229},"           f",[197,1660,1661],{"class":207},"\"August 2026 · generated ",[197,1663,1278],{"class":1277},[197,1665,1666],{"class":233},"stamp",[197,1668,1284],{"class":1277},[197,1670,1671],{"class":207},"\"",[197,1673,268],{"class":233},[197,1675,1676],{"class":362},"columns",[197,1678,366],{"class":229},[197,1680,79],{"class":290},[197,1682,382],{"class":233},[197,1684,1686,1689,1691],{"class":199,"line":1685},36,[197,1687,1688],{"class":233},"ws.freeze_panes ",[197,1690,366],{"class":229},[197,1692,1693],{"class":207}," \"A4\"\n",[197,1695,1697,1699,1701],{"class":199,"line":1696},37,[197,1698,632],{"class":233},[197,1700,635],{"class":207},[197,1702,382],{"class":233},[10,1704,1705,1708],{},[194,1706,1707],{},"freeze_panes = \"A4\""," keeps the banner and the column headers visible as the reader scrolls — a small thing that makes a long report noticeably easier to use. Putting the generation time in the subtitle is worth the line too: it is the first question anybody asks about a report they were forwarded.",[182,1710,1712],{"id":1711},"step-4-consider-centre-across-selection-instead","Step 4 — Consider centre-across-selection instead",[10,1714,1715],{},"Merging is right for a banner above the data. Inside a data region it causes real problems, and there is an alternative that looks identical and causes none.",[20,1717,30,1724,30,1727,30,1730,30,1735,30,1738,30,1743,30,1747,30,1752,30,1756,30,1760,30,1764,30,1768,30,1771,30,1776,30,1779,30,1782,30,1785,30,1787,30,1790,30,1793,30,1796],{"viewBox":1718,"role":23,"ariaLabel":1719,"ariaLabelledBy":1720,"xmlns":28,"style":1723},"0 0 800 240","Merged cells versus centre across selection: both look centred, but merging breaks sorting, filtering and pandas reads while centre across selection leaves the cells independent.",[1721,1722],"cas-t","cas-d","width:100%;max-width:800px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[32,1725,1726],{"id":1721},"Two ways to centre text over several columns",[36,1728,1729],{"id":1722},"Merging cells produces the visual result but genuinely combines the cells, which breaks sorting, filtering and pivot tables, and makes pandas read one value followed by a run of blanks. Centre across selection is purely an alignment setting: the text is drawn centred over the range while every cell remains independent, so all of those operations keep working. Visually a reader cannot tell them apart.",[40,1731],{"x":1732,"y":1732,"width":1733,"height":1734,"fill":46},"0","800","240",[40,1736],{"x":43,"y":42,"width":1737,"height":45,"rx":43,"fill":145,"stroke":131,"style":56},"368",[58,1739,1742],{"x":1740,"y":135,"style":1741},"198","font-size:12.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","merge_cells",[40,1744],{"x":1745,"y":165,"width":1746,"height":1203,"rx":53,"fill":46,"stroke":131,"style":56},"44","308",[58,1748,1751],{"x":1740,"y":1749,"style":1750},"86","font-size:11px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Q3 Summary",[58,1753,1755],{"x":1740,"y":77,"style":1754},"font-size:11px;fill:var(--text,#172033);text-anchor:middle","the cells really are combined",[58,1757,1759],{"x":1740,"y":1758,"style":72},"146","sorting and filtering break",[58,1761,1763],{"x":1740,"y":1762,"style":72},"168","pivot tables refuse the range",[58,1765,1767],{"x":1740,"y":45,"style":1766},"font-size:11px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","pandas reads value + blanks",[40,1769],{"x":1770,"y":42,"width":1737,"height":45,"rx":43,"fill":80,"stroke":81,"style":56},"418",[58,1772,1775],{"x":1773,"y":135,"style":1774},"602","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","centre across selection",[40,1777],{"x":1778,"y":165,"width":84,"height":1203,"rx":53,"fill":46,"stroke":81},"448",[40,1780],{"x":1781,"y":165,"width":84,"height":1203,"rx":53,"fill":46,"stroke":81},"552",[40,1783],{"x":1784,"y":165,"width":84,"height":1203,"rx":53,"fill":46,"stroke":81},"656",[58,1786,1751],{"x":1773,"y":1749,"style":1750},[58,1788,1789],{"x":1773,"y":77,"style":1754},"only an alignment setting",[58,1791,1792],{"x":1773,"y":1758,"style":72},"sorting and filtering work",[58,1794,1795],{"x":1773,"y":1762,"style":72},"pivot tables accept the range",[58,1797,1799],{"x":1773,"y":45,"style":1798},"font-size:11px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","visually identical to a reader",[187,1801,1803],{"className":220,"code":1802,"language":222,"meta":192,"style":192},"from openpyxl.styles import Alignment\n\nws[\"A1\"] = \"Regional Revenue Report\"\n# No merge. The text is drawn centred across A1:D1.\nfor row in ws[\"A1:D1\"]:\n    for cell in row:\n        cell.alignment = Alignment(horizontal=\"centerContinuous\")\n",[194,1804,1805,1816,1820,1832,1837,1851,1861],{"__ignoreMap":192},[197,1806,1807,1809,1811,1813],{"class":199,"line":200},[197,1808,399],{"class":229},[197,1810,414],{"class":233},[197,1812,230],{"class":229},[197,1814,1815],{"class":233}," Alignment\n",[197,1817,1818],{"class":199,"line":243},[197,1819,247],{"emptyLinePlaceholder":246},[197,1821,1822,1824,1826,1828,1830],{"class":199,"line":250},[197,1823,456],{"class":233},[197,1825,459],{"class":207},[197,1827,462],{"class":233},[197,1829,366],{"class":229},[197,1831,467],{"class":207},[197,1833,1834],{"class":199,"line":256},[197,1835,1836],{"class":683},"# No merge. The text is drawn centred across A1:D1.\n",[197,1838,1839,1841,1843,1845,1847,1849],{"class":199,"line":282},[197,1840,717],{"class":229},[197,1842,720],{"class":233},[197,1844,723],{"class":229},[197,1846,493],{"class":233},[197,1848,475],{"class":207},[197,1850,730],{"class":233},[197,1852,1853,1855,1857,1859],{"class":199,"line":306},[197,1854,735],{"class":229},[197,1856,738],{"class":233},[197,1858,723],{"class":229},[197,1860,743],{"class":233},[197,1862,1863,1866,1868,1870,1872,1874,1877],{"class":199,"line":329},[197,1864,1865],{"class":233},"        cell.alignment ",[197,1867,366],{"class":229},[197,1869,550],{"class":233},[197,1871,553],{"class":362},[197,1873,366],{"class":229},[197,1875,1876],{"class":207},"\"centerContinuous\"",[197,1878,382],{"class":233},[10,1880,1881,1884,1885,18],{},[194,1882,1883],{},"centerContinuous"," centres the anchor's text across every adjacent cell that also carries the setting, stopping at the first cell that does not. The cells stay independent, so a reader can sort, filter and pivot the sheet normally — and pandas reads it without the blank-run problem described in ",[14,1886,1888],{"href":1887},"\u002Fgetting-started-with-python-excel-automation\u002Freading-excel-files-with-pandas\u002Fhandle-merged-cells-when-reading-excel-with-pandas\u002F","handling merged cells when reading Excel",[10,1890,1891,1892,1894],{},"Use merging for a title above the data, where nothing will ever sort it. Use ",[194,1893,1883],{}," anywhere a merge would sit inside or beside a data region.",[182,1896,1898],{"id":1897},"step-5-unmerge","Step 5 — Unmerge",[20,1900,30,1906,30,1909,30,1912,30,1915,30,1919,30,1922,30,1927,30,1930,30,1934,30,1938,30,1940,30,1944,30,1948,30,1951,30,1955],{"viewBox":1901,"role":23,"ariaLabel":1902,"ariaLabelledBy":1903,"xmlns":28,"style":1723},"0 0 800 224","What a merged cell accepts: the anchor takes values and styles, while the placeholders accept styles but reject any value assignment.",[1904,1905],"mc-t","mc-d",[32,1907,1908],{"id":1904},"The anchor takes values; the placeholders take only styles",[36,1910,1911],{"id":1905},"A merged range from A1 to D1. The anchor cell A1 is a real cell and accepts both a value and styling. Cells B1, C1 and D1 are MergedCell placeholders: assigning a fill or a border to them works and is in fact required for the border to span the range, but assigning a value raises an AttributeError because the value attribute is read-only.",[40,1913],{"x":1732,"y":1732,"width":1733,"height":1914,"fill":46},"224",[40,1916],{"x":43,"y":1917,"width":1737,"height":1918,"rx":43,"fill":80,"stroke":81,"style":56},"42","160",[58,1920,1921],{"x":1740,"y":143,"style":1774},"A1 — the anchor",[58,1923,1926],{"x":1740,"y":1924,"style":1925},"102","font-size:11.5px;fill:var(--text,#172033);text-anchor:middle","cell.value = \"Report title\"",[58,1928,1929],{"x":1740,"y":164,"style":1925},"cell.font, cell.fill, cell.border",[58,1931,1933],{"x":1740,"y":1932,"style":1798},"158","a real cell — everything works",[58,1935,1937],{"x":1740,"y":1936,"style":160},"182","its value displays across the range",[40,1939],{"x":1770,"y":1917,"width":1737,"height":1918,"rx":43,"fill":166,"stroke":167,"style":56},[58,1941,1943],{"x":1773,"y":143,"style":1942},"font-size:12.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","B1, C1, D1 — placeholders",[58,1945,1947],{"x":1773,"y":1924,"style":1946},"font-size:11.5px;fill:var(--accent-ink,#be185d);text-anchor:middle","cell.value = … raises",[58,1949,1950],{"x":1773,"y":164,"style":1925},"cell.fill, cell.border work",[58,1952,1954],{"x":1773,"y":1932,"style":1953},"font-size:11px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","and styling them is required",[58,1956,1957],{"x":1773,"y":1936,"style":160},"or the border stops partway across",[10,1959,1960],{},"Removing a merge takes the same reference:",[187,1962,1964],{"className":220,"code":1963,"language":222,"meta":192,"style":192},"from openpyxl import load_workbook\n\nwb = load_workbook(\"report_titled.xlsx\")\nws = wb.active\n\nws.unmerge_cells(\"A1:D1\")\nprint([str(r) for r in ws.merged_cells.ranges])\n",[194,1965,1966,1976,1980,1992,2000,2004,2013],{"__ignoreMap":192},[197,1967,1968,1970,1972,1974],{"class":199,"line":200},[197,1969,399],{"class":229},[197,1971,402],{"class":233},[197,1973,230],{"class":229},[197,1975,407],{"class":233},[197,1977,1978],{"class":199,"line":243},[197,1979,247],{"emptyLinePlaceholder":246},[197,1981,1982,1984,1986,1988,1990],{"class":199,"line":250},[197,1983,428],{"class":233},[197,1985,366],{"class":229},[197,1987,433],{"class":233},[197,1989,635],{"class":207},[197,1991,382],{"class":233},[197,1993,1994,1996,1998],{"class":199,"line":256},[197,1995,442],{"class":233},[197,1997,366],{"class":229},[197,1999,447],{"class":233},[197,2001,2002],{"class":199,"line":282},[197,2003,247],{"emptyLinePlaceholder":246},[197,2005,2006,2009,2011],{"class":199,"line":306},[197,2007,2008],{"class":233},"ws.unmerge_cells(",[197,2010,475],{"class":207},[197,2012,382],{"class":233},[197,2014,2015,2018,2021,2024,2027,2029,2032,2034],{"class":199,"line":329},[197,2016,2017],{"class":290},"print",[197,2019,2020],{"class":233},"([",[197,2022,2023],{"class":290},"str",[197,2025,2026],{"class":233},"(r) ",[197,2028,717],{"class":229},[197,2030,2031],{"class":233}," r ",[197,2033,723],{"class":229},[197,2035,2036],{"class":233}," ws.merged_cells.ranges])\n",[10,2038,2039],{},"The anchor keeps its value and the former placeholders become ordinary empty cells. When you are flattening a whole sheet rather than one range, snapshot the ranges before iterating — unmerging mutates the collection you would otherwise be looping over:",[187,2041,2043],{"className":220,"code":2042,"language":222,"meta":192,"style":192},"for ref in [str(r) for r in ws.merged_cells.ranges]:\n    ws.unmerge_cells(ref)\n",[194,2044,2045,2070],{"__ignoreMap":192},[197,2046,2047,2049,2052,2054,2057,2059,2061,2063,2065,2067],{"class":199,"line":200},[197,2048,717],{"class":229},[197,2050,2051],{"class":233}," ref ",[197,2053,723],{"class":229},[197,2055,2056],{"class":233}," [",[197,2058,2023],{"class":290},[197,2060,2026],{"class":233},[197,2062,717],{"class":229},[197,2064,2031],{"class":233},[197,2066,723],{"class":229},[197,2068,2069],{"class":233}," ws.merged_cells.ranges]:\n",[197,2071,2072],{"class":199,"line":243},[197,2073,2074],{"class":233},"    ws.unmerge_cells(ref)\n",[182,2076,2078],{"id":2077},"common-pitfalls-and-fixes","Common pitfalls and fixes",[2080,2081,2082,2098],"table",{},[2083,2084,2085],"thead",{},[2086,2087,2088,2092,2095],"tr",{},[2089,2090,2091],"th",{},"Symptom",[2089,2093,2094],{},"Cause",[2089,2096,2097],{},"Fix",[2099,2100,2101,2115,2126,2137,2151,2165,2179,2193],"tbody",{},[2086,2102,2103,2109,2112],{},[2104,2105,2106],"td",{},[194,2107,2108],{},"AttributeError: 'MergedCell' ... read-only",[2104,2110,2111],{},"Writing to a non-anchor cell",[2104,2113,2114],{},"Write to the top-left cell only.",[2086,2116,2117,2120,2123],{},[2104,2118,2119],{},"Border stops a quarter of the way across",[2104,2121,2122],{},"Border set on the anchor only",[2104,2124,2125],{},"Apply it to every cell in the range.",[2086,2127,2128,2131,2134],{},[2104,2129,2130],{},"Title value disappeared after merging",[2104,2132,2133],{},"Value was not in the anchor",[2104,2135,2136],{},"Put it in the top-left cell.",[2086,2138,2139,2142,2145],{},[2104,2140,2141],{},"Sorting stopped working",[2104,2143,2144],{},"Merge inside the data region",[2104,2146,2147,2148,2150],{},"Use ",[194,2149,1883],{}," instead.",[2086,2152,2153,2156,2159],{},[2104,2154,2155],{},"pandas reads blanks after the title",[2104,2157,2158],{},"Merged range read normally",[2104,2160,2161,2162,18],{},"Skip the banner rows with ",[194,2163,2164],{},"skiprows",[2086,2166,2167,2173,2176],{},[2104,2168,2169,2172],{},[194,2170,2171],{},"RuntimeError"," while unmerging in a loop",[2104,2174,2175],{},"Iterating the live ranges",[2104,2177,2178],{},"Snapshot the refs into a list first.",[2086,2180,2181,2184,2187],{},[2104,2182,2183],{},"Title cut off vertically",[2104,2185,2186],{},"Row height still the default",[2104,2188,2189,2190,18],{},"Set ",[194,2191,2192],{},"row_dimensions[1].height",[2086,2194,2195,2198,2201],{},[2104,2196,2197],{},"Banner scrolls out of view",[2104,2199,2200],{},"No frozen panes",[2104,2202,2203,18],{},[194,2204,2205],{},"ws.freeze_panes = \"A4\"",[182,2207,2209],{"id":2208},"performance-and-scale-notes","Performance and scale notes",[10,2211,2212],{},"Merging is cheap — a merged range is one entry in the sheet's range collection, regardless of how many cells it spans. The costs are elsewhere.",[10,2214,2215,2218,2219,2222],{},[641,2216,2217],{},"Styling the range is per-cell."," ",[194,2220,2221],{},"style_merged_range"," touches every cell, so a banner across ten columns costs ten style assignments. That is nothing for a title; it would matter if you merged thousands of ranges, which is itself a sign the design is wrong.",[10,2224,2225,2228,2229,2231],{},[641,2226,2227],{},"Merges slow Excel's own operations."," A sheet with many merged ranges is noticeably slower to scroll, sort and recalculate, because Excel checks the merge collection for every affected cell. A report with a handful of banner merges is fine; one with a merge per group heading across ten thousand rows is not — use ",[194,2230,1883],{},", or repeat the group value on every row and let a pivot do the grouping.",[10,2233,2234,2241,2242,2246],{},[641,2235,2236,2237,2240],{},"Merges cannot be created in ",[194,2238,2239],{},"write_only"," mode."," Streaming writes emit rows as they go and never hold the sheet, so a large report needing both streaming and a banner has to be written in two passes: stream the data with the approach in ",[14,2243,2245],{"href":2244},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-large-excel-files-in-python\u002Fwrite-large-dataframes-to-excel-with-write-only-mode\u002F","writing large DataFrames with write-only mode",", then re-open it normally to add the banner. Since the banner touches only the first two rows, that second pass is the cheapest part of the job.",[10,2248,2249],{},"If you are creating a report from scratch rather than modifying one, the xlsxwriter equivalent is a single call and slightly faster:",[187,2251,2253],{"className":220,"code":2252,"language":222,"meta":192,"style":192},"import pandas as pd\n\nwith pd.ExcelWriter(\"report.xlsx\", engine=\"xlsxwriter\") as writer:\n    df.to_excel(writer, sheet_name=\"Report\", index=False, startrow=2)\n    book, sheet = writer.book, writer.sheets[\"Report\"]\n\n    title = book.add_format({\n        \"bold\": True, \"font_size\": 15, \"font_color\": \"#4338CA\",\n        \"bg_color\": \"#EBEBFD\", \"align\": \"center\", \"valign\": \"vcenter\",\n        \"bottom\": 1, \"border_color\": \"#CDD5E6\",\n    })\n    sheet.merge_range(\"A1:D1\", \"Regional Revenue Report\", title)\n    sheet.set_row(0, 30)\n    sheet.freeze_panes(3, 0)\n",[194,2254,2255,2265,2269,2297,2328,2342,2346,2356,2387,2418,2439,2444,2458,2471],{"__ignoreMap":192},[197,2256,2257,2259,2261,2263],{"class":199,"line":200},[197,2258,230],{"class":229},[197,2260,234],{"class":233},[197,2262,237],{"class":229},[197,2264,240],{"class":233},[197,2266,2267],{"class":199,"line":243},[197,2268,247],{"emptyLinePlaceholder":246},[197,2270,2271,2274,2277,2279,2281,2284,2286,2289,2292,2294],{"class":199,"line":250},[197,2272,2273],{"class":229},"with",[197,2275,2276],{"class":233}," pd.ExcelWriter(",[197,2278,357],{"class":207},[197,2280,268],{"class":233},[197,2282,2283],{"class":362},"engine",[197,2285,366],{"class":229},[197,2287,2288],{"class":207},"\"xlsxwriter\"",[197,2290,2291],{"class":233},") ",[197,2293,237],{"class":229},[197,2295,2296],{"class":233}," writer:\n",[197,2298,2299,2302,2305,2307,2310,2312,2314,2316,2318,2320,2322,2324,2326],{"class":199,"line":256},[197,2300,2301],{"class":233},"    df.to_excel(writer, ",[197,2303,2304],{"class":362},"sheet_name",[197,2306,366],{"class":229},[197,2308,2309],{"class":207},"\"Report\"",[197,2311,268],{"class":233},[197,2313,363],{"class":362},[197,2315,366],{"class":229},[197,2317,369],{"class":290},[197,2319,268],{"class":233},[197,2321,374],{"class":362},[197,2323,366],{"class":229},[197,2325,379],{"class":290},[197,2327,382],{"class":233},[197,2329,2330,2333,2335,2338,2340],{"class":199,"line":282},[197,2331,2332],{"class":233},"    book, sheet ",[197,2334,366],{"class":229},[197,2336,2337],{"class":233}," writer.book, writer.sheets[",[197,2339,2309],{"class":207},[197,2341,498],{"class":233},[197,2343,2344],{"class":199,"line":306},[197,2345,247],{"emptyLinePlaceholder":246},[197,2347,2348,2351,2353],{"class":199,"line":329},[197,2349,2350],{"class":233},"    title ",[197,2352,366],{"class":229},[197,2354,2355],{"class":233}," book.add_format({\n",[197,2357,2358,2361,2364,2366,2368,2371,2373,2375,2377,2380,2382,2385],{"class":199,"line":351},[197,2359,2360],{"class":207},"        \"bold\"",[197,2362,2363],{"class":233},": ",[197,2365,527],{"class":290},[197,2367,268],{"class":233},[197,2369,2370],{"class":207},"\"font_size\"",[197,2372,2363],{"class":233},[197,2374,517],{"class":290},[197,2376,268],{"class":233},[197,2378,2379],{"class":207},"\"font_color\"",[197,2381,2363],{"class":233},[197,2383,2384],{"class":207},"\"#4338CA\"",[197,2386,1026],{"class":233},[197,2388,2389,2392,2394,2397,2399,2402,2404,2406,2408,2411,2413,2416],{"class":199,"line":480},[197,2390,2391],{"class":207},"        \"bg_color\"",[197,2393,2363],{"class":233},[197,2395,2396],{"class":207},"\"#EBEBFD\"",[197,2398,268],{"class":233},[197,2400,2401],{"class":207},"\"align\"",[197,2403,2363],{"class":233},[197,2405,558],{"class":207},[197,2407,268],{"class":233},[197,2409,2410],{"class":207},"\"valign\"",[197,2412,2363],{"class":233},[197,2414,2415],{"class":207},"\"vcenter\"",[197,2417,1026],{"class":233},[197,2419,2420,2423,2425,2427,2429,2432,2434,2437],{"class":199,"line":485},[197,2421,2422],{"class":207},"        \"bottom\"",[197,2424,2363],{"class":233},[197,2426,613],{"class":290},[197,2428,268],{"class":233},[197,2430,2431],{"class":207},"\"border_color\"",[197,2433,2363],{"class":233},[197,2435,2436],{"class":207},"\"#CDD5E6\"",[197,2438,1026],{"class":233},[197,2440,2441],{"class":199,"line":501},[197,2442,2443],{"class":233},"    })\n",[197,2445,2446,2449,2451,2453,2455],{"class":199,"line":542},[197,2447,2448],{"class":233},"    sheet.merge_range(",[197,2450,475],{"class":207},[197,2452,268],{"class":233},[197,2454,1650],{"class":207},[197,2456,2457],{"class":233},", title)\n",[197,2459,2460,2463,2465,2467,2469],{"class":199,"line":572},[197,2461,2462],{"class":233},"    sheet.set_row(",[197,2464,1732],{"class":290},[197,2466,268],{"class":233},[197,2468,1203],{"class":290},[197,2470,382],{"class":233},[197,2472,2473,2476,2479,2481,2483],{"class":199,"line":607},[197,2474,2475],{"class":233},"    sheet.freeze_panes(",[197,2477,2478],{"class":290},"3",[197,2480,268],{"class":233},[197,2482,1732],{"class":290},[197,2484,382],{"class":233},[10,2486,2487,2490],{},[194,2488,2489],{},"merge_range"," writes the value, merges and applies the format across the whole range in one call — including the border, which is the part openpyxl makes you do by hand.",[182,2492,2494],{"id":2493},"conclusion","Conclusion",[10,2496,2497,2498,2500,2501,2503],{},"A merged title banner is ",[194,2499,1742],{}," plus an alignment on the anchor, with two behaviours to remember: only the top-left cell accepts a value, and borders must be applied to every cell of the range or they stop partway across. Set the row height and freeze the panes so the banner stays visible. And keep merges above the data, never inside it — where you want the look without the consequences, ",[194,2502,1883],{}," centres text across columns while leaving every cell independent, so sorting, filtering and pandas all keep working.",[182,2505,2507],{"id":2506},"frequently-asked-questions","Frequently asked questions",[10,2509,2510,2517,2518,2520],{},[641,2511,2512,2513,2516],{},"Why does writing to a merged cell raise ",[194,2514,2515],{},"AttributeError","?","\nOnly the top-left cell of a merged range is a real cell; the rest are ",[194,2519,690],{}," objects that are read-only. Write to the anchor cell — the top-left one — and the value displays across the whole range.",[10,2522,2523,2526],{},[641,2524,2525],{},"How do I style the whole merged range?","\nSet the value and font on the anchor cell, but apply borders and fills to every cell in the range. A border set only on the anchor draws around that one cell's original bounds, leaving the rest of the merge unbordered.",[10,2528,2529,2532],{},[641,2530,2531],{},"Should I merge cells at all?","\nFor a title banner, yes — it is what readers expect. For anything inside a data region, no: merged cells break sorting, filtering and pivot tables, and they read back into pandas as one value plus a run of blanks.",[10,2534,2535,2538],{},[641,2536,2537],{},"What is centre across selection?","\nAn alignment option that visually centres text over several columns without actually merging them. It looks the same to a reader and leaves the cells independent, so sorting and filtering keep working.",[10,2540,2541,2544,2545,2548],{},[641,2542,2543],{},"How do I remove a merge?","\nCall ",[194,2546,2547],{},"unmerge_cells"," with the same range reference. The anchor keeps the value and the other cells become ordinary empty cells, so you may want to fill them afterwards.",[182,2550,2552],{"id":2551},"related","Related",[2554,2555,2556,2563,2570,2576,2583],"ul",{},[2557,2558,2559,2560,2562],"li",{},"Up to the parent: ",[14,2561,17],{"href":16}," — fonts, fills and borders in depth.",[2557,2564,2565,2569],{},[14,2566,2568],{"href":2567},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Fapply-a-reusable-style-theme-across-an-excel-report\u002F","Apply a Reusable Style Theme Across an Excel Report"," — where the banner styles belong.",[2557,2571,2572,2575],{},[14,2573,2574],{"href":1887},"Handle Merged Cells When Reading Excel with pandas"," — the reading-side consequences.",[2557,2577,2578,2582],{},[14,2579,2581],{"href":2580},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Ffreeze-header-row-in-excel-with-openpyxl\u002F","Freeze the Header Row in Excel with openpyxl"," — keeping the banner in view.",[2557,2584,2585,2589],{},[14,2586,2588],{"href":2587},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Fset-column-width-and-row-height-in-openpyxl\u002F","Set Column Width and Row Height in openpyxl"," — sizing the banner row.",[994,2591,2592],{},"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 .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}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":192,"searchDepth":243,"depth":243,"links":2594},[2595,2596,2597,2598,2599,2600,2601,2602,2603,2604,2605],{"id":184,"depth":243,"text":185},{"id":385,"depth":243,"text":386},{"id":775,"depth":243,"text":776},{"id":1117,"depth":243,"text":1118},{"id":1711,"depth":243,"text":1712},{"id":1897,"depth":243,"text":1898},{"id":2077,"depth":243,"text":2078},{"id":2208,"depth":243,"text":2209},{"id":2493,"depth":243,"text":2494},{"id":2506,"depth":243,"text":2507},{"id":2551,"depth":243,"text":2552},"2026-08-15","Add a merged, centred title banner to a generated report — merge_cells, styling the anchor cell, the MergedCell read-only trap, and why centre-across-selection is often better.","md",[2610,2613,2615,2619,2621],{"q":2611,"a":2612},"Why does writing to a merged cell raise AttributeError?","Only the top-left cell of a merged range is a real cell; the rest are MergedCell objects that are read-only. Write to the anchor cell — the top-left one — and the value displays across the whole range.",{"q":2525,"a":2614},"Set the value and font on the anchor cell, but apply borders and fills to every cell in the range. A border set only on the anchor draws around that one cell's original bounds, leaving the rest of the merge unbordered.",{"q":2531,"a":2616},{"For a title banner, yes — it is what readers expect":2617},{" For anything inside a data region, no":2618},"merged cells break sorting, filtering and pivot tables, and they read back into pandas as one value plus a run of blanks.",{"q":2537,"a":2620},"An alignment option that visually centres text over several columns without actually merging them. It looks the same to a reader and leaves the cells independent, so sorting and filtering keep working.",{"q":2543,"a":2622},"Call unmerge_cells with the same range reference. The anchor keeps the value and the other cells become ordinary empty cells, so you may want to fill them afterwards.",{},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Fmerge-cells-and-centre-a-report-title-with-openpyxl",{"title":2626,"description":2627},"Merge Cells and Centre a Title in openpyxl (Python)","Build a report title banner in Python: merge_cells and unmerge_cells, styling the top-left anchor, borders across a merged range, and the centre-across-selection alternative.","merge-cells-and-centre-a-report-title-with-openpyxl","formatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Fmerge-cells-and-centre-a-report-title-with-openpyxl\u002Findex","how-to","DzjA-xPaBVhIl7LMTihr_CIWqBc3vLBf3S8cuYGxlUc",[2633,2636],{"title":2581,"path":2634,"stem":2635,"children":-1},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Ffreeze-header-row-in-excel-with-openpyxl","formatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Ffreeze-header-row-in-excel-with-openpyxl\u002Findex",{"title":2588,"path":2637,"stem":2638,"children":-1},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Fset-column-width-and-row-height-in-openpyxl","formatting-and-charting-excel-reports-with-python\u002Fstyling-excel-cells-with-openpyxl\u002Fset-column-width-and-row-height-in-openpyxl\u002Findex",1786800028646]