Create a Dumbbell Chart in Excel with Python
When a report compares two values for each of several categories — this year against last year, before and after a change, target against actual — a clustered bar chart is the usual choice, and it is often a poor one. The eye has to compare the heights of pairs of bars and work out which pairs moved the most. A dumbbell chart shows the same data as two dots on a line for each category, joined by a bar: the length of the bar is the change, its direction is obvious from which dot is on top, and categories that hardly moved shrink to two overlapping dots. Excel has no dumbbell chart type, but it can draw one from parts it does have, and openpyxl can write those parts. This page builds a native, editable dumbbell chart from a small table of satisfaction scores, sorts it by the size of the change, and renders it to check the result. It belongs to Creating Charts in Excel with openpyxl.
Prerequisites
pip install openpyxl pandas
LibreOffice is used at the end to render the chart to an image; it is optional.
How the chart is built
The trick is to make Excel draw three things it already knows how to draw. A line chart with two series — the earlier and the later value — gives the dots, once the lines are switched off and markers switched on. The connector between each pair of dots comes from error bars: a third, invisible series sits at the lower of the two values, and a custom error bar extends upwards from it by the gap between them. Because both values are plotted on the same category axis, the error bar runs exactly from one dot to the other. Everything stays a normal chart that people can restyle in Excel, and it updates when the numbers in the sheet change.
Step 1 — The data, sorted by change, with two helper columns
import pandas as pd
from openpyxl import Workbook
scores = pd.DataFrame({
"Region": ["North", "South", "East", "West", "Export", "Online"],
"2025": [62, 58, 44, 70, 35, 66],
"2026": [71, 54, 57, 73, 49, 64],
})
scores = scores.assign(change=scores["2026"] - scores["2025"]).sort_values("change", ascending=False)
wb = Workbook()
ws = wb.active
ws.title = "Satisfaction"
ws.append(["Region", "2025", "2026", "Low", "Gap"])
for i, row in enumerate(scores.itertuples(index=False), start=2):
ws.append([row.Region, row._1, row._2, f"=MIN(B{i}:C{i})", f"=ABS(C{i}-B{i})"])
n = len(scores) + 1
print(scores.drop(columns="change").to_string(index=False))
The table is sorted by the change from one year to the next, largest improvement first, so the chart reads from the best story to the worst — the order in which a reader wants to discuss it. The two helper columns are formulas, not values computed in Python: Low is the smaller of the two scores and Gap the distance between them. Keeping them as formulas means that if someone corrects a score in the sheet, the connector moves with it. The helper columns can be hidden or placed out of the way; the chart needs them, the reader does not.
Step 2 — The marker series and the connector
from openpyxl.chart import LineChart, Reference
from openpyxl.chart.data_source import NumDataSource, NumRef
from openpyxl.chart.error_bar import ErrorBars
from openpyxl.chart.legend import LegendEntry
from openpyxl.chart.shapes import GraphicalProperties
from openpyxl.drawing.line import LineProperties
chart = LineChart()
chart.add_data(Reference(ws, min_col=4, min_row=1, max_row=n), titles_from_data=True) # Low (helper)
chart.add_data(Reference(ws, min_col=2, max_col=3, min_row=1, max_row=n), titles_from_data=True)
chart.set_categories(Reference(ws, min_col=1, min_row=2, max_row=n))
helper, earlier, later = chart.series
helper.graphicalProperties.line.noFill = True
helper.marker.symbol = "none"
helper.errBars = ErrorBars(errBarType="plus", errValType="cust", noEndCap=True,
plus=NumDataSource(numRef=NumRef(f=f"'{ws.title}'!$E$2:$E${n}")),
spPr=GraphicalProperties(ln=LineProperties(solidFill="BDBDBD", w=50800)))
for series, colour in [(earlier, "A5A5A5"), (later, "1F5FA8")]:
series.graphicalProperties.line.noFill = True
series.marker.symbol = "circle"
series.marker.size = 11
series.marker.graphicalProperties = GraphicalProperties(solidFill=colour)
series.marker.graphicalProperties.line.solidFill = colour
The helper series is added first so it is drawn underneath, and its line and markers are both switched off — only its error bars are visible. The error bars are custom: instead of a fixed amount or a percentage, each point's bar length comes from the Gap column through a cell reference, so every category gets its own connector. errBarType="plus" draws the bar upwards only, from the lower dot to the higher one, and noEndCap removes the little crossbar Excel puts at the end of error bars by default. A wide grey line (50,800 EMUs is 4 points) reads as a bar rather than a whisker.
The two score series keep their markers and lose their lines. A muted grey for the earlier year and a strong colour for the later one puts the visual weight on the current figure, which is usually the one being discussed.
Step 3 — Axes, legend and placement
chart.title = "Customer satisfaction by region, 2025 → 2026"
chart.y_axis.title = "Score"
chart.y_axis.scaling.min, chart.y_axis.scaling.max = 30, 80
chart.y_axis.majorUnit = 10
chart.x_axis.delete = chart.y_axis.delete = False
chart.legend.position = "b"
chart.legend.legendEntry = [LegendEntry(idx=0, delete=True)] # hide "Low" from the legend
chart.width, chart.height = 18, 9
ws.add_chart(chart, "A10")
ws.column_dimensions["D"].hidden = ws.column_dimensions["E"].hidden = True
chart.visible_cells_only = False # plot the hidden helper columns
wb.save("satisfaction_dumbbell.xlsx")
print("saved")
The vertical axis starts at 30 rather than zero. That is acceptable here, unlike for a bar chart: a dumbbell's message is in the distance between dots, not in the length from the axis, so a cropped axis makes the changes easier to see without misleading. The helper series would otherwise appear in the legend as Low; a LegendEntry with delete=True removes it by its index. Both axes are explicitly kept, because openpyxl writes charts whose axes Excel hides unless told otherwise. Finally the helper columns are hidden, and that needs one more setting. By default openpyxl marks a chart to plot visible cells only, which is also Excel's default, so hiding the helper columns would silently remove the connectors. Setting visible_cells_only = False is the code equivalent of ticking Show data in hidden rows and columns in Excel's chart settings. The first rendering of this chart, made before that line was added, showed the dots without a single connector — a good example of why a quick render is worth doing.
Step 4 — Render it to check
soffice --headless --convert-to pdf satisfaction_dumbbell.xlsx > /dev/null 2>&1
pdftoppm -r 70 -f 1 -l 1 -png satisfaction_dumbbell.pdf preview && ls preview*
Converting the workbook to PDF with LibreOffice and looking at the image is a quick way to check a chart built from parts, because a mistake — the helper series visible, error bars pointing the wrong way — is obvious at a glance. In the rendered chart, East and Export show the longest connectors, with the blue dot on top for an improvement; South and Online have short connectors with the grey dot on top, because their scores fell; and West is almost a single dot.
A note on high-low lines
Excel offers another way to draw the connectors: high-low lines, an option on line charts that joins the highest and lowest value in each category, which openpyxl exposes as chart.hiLowLines. In Excel the result looks the same and needs no helper columns. The catch is portability — LibreOffice ignores high-low lines on ordinary line charts, so the connectors vanish when the file is opened there or converted to PDF on a server without Excel. Error bars are drawn by both, which is why this page uses them. If the workbook will only ever be opened in Excel, high-low lines are a simpler option worth knowing.
Common pitfalls
- Lines left on. Set
line.noFillon every series or the dots are joined across categories. - Error bars without
cust. Fixed or percentage bars do not match each pair's gap. - The helper in the legend. Remove it with
LegendEntry(idx=0, delete=True). - Unsorted categories. Sort by change so the chart has a reading order.
- Hidden helper columns, invisible connectors. Set
visible_cells_only = False. - Gap computed in Python. Use formulas so corrections in the sheet move the connectors.
Conclusion
A dumbbell chart shows two values per category and the change between them more clearly than paired bars. With openpyxl, build it from a line chart: two marker-only series for the values, and an invisible helper series at the lower value whose custom error bars, sized from a gap column, draw the connectors. Sort categories by change, hide the helper from the legend and the sheet while letting the chart plot hidden cells, keep the axes visible, and render the workbook once to check.
Frequently asked questions
Does Excel have a dumbbell chart type? No; it is built from a line or scatter chart with markers only, plus error bars or high-low lines for the connectors.
How do I add custom error bars in openpyxl?
Create ErrorBars(errValType="cust", plus=NumDataSource(numRef=NumRef(f="'Sheet'!$E$2:$E$7"))) and assign it to the series' errBars.
How do I hide one series from the chart legend?
Set chart.legend.legendEntry = [LegendEntry(idx=0, delete=True)], using the series' index.
Can the value axis start above zero in a dumbbell chart? Yes; the message is the distance between the dots, so starting the axis near the data makes changes easier to see without distorting them.
Related
- Up one level: Creating Charts in Excel with openpyxl — native Excel charts from Python.
- Style Chart Colours and Data Labels with openpyxl — marker and line styling.
- Add Error Bars to an Excel Chart with openpyxl — error bars in general.
- Export an Excel Chart as an Image with Python — rendering charts.
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