Create a Doughnut Chart with openpyxl
A doughnut chart is a pie chart with the middle removed: it shows how a whole splits into parts — revenue by channel, budget by department, tickets by category — with the ring leaving space that makes the slices easier to compare by arc length. It is a common request for dashboard sheets, and openpyxl builds it natively, as a real Excel chart that updates when the data changes. The defaults, though, give a thin ring with legend-key squares on every label and dark text on dark colours. This page, part of Creating Charts in Excel with openpyxl, builds the chart and fixes each of those.
Prerequisites
pip install openpyxl
channel_mix.xlsx has revenue for four sales channels in 2025 and 2026 on a sheet called Mix. The charts below are added to that workbook. To look at a chart without Excel, convert the file to PDF with LibreOffice, as the last step shows.
Step 1 — A basic doughnut
Create a DoughnutChart, add the values with their header as the series title, and the channel names as categories:
from openpyxl import load_workbook
from openpyxl.chart import DoughnutChart, Reference
wb = load_workbook("channel_mix.xlsx")
ws = wb["Mix"]
chart = DoughnutChart()
chart.title = "Revenue by channel, 2026"
data = Reference(ws, min_col=3, min_row=1, max_row=5)
chart.add_data(data, titles_from_data=True)
chart.set_categories(Reference(ws, min_col=1, min_row=2, max_row=5))
chart.holeSize = 55
chart.width, chart.height = 13, 9
ws.add_chart(chart, "A8")
wb.save("channel_mix.xlsx")
print(chart.holeSize, len(chart.series))
holeSize is the hole's diameter as a percentage of the chart — Excel accepts 10 to 90, and the default of 50 gives a ring that is thinner than most people expect; 55–65 reads well on dashboards. width and height are in centimetres. The chart references the cells rather than copying the numbers, so changing a value on the sheet changes the slice when the workbook is opened in Excel.
Step 2 — Colour each slice
By default Excel uses its theme colours. To match a report's palette — or to keep "Online" the same colour in every chart — set each slice's fill with a DataPoint:
from openpyxl.chart.series import DataPoint
PALETTE = ["4338CA", "0F9488", "B4740A", "BE185D"]
def colour_slices(series, colours):
series.dPt = []
for i, colour in enumerate(colours):
point = DataPoint(idx=i)
point.graphicalProperties.solidFill = colour
point.graphicalProperties.line.solidFill = "FFFFFF"
series.dPt.append(point)
colour_slices(chart.series[0], PALETTE)
idx is the position of the slice in the data, starting at 0. The white outline separates adjacent slices cleanly. Keep a fixed mapping from category to colour across every chart in a report, ideally in one dictionary shared by all the chart code; a reader who learns that Online is indigo on one chart will read the next one wrong if it is teal there.
Step 3 — Percentage labels that can be read
Show each slice's share as a percentage, without the legend-key square Excel adds by default, in white bold text so it stays legible on dark colours:
from openpyxl.chart.label import DataLabelList
from openpyxl.chart.text import RichText
from openpyxl.drawing.text import CharacterProperties, Paragraph, ParagraphProperties
def percent_labels(chart, colour="FFFFFF", size=1100):
labels = DataLabelList()
labels.showPercent = True
for flag in ("showVal", "showCatName", "showSerName", "showLegendKey", "showLeaderLines"):
setattr(labels, flag, False)
font = CharacterProperties(sz=size, b=True, solidFill=colour)
labels.txPr = RichText(p=[Paragraph(pPr=ParagraphProperties(defRPr=font), endParaRPr=font)])
chart.dataLabels = labels
percent_labels(chart)
Excel works out the percentages from the values, so they always add up to 100% of what is plotted. Each show... flag controls one part of the label; turning off everything but the percentage keeps the ring uncluttered, with the category names in the legend. The font size is in hundredths of a point — 1100 is 11pt. Style Chart Colours and Data Labels with openpyxl covers label options in general.
Step 4 — Put the total in the title
A doughnut's hole is the obvious place for the total, but Excel charts have no built-in text in the hole, and a floating text box drawn by code does not move with the chart. The robust alternative is to compute the total and put it in the title:
total = sum(ws.cell(r, 3).value for r in range(2, 6))
chart.title = f"Revenue by channel, 2026 — total {total:,.0f}"
chart.firstSliceAng = 0
wb.save("channel_mix.xlsx")
print(chart.title.tx.rich.p[0].r[0].t)
Because the title is text, it does not update when the data changes; regenerate the workbook when the numbers change, which is what a scheduled report does anyway. firstSliceAng rotates the ring — 0 starts the first slice at 12 o'clock, which is where readers look first, so put the category you want noticed first in the data.
Step 5 — Two rings to compare two periods
Adding a second series draws a second ring. With 2025 on the inside and 2026 outside, a reader can see Partners growing and Retail shrinking:
two = DoughnutChart()
two.title = "Channel mix: 2025 inside, 2026 outside"
two.add_data(Reference(ws, min_col=2, max_col=3, min_row=1, max_row=5), titles_from_data=True)
two.set_categories(Reference(ws, min_col=1, min_row=2, max_row=5))
two.holeSize = 40
for s in two.series:
colour_slices(s, PALETTE)
percent_labels(two, size=900)
two.width, two.height = 13, 9
ws.add_chart(two, "A27")
wb.save("channel_mix.xlsx")
print([s.tx.strRef.f for s in two.series])
The first series becomes the inner ring. In the rendered chart, Online's share rises from 41% to 46% and Partners' from 6% to 8%, while Retail falls from 34% to 30% — the labels on the two rings sit next to each other, so the change reads without any arithmetic. Two rings are the most a doughnut can carry before it turns into a puzzle; for more periods, a stacked 100% column chart — Create a Stacked Column Chart with openpyxl — shows the same shares side by side and is easier to compare.
Step 6 — Check the chart without Excel
Charts written by openpyxl are only XML until an application draws them. Render the workbook to PDF with LibreOffice to check colours, labels and placement:
soffice -env:UserInstallation=file://$PWD/lo_profile --headless --convert-to pdf channel_mix.xlsx > /dev/null 2>&1
pdftoppm -png -r 60 -f 1 -l 1 channel_mix.pdf preview && ls preview*
The rendered page shows both charts with the four brand colours, white percentages on each slice and the legend on the right. LibreOffice's rendering is close to Excel's for doughnut charts; label positions can differ by a few pixels. Where the chart is anchored matters for printing: a chart anchored at column E with a width of 13 cm runs past the edge of a portrait page and gets cut off, legend first. Anchoring below the data, as here, or setting the sheet to landscape and fit-to-width keeps the whole chart on the page — which is also how the PDF check catches a layout problem before a reader does.
When not to use a doughnut
Doughnuts compare parts of one whole, with few parts. Beyond five or six slices the small ones become unreadable, and comparing slices across two doughnuts is hard for everyone. If the question is "which channel is biggest?" or "how did each channel change?", a sorted bar chart answers it faster. Use the doughnut where its strength — "this is the whole, and here is its split" — is the point, such as a single KPI block on a dashboard.
Common pitfalls
- Default hole size. 50% gives a thin ring; set
holeSizeto 55–65. - Legend-key squares in labels. Turn off
showLegendKeyon the data labels. - Dark text on dark slices. Set a label font colour with
txPr. - Inconsistent colours across charts. Set slice colours explicitly with
DataPoint. - Negative or zero values. Slices cannot be negative; a zero slice gets a label at the edge. Filter them out before charting.
Conclusion
Build a DoughnutChart from a value column and a category column, set holeSize to 55–65, colour each slice with a DataPoint from a fixed palette, and add percentage-only data labels with a white bold font through txPr. Put the total in the title, use firstSliceAng to start at the top, add a second series for a two-ring comparison, and render the workbook with LibreOffice to check it. Reach for a bar chart when there are many categories or several periods.
Frequently asked questions
How do I create a doughnut chart in Excel with Python? With openpyxl, create DoughnutChart(), add the values with add_data(Reference(...), titles_from_data=True), set the categories with set_categories, and place it with ws.add_chart(chart, "A8").
How do I change the hole size of a doughnut chart? Set chart.holeSize to a percentage between 10 and 90. The default is 50; values around 55 to 65 give a more readable ring.
How do I show percentages on a doughnut chart? Assign a DataLabelList with showPercent = True and the other show flags set to False to chart.dataLabels. Excel calculates the percentages from the plotted values.
Can I put the total in the middle of the doughnut? Excel has no built-in text for the hole. Put the total in the chart title, or place it in a cell next to the chart, so it stays attached when the chart is moved.
Related
- Up one level: Creating Charts in Excel with openpyxl — every chart type openpyxl supports.
- Create a Pie Chart in Excel with openpyxl — the doughnut's closest relative.
- Add a KPI Summary Block to an Excel Dashboard — where doughnuts usually live.
- Export an Excel Chart as an Image — using the chart outside the workbook.
More on charts & images
- How-toBuild a Dashboard Sheet with Charts from Multiple Tabs
- How-toBuild a Rolling 12-Month Chart on an Excel Dashboard
- How-toKeep Charts and Images When Filling an Excel Template
- How-toAdd a Chart to an Excel File with xlsxwriter
- How-toAdd Sparklines to an Excel Report with xlsxwriter
- How-toBuild a Waterfall Chart in Excel with Python