How-to
Formatting And Charting Excel Reports With PythonDeep dive

Create a Doughnut Chart with openpyxl

Build an Excel doughnut chart from Python: data and categories from a sheet, the hole size, one colour per slice, percentage labels that are readable on dark slices, a start angle, two rings to compare periods, the total shown in the title, and when a bar chart would say it better.

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

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

The data behind the chart A small table with channels down the side and revenue for 2025 and 2026. The 2026 column feeds the single-ring chart; both columns feed the two-ring chart. channel_mix.xlsx A B C 1 Channel 2025 2026 2 Online 355,000 412,000 3 Retail 291,000 268,000 4 Wholesale 160,000 149,000 5 Partners 52,000 71,000 Mix

Step 1 — A basic doughnut

Create a DoughnutChart, add the values with their header as the series title, and the channel names as categories:

Python
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:

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

Default labels and fixed labels Two panels comparing the default percentage labels, with legend-key squares and dark text on dark slices, and the fixed labels, with the squares removed and white bold text. Defaults small legend-key square on every label dark text on indigo and teal slices value and category shown together After Step 3 percentage only white bold text on every slice categories left to the legend

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:

Python
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:

Python
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:

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

The shares the chart shows, 2026 Horizontal bars of each channel's share of 2026 revenue — Online 46 percent, Retail 30 percent, Wholesale 17 percent, Partners 8 percent — the same numbers the doughnut labels display. Online 46% Retail 30% Wholesale 17% Partners 8% Shares of a whole are often clearer as bars; the doughnut earns its place on a dashboard with few categories.

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:

Bash
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 holeSize to 55–65.
  • Legend-key squares in labels. Turn off showLegendKey on 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.

More on charts & images

More on charts & images →