How-to
Formatting And Charting Excel Reports With PythonDeep dive

Use Multi-Level Category Labels in an Excel Chart

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.

A column chart of seven quarters labelled 2025 Q1, 2025 Q2, … 2026 Q3 crowds the axis with repeated years and makes the reader parse every label. Excel can do better: a multi-level category axis shows the quarters along the bottom and groups them under a second row of labels, one 2025 centred under the first four bars and one 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 Creating Charts in Excel with openpyxl.

Prerequisites

Bash
pip install openpyxl pandas

LibreOffice is used to render the charts for checking; it draws multi-level axes the same way Excel does.

Step 1 — Lay out the label columns with blanks

Python
import pandas as pd

df = pd.DataFrame({
    "Year": [2025] * 4 + [2026] * 3,
    "Quarter": ["Q1", "Q2", "Q3", "Q4", "Q1", "Q2", "Q3"],
    "Revenue": [120, 135, 128, 150, 141, 158, 149],
    "Costs": [96, 104, 101, 112, 108, 117, 113],
})
labels = df.assign(Year=df["Year"].astype("string").mask(df["Year"].duplicated()))
print(labels.to_string(index=False))

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 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' duplicated() marks every repeat of a year after its first appearance, and mask blanks those cells — the year column becomes 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.

How the label columns must be laid out 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. A B C D 1 Year Quarter Revenue Costs 2 2025 Q1 120 96 3 Q2 135 104 4 Q3 128 101 5 Q4 150 112 6 2026 Q1 141 108 7 Q2 158 117 8 Q3 149 113

Step 2 — Point the categories at both columns

Python
from openpyxl import Workbook
from openpyxl.chart import BarChart, Reference
from openpyxl.chart.data_source import AxDataSource, MultiLevelStrRef

wb = Workbook()
ws = wb.active
ws.title = "Quarterly"
ws.append(list(labels.columns))
for row in labels.itertuples(index=False):
    ws.append([None if pd.isna(v) else v for v in row])
last = len(labels) + 1

chart = BarChart()
chart.type = "col"
chart.add_data(Reference(ws, min_col=3, max_col=4, min_row=1, max_row=last), titles_from_data=True)
categories = AxDataSource(multiLvlStrRef=MultiLevelStrRef(f=f"'{ws.title}'!$A$2:$B${last}"))
for series in chart.series:
    series.cat = categories
chart.title = "Revenue and costs by quarter"
chart.y_axis.title = "£k"
chart.x_axis.delete = chart.y_axis.delete = False
chart.legend.position = "b"
chart.width, chart.height = 16, 8
ws.add_chart(chart, "A11")
wb.save("quarterly_multilevel.xlsx")
print("categories:", chart.series[0].cat.multiLvlStrRef.f)

The usual set_categories call works with one column. For two, each series gets its categories as a MultiLevelStrRef covering both label columns — 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 Reference over two columns passed to set_categories 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.

Step 3 — Render it, and see what repeated labels do

Python
repeated = Workbook()
rs = repeated.active
rs.title = "Quarterly"
rs.append(list(df.columns))
for row in df.astype({"Year": "string"}).itertuples(index=False):
    rs.append(list(row))
bad = BarChart()
bad.type = "col"
bad.add_data(Reference(rs, min_col=3, max_col=4, min_row=1, max_row=last), titles_from_data=True)
for series in bad.series:
    series.cat = AxDataSource(multiLvlStrRef=MultiLevelStrRef(f=f"'Quarterly'!$A$2:$B${last}"))
bad.title = "Repeated years: no grouping"
bad.x_axis.delete = bad.y_axis.delete = False
bad.width, bad.height = 16, 8
rs.add_chart(bad, "A11")
repeated.save("quarterly_repeated.xlsx")
Bash
for f in quarterly_multilevel quarterly_repeated; do
  soffice --headless --convert-to pdf "$f.xlsx" > /dev/null 2>&1
  pdftoppm -r 70 -f 1 -l 1 -png "$f.pdf" "$f"
done
ls *.png

Rendering both workbooks shows the difference. With blank repeats, the axis has the quarters along the bottom and two group labels underneath — 2025 centred under four bars, 2026 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.

Blank or repeated outer labels 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. Year once, blanks below 2025 spans Q1 to Q4 2026 spans the next three dividers between years clean, readable axis Year on every row seven groups of one year printed under each bar no dividers that help as crowded as before

Sort first, then blank

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, sort_values(["Year", "Quarter"]) before the mask step. The printed table in step 1 shows the result before it is written: pandas displays the blanked cells as <NA>, and the loop that writes the rows turns them into None, 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.

The XlsxWriter version

XlsxWriter supports multi-level categories directly: pass a two-column range as the categories, for example "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 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.

When to use more levels

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.

Label levels from outermost to innermost 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. Innermost — Quarter next to the bars Middle — optional e.g. half-year Outermost — Year group label, once per group

Common pitfalls

  • Repeated outer labels. Write each outer label once and leave the cells below blank.
  • set_categories over two columns. Use a MultiLevelStrRef so Excel gets text labels.
  • Numbers as outer labels. Convert years to strings in the label column.
  • Different category references per series. Give every series the same multi-level reference.
  • Too many levels. More than three levels crowd the axis; split the chart instead.

Conclusion

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 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.

Frequently asked questions

How do I make a two-level category axis in an Excel chart with openpyxl? 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.

Why does my multi-level axis not group the labels? 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.

Does XlsxWriter support multi-level categories? Yes; pass a range that spans two or more columns as the series' categories.

Can I use multi-level labels with a line chart? Yes; any chart with a category axis, including line and area charts, can use multi-level labels.

More on charts & images

More on charts & images →