[{"data":1,"prerenderedAt":1854},["ShallowReactive",2],{"doc:\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-charts-in-excel-with-openpyxl\u002Fuse-multi-level-category-labels-in-an-excel-chart":3,"surround:\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-charts-in-excel-with-openpyxl\u002Fuse-multi-level-category-labels-in-an-excel-chart":1821,"ancestors:\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-charts-in-excel-with-openpyxl\u002Fuse-multi-level-category-labels-in-an-excel-chart":1829,"children:\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-charts-in-excel-with-openpyxl\u002Fuse-multi-level-category-labels-in-an-excel-chart":1834,"related:\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-charts-in-excel-with-openpyxl\u002Fuse-multi-level-category-labels-in-an-excel-chart":1835},{"id":4,"title":5,"body":6,"dateModified":1787,"datePublished":1787,"description":1788,"extension":1789,"faq":1790,"keyword":1799,"kind":1800,"meta":1801,"navigation":105,"path":1810,"seo":1811,"slug":1814,"stem":1815,"topics":1816,"type":1819,"__hash__":1820},"docs\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-charts-in-excel-with-openpyxl\u002Fuse-multi-level-category-labels-in-an-excel-chart\u002Findex.md","Use Multi-Level Category Labels in an Excel Chart",{"type":7,"value":8,"toc":1774},"minimark",[9,36,41,70,73,77,349,369,631,635,1057,1079,1083,1326,1439,1448,1525,1529,1546,1550,1561,1565,1568,1644,1648,1687,1691,1698,1702,1716,1722,1731,1737,1741,1770],[10,11,12,13,17,18,21,22,25,26,29,30,35],"p",{},"A column chart of seven quarters labelled ",[14,15,16],"em",{},"2025 Q1, 2025 Q2, … 2026 Q3"," crowds the axis with repeated years and makes the reader parse every label. Excel can do better: a ",[14,19,20],{},"multi-level"," category axis shows the quarters along the bottom and groups them under a second row of labels, one ",[14,23,24],{},"2025"," centred under the first four bars and one ",[14,27,28],{},"2026"," under the rest, with divider lines between the groups. The same layout works for months under quarters, products under regions or weeks under months. Excel builds this automatically when the chart's categories span two or more columns laid out in a particular way, and openpyxl can write such a chart — but the layout of the source cells matters, and one common shortcut silently breaks it. This page builds a two-level axis from a pandas table, shows what goes wrong with repeated labels, and gives the XlsxWriter version. It belongs to ",[31,32,34],"a",{"href":33},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-charts-in-excel-with-openpyxl\u002F","Creating Charts in Excel with openpyxl",".",[37,38,40],"h2",{"id":39},"prerequisites","Prerequisites",[42,43,48],"pre",{"className":44,"code":45,"language":46,"meta":47,"style":47},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install openpyxl pandas\n","bash","",[49,50,51],"code",{"__ignoreMap":47},[52,53,56,60,64,67],"span",{"class":54,"line":55},"line",1,[52,57,59],{"class":58},"sMTad","pip",[52,61,63],{"class":62},"srMev"," install",[52,65,66],{"class":62}," openpyxl",[52,68,69],{"class":62}," pandas\n",[10,71,72],{},"LibreOffice is used to render the charts for checking; it draws multi-level axes the same way Excel does.",[37,74,76],{"id":75},"step-1-lay-out-the-label-columns-with-blanks","Step 1 — Lay out the label columns with blanks",[42,78,82],{"className":79,"code":80,"language":81,"meta":47,"style":47},"language-python shiki shiki-themes github-light github-dark-high-contrast","import pandas as pd\n\ndf = pd.DataFrame({\n    \"Year\": [2025] * 4 + [2026] * 3,\n    \"Quarter\": [\"Q1\", \"Q2\", \"Q3\", \"Q4\", \"Q1\", \"Q2\", \"Q3\"],\n    \"Revenue\": [120, 135, 128, 150, 141, 158, 149],\n    \"Costs\": [96, 104, 101, 112, 108, 117, 113],\n})\nlabels = df.assign(Year=df[\"Year\"].astype(\"string\").mask(df[\"Year\"].duplicated()))\nprint(labels.to_string(index=False))\n","python",[49,83,84,100,107,119,158,200,243,286,292,329],{"__ignoreMap":47},[52,85,86,90,94,97],{"class":54,"line":55},[52,87,89],{"class":88},"s-kum","import",[52,91,93],{"class":92},"skGVy"," pandas ",[52,95,96],{"class":88},"as",[52,98,99],{"class":92}," pd\n",[52,101,103],{"class":54,"line":102},2,[52,104,106],{"emptyLinePlaceholder":105},true,"\n",[52,108,110,113,116],{"class":54,"line":109},3,[52,111,112],{"class":92},"df ",[52,114,115],{"class":88},"=",[52,117,118],{"class":92}," pd.DataFrame({\n",[52,120,122,125,128,131,134,137,140,143,146,148,150,152,155],{"class":54,"line":121},4,[52,123,124],{"class":62},"    \"Year\"",[52,126,127],{"class":92},": [",[52,129,24],{"class":130},"sP0c6",[52,132,133],{"class":92},"] ",[52,135,136],{"class":88},"*",[52,138,139],{"class":130}," 4",[52,141,142],{"class":88}," +",[52,144,145],{"class":92}," [",[52,147,28],{"class":130},[52,149,133],{"class":92},[52,151,136],{"class":88},[52,153,154],{"class":130}," 3",[52,156,157],{"class":92},",\n",[52,159,161,164,166,169,172,175,177,180,182,185,187,189,191,193,195,197],{"class":54,"line":160},5,[52,162,163],{"class":62},"    \"Quarter\"",[52,165,127],{"class":92},[52,167,168],{"class":62},"\"Q1\"",[52,170,171],{"class":92},", ",[52,173,174],{"class":62},"\"Q2\"",[52,176,171],{"class":92},[52,178,179],{"class":62},"\"Q3\"",[52,181,171],{"class":92},[52,183,184],{"class":62},"\"Q4\"",[52,186,171],{"class":92},[52,188,168],{"class":62},[52,190,171],{"class":92},[52,192,174],{"class":62},[52,194,171],{"class":92},[52,196,179],{"class":62},[52,198,199],{"class":92},"],\n",[52,201,203,206,208,211,213,216,218,221,223,226,228,231,233,236,238,241],{"class":54,"line":202},6,[52,204,205],{"class":62},"    \"Revenue\"",[52,207,127],{"class":92},[52,209,210],{"class":130},"120",[52,212,171],{"class":92},[52,214,215],{"class":130},"135",[52,217,171],{"class":92},[52,219,220],{"class":130},"128",[52,222,171],{"class":92},[52,224,225],{"class":130},"150",[52,227,171],{"class":92},[52,229,230],{"class":130},"141",[52,232,171],{"class":92},[52,234,235],{"class":130},"158",[52,237,171],{"class":92},[52,239,240],{"class":130},"149",[52,242,199],{"class":92},[52,244,246,249,251,254,256,259,261,264,266,269,271,274,276,279,281,284],{"class":54,"line":245},7,[52,247,248],{"class":62},"    \"Costs\"",[52,250,127],{"class":92},[52,252,253],{"class":130},"96",[52,255,171],{"class":92},[52,257,258],{"class":130},"104",[52,260,171],{"class":92},[52,262,263],{"class":130},"101",[52,265,171],{"class":92},[52,267,268],{"class":130},"112",[52,270,171],{"class":92},[52,272,273],{"class":130},"108",[52,275,171],{"class":92},[52,277,278],{"class":130},"117",[52,280,171],{"class":92},[52,282,283],{"class":130},"113",[52,285,199],{"class":92},[52,287,289],{"class":54,"line":288},8,[52,290,291],{"class":92},"})\n",[52,293,295,298,300,303,307,309,312,315,318,321,324,326],{"class":54,"line":294},9,[52,296,297],{"class":92},"labels ",[52,299,115],{"class":88},[52,301,302],{"class":92}," df.assign(",[52,304,306],{"class":305},"sa561","Year",[52,308,115],{"class":88},[52,310,311],{"class":92},"df[",[52,313,314],{"class":62},"\"Year\"",[52,316,317],{"class":92},"].astype(",[52,319,320],{"class":62},"\"string\"",[52,322,323],{"class":92},").mask(df[",[52,325,314],{"class":62},[52,327,328],{"class":92},"].duplicated()))\n",[52,330,332,335,338,341,343,346],{"class":54,"line":331},10,[52,333,334],{"class":130},"print",[52,336,337],{"class":92},"(labels.to_string(",[52,339,340],{"class":305},"index",[52,342,115],{"class":88},[52,344,345],{"class":130},"False",[52,347,348],{"class":92},"))\n",[10,350,351,352,356,357,360,361,364,365,368],{},"Excel reads a multi-level category range column by column, from the outermost (left) to the innermost (right). A label in the outer column starts a new group, and the group continues through the following rows ",[353,354,355],"strong",{},"while the outer cell is empty",". So the year must appear only on the first row of its group and the rows beneath must be blank. pandas' ",[49,358,359],{},"duplicated()"," marks every repeat of a year after its first appearance, and ",[49,362,363],{},"mask"," blanks those cells — the year column becomes ",[14,366,367],{},"2025, blank, blank, blank, 2026, blank, blank",". Converting the year to a string first matters too: category labels are text, and a number in the outer column can be treated as a value rather than a label by some readers.",[370,371,379,380,379,384,379,388,379,396,379,405,379,409,379,416,379,420,379,424,379,428,379,432,379,436,379,440,379,444,379,449,379,454,379,457,379,459,379,462,379,464,379,467,379,469,379,472,379,475,379,479,379,483,379,486,379,488,379,492,379,494,379,496,379,498,379,500,379,503,379,507,379,509,379,511,379,514,379,516,379,518,379,520,379,522,379,525,379,529,379,531,379,533,379,536,379,538,379,540,379,542,379,544,379,547,379,551,379,553,379,555,379,558,379,560,379,562,379,564,379,566,379,569,379,573,379,575,379,577,379,579,379,581,379,583,379,585,379,587,379,589,379,592,379,596,379,598,379,600,379,602,379,604,379,606,379,608,379,610,379,613,379,617,379,619,379,621,379,623,379,625,379,627,379,629],"svg",{"viewBox":372,"role":373,"ariaLabelledBy":374,"xmlns":377,"style":378},"215 0 330 280","img",[375,376],"ml-layout-t","ml-layout-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:330px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[381,382,383],"title",{"id":375},"How the label columns must be laid out",[385,386,387],"desc",{"id":376},"The year appears only on the first row of each group and the cells below it are blank. The quarter column is filled on every row. Excel groups Q1 to Q4 under 2025 and the next three quarters under 2026.",[389,390],"rect",{"x":391,"y":392,"width":393,"height":394,"fill":395},"215","0","330","280","#ffffff",[389,397],{"x":398,"y":399,"width":400,"height":401,"rx":392,"fill":402,"stroke":403,"style":404},"231.1","16.0","32.0","24.0","#e7ebef","var(--line,#cdd5e6)","stroke-width:1px",[389,406],{"x":407,"y":399,"width":408,"height":401,"rx":392,"fill":402,"stroke":403,"style":404},"263.1","56.5",[410,411,415],"text",{"x":412,"y":413,"style":414},"291.4","33.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[389,417],{"x":418,"y":399,"width":419,"height":401,"rx":392,"fill":402,"stroke":403,"style":404},"319.7","71.3",[410,421,423],{"x":422,"y":413,"style":414},"355.4","B",[389,425],{"x":426,"y":399,"width":427,"height":401,"rx":392,"fill":402,"stroke":403,"style":404},"391.0","78.1",[410,429,431],{"x":430,"y":413,"style":414},"430.1","C",[389,433],{"x":434,"y":399,"width":435,"height":401,"rx":392,"fill":402,"stroke":403,"style":404},"469.1","59.8",[410,437,439],{"x":438,"y":413,"style":414},"499.0","D",[389,441],{"x":398,"y":442,"width":400,"height":443,"rx":392,"fill":402,"stroke":403,"style":404},"40.0","28.0",[410,445,448],{"x":446,"y":447,"style":414},"247.1","59.0","1",[389,450],{"x":407,"y":442,"width":408,"height":443,"rx":392,"fill":451,"stroke":452,"style":453},"#f0f4ff","var(--brand,#5b5cf0)","stroke-width:1.5px",[410,455,306],{"x":412,"y":447,"style":456},"font-size:12px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",[389,458],{"x":418,"y":442,"width":419,"height":443,"rx":392,"fill":451,"stroke":452,"style":453},[410,460,461],{"x":422,"y":447,"style":456},"Quarter",[389,463],{"x":426,"y":442,"width":427,"height":443,"rx":392,"fill":451,"stroke":452,"style":453},[410,465,466],{"x":430,"y":447,"style":456},"Revenue",[389,468],{"x":434,"y":442,"width":435,"height":443,"rx":392,"fill":451,"stroke":452,"style":453},[410,470,471],{"x":438,"y":447,"style":456},"Costs",[389,473],{"x":398,"y":474,"width":400,"height":443,"rx":392,"fill":402,"stroke":403,"style":404},"68.0",[410,476,478],{"x":446,"y":477,"style":414},"87.0","2",[389,480],{"x":407,"y":474,"width":408,"height":443,"rx":392,"fill":481,"stroke":482,"style":453},"#d9f4f1","var(--teal,#0f9488)",[410,484,24],{"x":412,"y":477,"style":485},"font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle",[389,487],{"x":418,"y":474,"width":419,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,489,491],{"x":422,"y":477,"style":490},"font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","Q1",[389,493],{"x":426,"y":474,"width":427,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,495,210],{"x":430,"y":477,"style":490},[389,497],{"x":434,"y":474,"width":435,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,499,253],{"x":438,"y":477,"style":490},[389,501],{"x":398,"y":502,"width":400,"height":443,"rx":392,"fill":402,"stroke":403,"style":404},"96.0",[410,504,506],{"x":446,"y":505,"style":414},"115.0","3",[389,508],{"x":407,"y":502,"width":408,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[389,510],{"x":418,"y":502,"width":419,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,512,513],{"x":422,"y":505,"style":490},"Q2",[389,515],{"x":426,"y":502,"width":427,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,517,215],{"x":430,"y":505,"style":490},[389,519],{"x":434,"y":502,"width":435,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,521,258],{"x":438,"y":505,"style":490},[389,523],{"x":398,"y":524,"width":400,"height":443,"rx":392,"fill":402,"stroke":403,"style":404},"124.0",[410,526,528],{"x":446,"y":527,"style":414},"143.0","4",[389,530],{"x":407,"y":524,"width":408,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[389,532],{"x":418,"y":524,"width":419,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,534,535],{"x":422,"y":527,"style":490},"Q3",[389,537],{"x":426,"y":524,"width":427,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,539,220],{"x":430,"y":527,"style":490},[389,541],{"x":434,"y":524,"width":435,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,543,263],{"x":438,"y":527,"style":490},[389,545],{"x":398,"y":546,"width":400,"height":443,"rx":392,"fill":402,"stroke":403,"style":404},"152.0",[410,548,550],{"x":446,"y":549,"style":414},"171.0","5",[389,552],{"x":407,"y":546,"width":408,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[389,554],{"x":418,"y":546,"width":419,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,556,557],{"x":422,"y":549,"style":490},"Q4",[389,559],{"x":426,"y":546,"width":427,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,561,225],{"x":430,"y":549,"style":490},[389,563],{"x":434,"y":546,"width":435,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,565,268],{"x":438,"y":549,"style":490},[389,567],{"x":398,"y":568,"width":400,"height":443,"rx":392,"fill":402,"stroke":403,"style":404},"180.0",[410,570,572],{"x":446,"y":571,"style":414},"199.0","6",[389,574],{"x":407,"y":568,"width":408,"height":443,"rx":392,"fill":481,"stroke":482,"style":453},[410,576,28],{"x":412,"y":571,"style":485},[389,578],{"x":418,"y":568,"width":419,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,580,491],{"x":422,"y":571,"style":490},[389,582],{"x":426,"y":568,"width":427,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,584,230],{"x":430,"y":571,"style":490},[389,586],{"x":434,"y":568,"width":435,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,588,273],{"x":438,"y":571,"style":490},[389,590],{"x":398,"y":591,"width":400,"height":443,"rx":392,"fill":402,"stroke":403,"style":404},"208.0",[410,593,595],{"x":446,"y":594,"style":414},"227.0","7",[389,597],{"x":407,"y":591,"width":408,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[389,599],{"x":418,"y":591,"width":419,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,601,513],{"x":422,"y":594,"style":490},[389,603],{"x":426,"y":591,"width":427,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,605,235],{"x":430,"y":594,"style":490},[389,607],{"x":434,"y":591,"width":435,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,609,278],{"x":438,"y":594,"style":490},[389,611],{"x":398,"y":612,"width":400,"height":443,"rx":392,"fill":402,"stroke":403,"style":404},"236.0",[410,614,616],{"x":446,"y":615,"style":414},"255.0","8",[389,618],{"x":407,"y":612,"width":408,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[389,620],{"x":418,"y":612,"width":419,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,622,535],{"x":422,"y":615,"style":490},[389,624],{"x":426,"y":612,"width":427,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,626,240],{"x":430,"y":615,"style":490},[389,628],{"x":434,"y":612,"width":435,"height":443,"rx":392,"fill":395,"stroke":403,"style":404},[410,630,283],{"x":438,"y":615,"style":490},[37,632,634],{"id":633},"step-2-point-the-categories-at-both-columns","Step 2 — Point the categories at both columns",[42,636,638],{"className":79,"code":637,"language":81,"meta":47,"style":47},"from openpyxl import Workbook\nfrom openpyxl.chart import BarChart, Reference\nfrom openpyxl.chart.data_source import AxDataSource, MultiLevelStrRef\n\nwb = Workbook()\nws = wb.active\nws.title = \"Quarterly\"\nws.append(list(labels.columns))\nfor row in labels.itertuples(index=False):\n    ws.append([None if pd.isna(v) else v for v in row])\nlast = len(labels) + 1\n\nchart = BarChart()\nchart.type = \"col\"\nchart.add_data(Reference(ws, min_col=3, max_col=4, min_row=1, max_row=last), titles_from_data=True)\ncategories = AxDataSource(multiLvlStrRef=MultiLevelStrRef(f=f\"'{ws.title}'!$A$2:$B${last}\"))\nfor series in chart.series:\n    series.cat = categories\nchart.title = \"Revenue and costs by quarter\"\nchart.y_axis.title = \"£k\"\nchart.x_axis.delete = chart.y_axis.delete = False\nchart.legend.position = \"b\"\nchart.width, chart.height = 16, 8\nws.add_chart(chart, \"A11\")\nwb.save(\"quarterly_multilevel.xlsx\")\nprint(\"categories:\", chart.series[0].cat.multiLvlStrRef.f)\n",[49,639,640,653,665,677,681,691,701,711,722,745,774,794,799,810,821,873,927,940,951,962,973,989,1000,1016,1027,1038],{"__ignoreMap":47},[52,641,642,645,648,650],{"class":54,"line":55},[52,643,644],{"class":88},"from",[52,646,647],{"class":92}," openpyxl ",[52,649,89],{"class":88},[52,651,652],{"class":92}," Workbook\n",[52,654,655,657,660,662],{"class":54,"line":102},[52,656,644],{"class":88},[52,658,659],{"class":92}," openpyxl.chart ",[52,661,89],{"class":88},[52,663,664],{"class":92}," BarChart, Reference\n",[52,666,667,669,672,674],{"class":54,"line":109},[52,668,644],{"class":88},[52,670,671],{"class":92}," openpyxl.chart.data_source ",[52,673,89],{"class":88},[52,675,676],{"class":92}," AxDataSource, MultiLevelStrRef\n",[52,678,679],{"class":54,"line":121},[52,680,106],{"emptyLinePlaceholder":105},[52,682,683,686,688],{"class":54,"line":160},[52,684,685],{"class":92},"wb ",[52,687,115],{"class":88},[52,689,690],{"class":92}," Workbook()\n",[52,692,693,696,698],{"class":54,"line":202},[52,694,695],{"class":92},"ws ",[52,697,115],{"class":88},[52,699,700],{"class":92}," wb.active\n",[52,702,703,706,708],{"class":54,"line":245},[52,704,705],{"class":92},"ws.title ",[52,707,115],{"class":88},[52,709,710],{"class":62}," \"Quarterly\"\n",[52,712,713,716,719],{"class":54,"line":288},[52,714,715],{"class":92},"ws.append(",[52,717,718],{"class":130},"list",[52,720,721],{"class":92},"(labels.columns))\n",[52,723,724,727,730,733,736,738,740,742],{"class":54,"line":294},[52,725,726],{"class":88},"for",[52,728,729],{"class":92}," row ",[52,731,732],{"class":88},"in",[52,734,735],{"class":92}," labels.itertuples(",[52,737,340],{"class":305},[52,739,115],{"class":88},[52,741,345],{"class":130},[52,743,744],{"class":92},"):\n",[52,746,747,750,753,756,759,762,765,767,769,771],{"class":54,"line":331},[52,748,749],{"class":92},"    ws.append([",[52,751,752],{"class":130},"None",[52,754,755],{"class":88}," if",[52,757,758],{"class":92}," pd.isna(v) ",[52,760,761],{"class":88},"else",[52,763,764],{"class":92}," v ",[52,766,726],{"class":88},[52,768,764],{"class":92},[52,770,732],{"class":88},[52,772,773],{"class":92}," row])\n",[52,775,777,780,782,785,788,791],{"class":54,"line":776},11,[52,778,779],{"class":92},"last ",[52,781,115],{"class":88},[52,783,784],{"class":130}," len",[52,786,787],{"class":92},"(labels) ",[52,789,790],{"class":88},"+",[52,792,793],{"class":130}," 1\n",[52,795,797],{"class":54,"line":796},12,[52,798,106],{"emptyLinePlaceholder":105},[52,800,802,805,807],{"class":54,"line":801},13,[52,803,804],{"class":92},"chart ",[52,806,115],{"class":88},[52,808,809],{"class":92}," BarChart()\n",[52,811,813,816,818],{"class":54,"line":812},14,[52,814,815],{"class":92},"chart.type ",[52,817,115],{"class":88},[52,819,820],{"class":62}," \"col\"\n",[52,822,824,827,830,832,834,836,839,841,843,845,848,850,852,854,857,859,862,865,867,870],{"class":54,"line":823},15,[52,825,826],{"class":92},"chart.add_data(Reference(ws, ",[52,828,829],{"class":305},"min_col",[52,831,115],{"class":88},[52,833,506],{"class":130},[52,835,171],{"class":92},[52,837,838],{"class":305},"max_col",[52,840,115],{"class":88},[52,842,528],{"class":130},[52,844,171],{"class":92},[52,846,847],{"class":305},"min_row",[52,849,115],{"class":88},[52,851,448],{"class":130},[52,853,171],{"class":92},[52,855,856],{"class":305},"max_row",[52,858,115],{"class":88},[52,860,861],{"class":92},"last), ",[52,863,864],{"class":305},"titles_from_data",[52,866,115],{"class":88},[52,868,869],{"class":130},"True",[52,871,872],{"class":92},")\n",[52,874,876,879,881,884,887,889,892,895,897,899,902,906,909,912,915,917,920,922,925],{"class":54,"line":875},16,[52,877,878],{"class":92},"categories ",[52,880,115],{"class":88},[52,882,883],{"class":92}," AxDataSource(",[52,885,886],{"class":305},"multiLvlStrRef",[52,888,115],{"class":88},[52,890,891],{"class":92},"MultiLevelStrRef(",[52,893,894],{"class":305},"f",[52,896,115],{"class":88},[52,898,894],{"class":88},[52,900,901],{"class":62},"\"'",[52,903,905],{"class":904},"sSjpA","{",[52,907,908],{"class":92},"ws.title",[52,910,911],{"class":904},"}",[52,913,914],{"class":62},"'!$A$2:$B$",[52,916,905],{"class":904},[52,918,919],{"class":92},"last",[52,921,911],{"class":904},[52,923,924],{"class":62},"\"",[52,926,348],{"class":92},[52,928,930,932,935,937],{"class":54,"line":929},17,[52,931,726],{"class":88},[52,933,934],{"class":92}," series ",[52,936,732],{"class":88},[52,938,939],{"class":92}," chart.series:\n",[52,941,943,946,948],{"class":54,"line":942},18,[52,944,945],{"class":92},"    series.cat ",[52,947,115],{"class":88},[52,949,950],{"class":92}," categories\n",[52,952,954,957,959],{"class":54,"line":953},19,[52,955,956],{"class":92},"chart.title ",[52,958,115],{"class":88},[52,960,961],{"class":62}," \"Revenue and costs by quarter\"\n",[52,963,965,968,970],{"class":54,"line":964},20,[52,966,967],{"class":92},"chart.y_axis.title ",[52,969,115],{"class":88},[52,971,972],{"class":62}," \"£k\"\n",[52,974,976,979,981,984,986],{"class":54,"line":975},21,[52,977,978],{"class":92},"chart.x_axis.delete ",[52,980,115],{"class":88},[52,982,983],{"class":92}," chart.y_axis.delete ",[52,985,115],{"class":88},[52,987,988],{"class":130}," False\n",[52,990,992,995,997],{"class":54,"line":991},22,[52,993,994],{"class":92},"chart.legend.position ",[52,996,115],{"class":88},[52,998,999],{"class":62}," \"b\"\n",[52,1001,1003,1006,1008,1011,1013],{"class":54,"line":1002},23,[52,1004,1005],{"class":92},"chart.width, chart.height ",[52,1007,115],{"class":88},[52,1009,1010],{"class":130}," 16",[52,1012,171],{"class":92},[52,1014,1015],{"class":130},"8\n",[52,1017,1019,1022,1025],{"class":54,"line":1018},24,[52,1020,1021],{"class":92},"ws.add_chart(chart, ",[52,1023,1024],{"class":62},"\"A11\"",[52,1026,872],{"class":92},[52,1028,1030,1033,1036],{"class":54,"line":1029},25,[52,1031,1032],{"class":92},"wb.save(",[52,1034,1035],{"class":62},"\"quarterly_multilevel.xlsx\"",[52,1037,872],{"class":92},[52,1039,1041,1043,1046,1049,1052,1054],{"class":54,"line":1040},26,[52,1042,334],{"class":130},[52,1044,1045],{"class":92},"(",[52,1047,1048],{"class":62},"\"categories:\"",[52,1050,1051],{"class":92},", chart.series[",[52,1053,392],{"class":130},[52,1055,1056],{"class":92},"].cat.multiLvlStrRef.f)\n",[10,1058,1059,1060,1063,1064,1067,1068,1071,1072,1075,1076,1078],{},"The usual ",[49,1061,1062],{},"set_categories"," call works with one column. For two, each series gets its categories as a ",[49,1065,1066],{},"MultiLevelStrRef"," covering both label columns — ",[49,1069,1070],{},"A2:B8"," here — which is the element Excel itself writes when you select a two-column category range in its chart dialog. Every series in the chart must use the same reference, so it is assigned in a loop. The rest of the chart is ordinary: two series, revenue and costs, clustered side by side within each quarter. A single ",[49,1073,1074],{},"Reference"," over two columns passed to ",[49,1077,1062],{}," happens to render in some applications too, but openpyxl then writes it as a numeric reference, which is not what Excel expects for text labels; the explicit multi-level reference avoids relying on that.",[37,1080,1082],{"id":1081},"step-3-render-it-and-see-what-repeated-labels-do","Step 3 — Render it, and see what repeated labels do",[42,1084,1086],{"className":79,"code":1085,"language":81,"meta":47,"style":47},"repeated = Workbook()\nrs = repeated.active\nrs.title = \"Quarterly\"\nrs.append(list(df.columns))\nfor row in df.astype({\"Year\": \"string\"}).itertuples(index=False):\n    rs.append(list(row))\nbad = BarChart()\nbad.type = \"col\"\nbad.add_data(Reference(rs, min_col=3, max_col=4, min_row=1, max_row=last), titles_from_data=True)\nfor series in bad.series:\n    series.cat = AxDataSource(multiLvlStrRef=MultiLevelStrRef(f=f\"'Quarterly'!$A$2:$B${last}\"))\nbad.title = \"Repeated years: no grouping\"\nbad.x_axis.delete = bad.y_axis.delete = False\nbad.width, bad.height = 16, 8\nrs.add_chart(bad, \"A11\")\nrepeated.save(\"quarterly_repeated.xlsx\")\n",[49,1087,1088,1097,1107,1116,1126,1155,1165,1174,1183,1226,1237,1270,1280,1294,1307,1316],{"__ignoreMap":47},[52,1089,1090,1093,1095],{"class":54,"line":55},[52,1091,1092],{"class":92},"repeated ",[52,1094,115],{"class":88},[52,1096,690],{"class":92},[52,1098,1099,1102,1104],{"class":54,"line":102},[52,1100,1101],{"class":92},"rs ",[52,1103,115],{"class":88},[52,1105,1106],{"class":92}," repeated.active\n",[52,1108,1109,1112,1114],{"class":54,"line":109},[52,1110,1111],{"class":92},"rs.title ",[52,1113,115],{"class":88},[52,1115,710],{"class":62},[52,1117,1118,1121,1123],{"class":54,"line":121},[52,1119,1120],{"class":92},"rs.append(",[52,1122,718],{"class":130},[52,1124,1125],{"class":92},"(df.columns))\n",[52,1127,1128,1130,1132,1134,1137,1139,1142,1144,1147,1149,1151,1153],{"class":54,"line":160},[52,1129,726],{"class":88},[52,1131,729],{"class":92},[52,1133,732],{"class":88},[52,1135,1136],{"class":92}," df.astype({",[52,1138,314],{"class":62},[52,1140,1141],{"class":92},": ",[52,1143,320],{"class":62},[52,1145,1146],{"class":92},"}).itertuples(",[52,1148,340],{"class":305},[52,1150,115],{"class":88},[52,1152,345],{"class":130},[52,1154,744],{"class":92},[52,1156,1157,1160,1162],{"class":54,"line":202},[52,1158,1159],{"class":92},"    rs.append(",[52,1161,718],{"class":130},[52,1163,1164],{"class":92},"(row))\n",[52,1166,1167,1170,1172],{"class":54,"line":245},[52,1168,1169],{"class":92},"bad ",[52,1171,115],{"class":88},[52,1173,809],{"class":92},[52,1175,1176,1179,1181],{"class":54,"line":288},[52,1177,1178],{"class":92},"bad.type ",[52,1180,115],{"class":88},[52,1182,820],{"class":62},[52,1184,1185,1188,1190,1192,1194,1196,1198,1200,1202,1204,1206,1208,1210,1212,1214,1216,1218,1220,1222,1224],{"class":54,"line":294},[52,1186,1187],{"class":92},"bad.add_data(Reference(rs, ",[52,1189,829],{"class":305},[52,1191,115],{"class":88},[52,1193,506],{"class":130},[52,1195,171],{"class":92},[52,1197,838],{"class":305},[52,1199,115],{"class":88},[52,1201,528],{"class":130},[52,1203,171],{"class":92},[52,1205,847],{"class":305},[52,1207,115],{"class":88},[52,1209,448],{"class":130},[52,1211,171],{"class":92},[52,1213,856],{"class":305},[52,1215,115],{"class":88},[52,1217,861],{"class":92},[52,1219,864],{"class":305},[52,1221,115],{"class":88},[52,1223,869],{"class":130},[52,1225,872],{"class":92},[52,1227,1228,1230,1232,1234],{"class":54,"line":331},[52,1229,726],{"class":88},[52,1231,934],{"class":92},[52,1233,732],{"class":88},[52,1235,1236],{"class":92}," bad.series:\n",[52,1238,1239,1241,1243,1245,1247,1249,1251,1253,1255,1257,1260,1262,1264,1266,1268],{"class":54,"line":776},[52,1240,945],{"class":92},[52,1242,115],{"class":88},[52,1244,883],{"class":92},[52,1246,886],{"class":305},[52,1248,115],{"class":88},[52,1250,891],{"class":92},[52,1252,894],{"class":305},[52,1254,115],{"class":88},[52,1256,894],{"class":88},[52,1258,1259],{"class":62},"\"'Quarterly'!$A$2:$B$",[52,1261,905],{"class":904},[52,1263,919],{"class":92},[52,1265,911],{"class":904},[52,1267,924],{"class":62},[52,1269,348],{"class":92},[52,1271,1272,1275,1277],{"class":54,"line":796},[52,1273,1274],{"class":92},"bad.title ",[52,1276,115],{"class":88},[52,1278,1279],{"class":62}," \"Repeated years: no grouping\"\n",[52,1281,1282,1285,1287,1290,1292],{"class":54,"line":801},[52,1283,1284],{"class":92},"bad.x_axis.delete ",[52,1286,115],{"class":88},[52,1288,1289],{"class":92}," bad.y_axis.delete ",[52,1291,115],{"class":88},[52,1293,988],{"class":130},[52,1295,1296,1299,1301,1303,1305],{"class":54,"line":812},[52,1297,1298],{"class":92},"bad.width, bad.height ",[52,1300,115],{"class":88},[52,1302,1010],{"class":130},[52,1304,171],{"class":92},[52,1306,1015],{"class":130},[52,1308,1309,1312,1314],{"class":54,"line":823},[52,1310,1311],{"class":92},"rs.add_chart(bad, ",[52,1313,1024],{"class":62},[52,1315,872],{"class":92},[52,1317,1318,1321,1324],{"class":54,"line":875},[52,1319,1320],{"class":92},"repeated.save(",[52,1322,1323],{"class":62},"\"quarterly_repeated.xlsx\"",[52,1325,872],{"class":92},[42,1327,1329],{"className":44,"code":1328,"language":46,"meta":47,"style":47},"for f in quarterly_multilevel quarterly_repeated; do\n  soffice --headless --convert-to pdf \"$f.xlsx\" > \u002Fdev\u002Fnull 2>&1\n  pdftoppm -r 70 -f 1 -l 1 -png \"$f.pdf\" \"$f\"\ndone\nls *.png\n",[49,1330,1331,1352,1384,1423,1428],{"__ignoreMap":47},[52,1332,1333,1335,1338,1340,1343,1346,1349],{"class":54,"line":55},[52,1334,726],{"class":88},[52,1336,1337],{"class":92}," f ",[52,1339,732],{"class":88},[52,1341,1342],{"class":62}," quarterly_multilevel",[52,1344,1345],{"class":62}," quarterly_repeated",[52,1347,1348],{"class":92},"; ",[52,1350,1351],{"class":88},"do\n",[52,1353,1354,1357,1360,1363,1366,1369,1372,1375,1378,1381],{"class":54,"line":102},[52,1355,1356],{"class":58},"  soffice",[52,1358,1359],{"class":130}," --headless",[52,1361,1362],{"class":130}," --convert-to",[52,1364,1365],{"class":62}," pdf",[52,1367,1368],{"class":62}," \"",[52,1370,1371],{"class":92},"$f",[52,1373,1374],{"class":62},".xlsx\"",[52,1376,1377],{"class":88}," >",[52,1379,1380],{"class":62}," \u002Fdev\u002Fnull",[52,1382,1383],{"class":88}," 2>&1\n",[52,1385,1386,1389,1392,1395,1398,1401,1404,1406,1409,1411,1413,1416,1418,1420],{"class":54,"line":109},[52,1387,1388],{"class":58},"  pdftoppm",[52,1390,1391],{"class":130}," -r",[52,1393,1394],{"class":130}," 70",[52,1396,1397],{"class":130}," -f",[52,1399,1400],{"class":130}," 1",[52,1402,1403],{"class":130}," -l",[52,1405,1400],{"class":130},[52,1407,1408],{"class":130}," -png",[52,1410,1368],{"class":62},[52,1412,1371],{"class":92},[52,1414,1415],{"class":62},".pdf\"",[52,1417,1368],{"class":62},[52,1419,1371],{"class":92},[52,1421,1422],{"class":62},"\"\n",[52,1424,1425],{"class":54,"line":121},[52,1426,1427],{"class":88},"done\n",[52,1429,1430,1433,1436],{"class":54,"line":160},[52,1431,1432],{"class":58},"ls",[52,1434,1435],{"class":130}," *",[52,1437,1438],{"class":62},".png\n",[10,1440,1441,1442,1444,1445,1447],{},"Rendering both workbooks shows the difference. With blank repeats, the axis has the quarters along the bottom and two group labels underneath — ",[14,1443,24],{}," centred under four bars, ",[14,1446,28],{}," under three — separated by a divider. With the year repeated on every row, each cell in the outer column starts a new group of one, so the axis shows seven tiny groups each labelled with its own year: technically two levels, but no grouping at all, and no less crowded than single labels. This is the mistake to look for when a multi-level axis \"does nothing\". It is also the layout pandas produces by default, since a DataFrame repeats the year on every row, so the blanking step is needed almost every time the source is a tidy table.",[370,1449,379,1455,379,1458,379,1461,379,1465,379,1472,379,1478,379,1483,379,1487,379,1491,379,1495,379,1499,379,1504,379,1509,379,1513,379,1516,379,1519,379,1522],{"viewBox":1450,"role":373,"ariaLabelledBy":1451,"xmlns":377,"style":1454},"4 0 752 199",[1452,1453],"ml-blank-vs-repeat-t","ml-blank-vs-repeat-d","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[381,1456,1457],{"id":1452},"Blank or repeated outer labels",[385,1459,1460],{"id":1453},"With the outer label written once and blanks below it, quarters are grouped under each year with a divider between years. With the year repeated on every row, each row is its own group of one and there is no grouping.",[389,1462],{"x":528,"y":392,"width":1463,"height":1464,"fill":395},"752","199",[389,1466],{"x":1467,"y":399,"width":1468,"height":1469,"rx":1470,"fill":481,"stroke":482,"style":1471},"20.0","351.0","167.0","14","stroke-width:2px",[410,1473,1477],{"x":1474,"y":1475,"style":1476},"195.5","44.0","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Year once, blanks below",[54,1479],{"x1":1480,"y1":1481,"x2":1482,"y2":1481,"stroke":482,"style":404},"36.0","57.0","355.0",[410,1484,1486],{"x":1474,"y":1485,"style":490},"77.0","2025 spans Q1 to Q4",[410,1488,1490],{"x":1474,"y":1489,"style":490},"103.0","2026 spans the next three",[410,1492,1494],{"x":1474,"y":1493,"style":490},"129.0","dividers between years",[410,1496,1498],{"x":1474,"y":1497,"style":490},"155.0","clean, readable axis",[389,1500],{"x":1501,"y":399,"width":1468,"height":1469,"rx":1470,"fill":1502,"stroke":1503,"style":1471},"389.0","#fce9e9","var(--danger,#dc2626)",[410,1505,1508],{"x":1506,"y":1475,"style":1507},"564.5","font-size:14px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","Year on every row",[54,1510],{"x1":1511,"y1":1481,"x2":1512,"y2":1481,"stroke":1503,"style":404},"405.0","724.0",[410,1514,1515],{"x":1506,"y":1485,"style":490},"seven groups of one",[410,1517,1518],{"x":1506,"y":1489,"style":490},"year printed under each bar",[410,1520,1521],{"x":1506,"y":1493,"style":490},"no dividers that help",[410,1523,1524],{"x":1506,"y":1497,"style":490},"as crowded as before",[37,1526,1528],{"id":1527},"sort-first-then-blank","Sort first, then blank",[10,1530,1531,1532,1535,1536,1538,1539,1542,1543,1545],{},"The blanking rule has a consequence that is easy to miss: the rows must be sorted by the outer level before the repeats are removed. Excel does not merge groups that happen to share a label; it only looks at whether the outer cell is empty. If the data arrives sorted by quarter rather than by year — 2025 Q1, 2026 Q1, 2025 Q2, 2026 Q2 — then blanking duplicates leaves a group for 2025 containing one quarter, a group for 2026 containing one quarter, and then unlabelled bars that silently belong to whichever year came last. Sort by the outer column, then the inner one, then blank the repeats — in pandas, ",[49,1533,1534],{},"sort_values([\"Year\", \"Quarter\"])"," before the ",[49,1537,363],{}," step. The printed table in step 1 shows the result before it is written: pandas displays the blanked cells as ",[49,1540,1541],{},"\u003CNA>",", and the loop that writes the rows turns them into ",[49,1544,752],{},", which openpyxl leaves as truly empty cells. Writing an empty string instead would also work in Excel, but a real empty cell is the unambiguous choice.",[37,1547,1549],{"id":1548},"the-xlsxwriter-version","The XlsxWriter version",[10,1551,1552,1553,1556,1557,1560],{},"XlsxWriter supports multi-level categories directly: pass a two-column range as the categories, for example ",[49,1554,1555],{},"\"categories\": \"=Quarterly!$A$2:$B$8\"",", and it writes the multi-level reference itself. The layout rule is the same — outer labels once, blanks below — because it is a rule of how Excel reads the range, not of the library. When the data comes from a pandas DataFrame with a MultiIndex, writing it with ",[49,1558,1559],{},"to_excel(merge_cells=True)"," produces merged outer cells, which Excel reads as a label followed by blanks; that can save the masking step, though merged cells have other drawbacks in data sheets, so the explicit blank layout is usually the cleaner source for a chart.",[37,1562,1564],{"id":1563},"when-to-use-more-levels","When to use more levels",[10,1566,1567],{},"Two levels cover most reports. A third — products under regions under years, say — is possible by adding another column on the left of the label range, with the same rule applied at each level: each label once, blanks below until the group changes. Beyond that the axis takes more space than the bars, and separate charts per group, or a table, communicate better. Multi-level labels also suit line charts with many periods, such as months under years; the axis groups the months and the years become natural reading anchors.",[370,1569,379,1575,379,1578,379,1581,379,1586,379,1591,379,1597,379,1602,379,1606,379,1611,379,1616,379,1621,379,1625,379,1629,379,1632,379,1635,379,1640],{"viewBox":1570,"role":373,"ariaLabelledBy":1571,"xmlns":377,"style":1574},"84 0 592 281",[1572,1573],"ml-levels-t","ml-levels-d","width:100%;max-width:592px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[381,1576,1577],{"id":1572},"Label levels from outermost to innermost",[385,1579,1580],{"id":1573},"The leftmost label column is the outermost level, such as year. The next column is a finer level, such as quarter. The innermost level is drawn next to the bars, and outer levels are drawn below it as group labels.",[389,1582],{"x":1583,"y":392,"width":1584,"height":1585,"fill":395},"84","592","281",[389,1587],{"x":1588,"y":399,"width":1589,"height":447,"rx":1590,"fill":451,"stroke":452,"style":1471},"100.0","560.0","12",[410,1592,1596],{"x":1593,"y":1594,"style":1595},"380.0","41.5","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Innermost — Quarter",[410,1598,1601],{"x":1593,"y":1599,"style":1600},"61.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","next to the bars",[54,1603],{"x1":1593,"y1":1604,"x2":1593,"y2":1605,"stroke":452,"style":1471},"80.0","102.0",[1607,1608],"polygon",{"points":1609,"fill":1610},"380.0,106.0 375.0,97.0 385.0,97.0","#5b5cf0",[389,1612],{"x":1588,"y":1613,"width":1589,"height":447,"rx":1590,"fill":1614,"stroke":1615,"style":1471},"111.0","#fdefd8","var(--gold,#b4740a)",[410,1617,1620],{"x":1593,"y":1618,"style":1619},"136.5","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Middle — optional",[410,1622,1624],{"x":1593,"y":1623,"style":1600},"156.0","e.g. half-year",[54,1626],{"x1":1593,"y1":1627,"x2":1593,"y2":1628,"stroke":452,"style":1471},"175.0","197.0",[1607,1630],{"points":1631,"fill":1610},"380.0,201.0 375.0,192.0 385.0,192.0",[389,1633],{"x":1588,"y":1634,"width":1589,"height":447,"rx":1590,"fill":481,"stroke":482,"style":1471},"206.0",[410,1636,1639],{"x":1593,"y":1637,"style":1638},"231.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Outermost — Year",[410,1641,1643],{"x":1593,"y":1642,"style":1600},"251.0","group label, once per group",[37,1645,1647],{"id":1646},"common-pitfalls","Common pitfalls",[1649,1650,1651,1658,1669,1675,1681],"ul",{},[1652,1653,1654,1657],"li",{},[353,1655,1656],{},"Repeated outer labels."," Write each outer label once and leave the cells below blank.",[1652,1659,1660,1665,1666,1668],{},[353,1661,1662,1664],{},[49,1663,1062],{}," over two columns."," Use a ",[49,1667,1066],{}," so Excel gets text labels.",[1652,1670,1671,1674],{},[353,1672,1673],{},"Numbers as outer labels."," Convert years to strings in the label column.",[1652,1676,1677,1680],{},[353,1678,1679],{},"Different category references per series."," Give every series the same multi-level reference.",[1652,1682,1683,1686],{},[353,1684,1685],{},"Too many levels."," More than three levels crowd the axis; split the chart instead.",[37,1688,1690],{"id":1689},"conclusion","Conclusion",[10,1692,1693,1694,1697],{},"A multi-level category axis groups detailed labels under broader ones without repeating them under every bar. Lay out the label columns from outermost to innermost, write each outer label only on the first row of its group with blanks below, and give every series an ",[49,1695,1696],{},"AxDataSource(multiLvlStrRef=MultiLevelStrRef(f=...))"," covering all the label columns. Render the workbook once to check the grouping, and remember that a repeated outer label turns every row into a group of its own.",[37,1699,1701],{"id":1700},"frequently-asked-questions","Frequently asked questions",[10,1703,1704,1707,1708,1711,1712,1715],{},[353,1705,1706],{},"How do I make a two-level category axis in an Excel chart with openpyxl?","\nSet each series' ",[49,1709,1710],{},"cat"," to ",[49,1713,1714],{},"AxDataSource(multiLvlStrRef=MultiLevelStrRef(f=\"'Sheet'!$A$2:$B$8\"))",", where column A holds the outer labels and column B the inner ones.",[10,1717,1718,1721],{},[353,1719,1720],{},"Why does my multi-level axis not group the labels?","\nThe outer labels are probably repeated on every row; Excel starts a new group at every non-empty outer cell, so leave the cells below each outer label blank.",[10,1723,1724,1727,1728,35],{},[353,1725,1726],{},"Does XlsxWriter support multi-level categories?","\nYes; pass a range that spans two or more columns as the series' ",[49,1729,1730],{},"categories",[10,1732,1733,1736],{},[353,1734,1735],{},"Can I use multi-level labels with a line chart?","\nYes; any chart with a category axis, including line and area charts, can use multi-level labels.",[37,1738,1740],{"id":1739},"related","Related",[1649,1742,1743,1749,1756,1763],{},[1652,1744,1745,1746,1748],{},"Up one level: ",[31,1747,34],{"href":33}," — native Excel charts from Python.",[1652,1750,1751,1755],{},[31,1752,1754],{"href":1753},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-charts-in-excel-with-openpyxl\u002Fcreate-bar-chart-in-excel-with-openpyxl\u002F","Create a Bar Chart in Excel with openpyxl"," — bar and column basics.",[1652,1757,1758,1762],{},[31,1759,1761],{"href":1760},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-charts-in-excel-with-openpyxl\u002Fformat-chart-axes-with-openpyxl\u002F","Format Chart Axes with openpyxl"," — axis titles, scales and labels.",[1652,1764,1765,1769],{},[31,1766,1768],{"href":1767},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-charts-in-excel-with-openpyxl\u002Fuse-a-date-axis-in-an-excel-chart-with-openpyxl\u002F","Use a Date Axis in an Excel Chart with openpyxl"," — when categories are dates.",[1771,1772,1773],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .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":47,"searchDepth":102,"depth":102,"links":1775},[1776,1777,1778,1779,1780,1781,1782,1783,1784,1785,1786],{"id":39,"depth":102,"text":40},{"id":75,"depth":102,"text":76},{"id":633,"depth":102,"text":634},{"id":1081,"depth":102,"text":1082},{"id":1527,"depth":102,"text":1528},{"id":1548,"depth":102,"text":1549},{"id":1563,"depth":102,"text":1564},{"id":1646,"depth":102,"text":1647},{"id":1689,"depth":102,"text":1690},{"id":1700,"depth":102,"text":1701},{"id":1739,"depth":102,"text":1740},"2026-10-02","Label a chart's category axis in two tiers — quarters grouped under years, months under quarters, products under regions — with openpyxl: write the outer label only on the first row of each group, point the categories at both columns as a multi-level string reference, and see why repeated outer labels break the grouping. Rendered to check, with the XlsxWriter equivalent.","md",[1791,1793,1795,1797],{"q":1706,"a":1792},"Set each series' cat to AxDataSource(multiLvlStrRef=MultiLevelStrRef(f=\"'Sheet'!$A$2:$B$8\")), where column A holds the outer labels and column B the inner ones.",{"q":1720,"a":1794},"The outer labels are probably repeated on every row; Excel starts a new group at every non-empty outer cell, so leave the cells below each outer label blank.",{"q":1726,"a":1796},"Yes; pass a range that spans two or more columns as the series' categories.",{"q":1735,"a":1798},"Yes; any chart with a category axis, including line and area charts, can use multi-level labels.","multi-level category labels excel chart python",null,{"breadcrumb":1802},[1803,1806,1809],{"name":1804,"item":1805},"Home","\u002F",{"name":1807,"item":1808},"Formatting and Charting Excel Reports with Python","\u002Fformatting-and-charting-excel-reports-with-python\u002F",{"name":34,"item":33},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-charts-in-excel-with-openpyxl\u002Fuse-multi-level-category-labels-in-an-excel-chart",{"title":1812,"description":1813},"Use Multi-Level Category Labels in an Excel Chart with Python","Two-tier category axis labels with openpyxl: blank repeated outer labels, a MultiLevelStrRef over two columns, why repeats break the grouping, and the XlsxWriter version.","use-multi-level-category-labels-in-an-excel-chart","formatting-and-charting-excel-reports-with-python\u002Fcreating-charts-in-excel-with-openpyxl\u002Fuse-multi-level-category-labels-in-an-excel-chart\u002Findex",[1817,1818],"charts-and-images","openpyxl","how-to","Ac1ry4HmTKKhRkAoPx-bLyy_jnMfBH3TWQ6guZUsqzk",[1822,1825],{"title":1768,"path":1823,"stem":1824,"children":-1},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-charts-in-excel-with-openpyxl\u002Fuse-a-date-axis-in-an-excel-chart-with-openpyxl","formatting-and-charting-excel-reports-with-python\u002Fcreating-charts-in-excel-with-openpyxl\u002Fuse-a-date-axis-in-an-excel-chart-with-openpyxl\u002Findex",{"title":1826,"path":1827,"stem":1828,"children":-1},"Creating Excel Tables and Autofilters with Python","\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-excel-tables-and-autofilters-with-python","formatting-and-charting-excel-reports-with-python\u002Fcreating-excel-tables-and-autofilters-with-python\u002Findex",[1830,1832],{"path":1831,"title":34},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fcreating-charts-in-excel-with-openpyxl",{"path":1833,"title":1807},"\u002Fformatting-and-charting-excel-reports-with-python",[],[1836,1839,1842,1845,1848,1851],{"path":1837,"title":1838,"kind":1819},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fbuild-a-dashboard-sheet-with-charts-from-multiple-tabs","Build a Dashboard Sheet with Charts from Multiple Tabs",{"path":1840,"title":1841,"kind":1819},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fbuild-a-rolling-12-month-chart-on-an-excel-dashboard","Build a Rolling 12-Month Chart on an Excel Dashboard",{"path":1843,"title":1844,"kind":1819},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fkeep-charts-and-images-when-filling-an-excel-template","Keep Charts and Images When Filling an Excel Template",{"path":1846,"title":1847,"kind":1819},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fbuilding-excel-reports-with-xlsxwriter\u002Fadd-a-chart-to-an-excel-file-with-xlsxwriter","Add a Chart to an Excel File with xlsxwriter",{"path":1849,"title":1850,"kind":1819},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fbuilding-excel-reports-with-xlsxwriter\u002Fadd-sparklines-to-an-excel-report-with-xlsxwriter","Add Sparklines to an Excel Report with xlsxwriter",{"path":1852,"title":1853,"kind":1819},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fbuilding-excel-reports-with-xlsxwriter\u002Fbuild-a-waterfall-chart-in-excel-with-python","Build a Waterfall Chart in Excel with Python",1790972115410]