Add a Combo Chart with a Secondary Axis in openpyxl
Revenue in thousands and margin as a percentage cannot share a scale: plot them on one axis and the margin line lies flat along the bottom. A secondary axis solves it — bars on the left scale, a line on the right — and openpyxl builds one by combining two chart objects with a couple of axis settings that are easy to get wrong. This guide covers the construction, the two settings that decide whether the second series appears at all, and the cases where a secondary axis actively misleads. It extends Creating Charts in Excel with openpyxl.
Prerequisites
pip install openpyxl pandas
Data with two very different scales:
import pandas as pd
monthly = pd.DataFrame({
"month": ["Mar", "Apr", "May", "Jun", "Jul", "Aug"],
"revenue": [5150, 4820, 3980, 5402, 6100, 4560],
"margin": [0.152, 0.181, 0.118, 0.205, 0.228, 0.163],
})
monthly.to_excel("monthly.xlsx", index=False)
Revenue runs in the thousands, margin between 0.11 and 0.23. On one axis the margin series is invisible.
Step 1 — Build the primary chart
The bar chart owns the axes, the title and the plot area:
from openpyxl import load_workbook
from openpyxl.chart import BarChart, Reference
wb = load_workbook("monthly.xlsx")
ws = wb.active
categories = Reference(ws, min_col=1, min_row=2, max_row=7)
bars = BarChart()
bars.type = "col"
bars.title = "Revenue and margin by month"
bars.y_axis.title = "Revenue"
bars.x_axis.title = "Month"
bars.height, bars.width = 9, 18
bars.add_data(Reference(ws, min_col=2, min_row=1, max_row=7), titles_from_data=True)
bars.set_categories(categories)
titles_from_data=True with min_row=1 takes the series name from the header cell, which is why the legend reads "revenue" rather than "Series 1".
Step 2 — Build the secondary chart and set its axis
The line chart contributes only its series and a second scale. Three settings make that work:
from openpyxl.chart import LineChart
line = LineChart()
line.add_data(Reference(ws, min_col=3, min_row=1, max_row=7), titles_from_data=True)
# 1. A distinct axis id — this is what makes it a SECOND axis.
line.y_axis.axId = 200
line.y_axis.title = "Margin"
line.y_axis.numFmt = "0.0%"
# 2. Cross at the far end so it draws on the right.
line.y_axis.crosses = "max"
# 3. Suppress the duplicate category axis.
line.x_axis.delete = True
Each of the three is load-bearing:
| Setting | Without it |
|---|---|
y_axis.axId = 200 | Both charts share one axis; the line flattens |
y_axis.crosses = "max" | The second axis draws on the left, overlapping the first |
x_axis.delete = True | Two category axes render on top of each other |
The axId value is arbitrary — it just has to differ from the primary chart's, which defaults to 100. Using 200 is a convention, not a requirement.
Step 3 — Combine and anchor
The += operator merges the second chart's series into the first:
bars += line
ws.add_chart(bars, "F2")
wb.save("monthly_combo.xlsx")
Order matters. The chart on the left of the += keeps its title, axes and plot area; the one on the right contributes series only. Combining the other way round would give you a line chart with bars added, and the title and axis labels you set on the bar chart would be lost.
The complete function:
from openpyxl import load_workbook
from openpyxl.chart import BarChart, LineChart, Reference
from openpyxl.chart.marker import Marker
def add_combo_chart(ws, anchor="F2", last_row=7,
bar_col=2, line_col=3, category_col=1,
title="Revenue and margin by month",
line_format="0.0%"):
"""Bars on the primary axis, a line on a secondary axis."""
categories = Reference(ws, min_col=category_col, min_row=2, max_row=last_row)
bars = BarChart()
bars.type = "col"
bars.title = title
bars.y_axis.title = ws.cell(row=1, column=bar_col).value
bars.x_axis.title = ws.cell(row=1, column=category_col).value
bars.y_axis.numFmt = "#,##0"
bars.height, bars.width = 9, 18
bars.add_data(Reference(ws, min_col=bar_col, min_row=1, max_row=last_row),
titles_from_data=True)
bars.set_categories(categories)
line = LineChart()
line.add_data(Reference(ws, min_col=line_col, min_row=1, max_row=last_row),
titles_from_data=True)
line.y_axis.axId = 200
line.y_axis.title = ws.cell(row=1, column=line_col).value
line.y_axis.numFmt = line_format
line.y_axis.crosses = "max"
line.x_axis.delete = True
series = line.series[0]
series.smooth = False # straight segments read honestly
series.marker = Marker(symbol="circle", size=7)
series.graphicalProperties.line.width = 28_000 # EMU, roughly 2.2pt
bars += line
ws.add_chart(bars, anchor)
return bars
Two details in the series styling. smooth = False matters more than it looks — a smoothed line curves between points and implies values that were never measured, which is misleading on monthly data. And line width is set in EMU, English Metric Units, where 12,700 EMU is one point; 28,000 is a little over two points.
Step 4 — Know when a second axis misleads
A secondary axis is a genuine solution to a scale problem and a genuine way to mislead, because the relationship it appears to show depends entirely on where you put the scales.
The defensible uses are narrow: the two series measure genuinely different quantities, and the reader needs to see them against the same time axis. The indefensible use is implying a correlation, because with two free scales you can make any two series appear to move together or apart.
Two alternatives are usually better when the goal is comparison. Stack two charts sharing a category axis, so each has an honest zero-based scale:
from openpyxl.chart import BarChart, LineChart, Reference
revenue = BarChart()
revenue.type = "col"
revenue.title = "Revenue"
revenue.add_data(Reference(ws, min_col=2, min_row=1, max_row=7),
titles_from_data=True)
revenue.set_categories(categories)
revenue.height, revenue.width = 6, 18
ws.add_chart(revenue, "F2")
margin = LineChart()
margin.title = "Margin"
margin.y_axis.numFmt = "0.0%"
margin.add_data(Reference(ws, min_col=3, min_row=1, max_row=7),
titles_from_data=True)
margin.set_categories(categories)
margin.height, margin.width = 6, 18
ws.add_chart(margin, "F16")
Or index both series to a common base, so a single axis shows relative movement:
import pandas as pd
indexed = monthly.copy()
for name in ("revenue", "margin"):
indexed[name] = indexed[name] / indexed[name].iloc[0] * 100
Then one axis reading "index, first month = 100" carries both series honestly. The line-chart mechanics are covered in adding a line chart to an Excel report.
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
| Second series missing | Shared axId | Set line.y_axis.axId = 200. |
| Both axes on the left | crosses not set | line.y_axis.crosses = "max". |
| Doubled category labels | Two x-axes drawn | line.x_axis.delete = True. |
| Title and axis labels lost | Combined in the wrong order | Put the chart owning the axes on the left of +=. |
| Legend reads "Series 1" | Header not included | min_row=1 with titles_from_data=True. |
Margin axis shows 0.15 | No number format | line.y_axis.numFmt = "0.0%". |
| The line implies values between points | Smoothing on | series.smooth = False. |
| Chart is tiny | Default size | Set height and width in centimetres. |
Performance and scale notes
A chart references cell ranges rather than copying values, so its cost is independent of the number of rows — the chart XML is the same size for a hundred points as for ten.
What does matter is how many points Excel has to draw. Beyond a few hundred, a line becomes an unreadable band and rendering slows noticeably; beyond a few thousand it is actively unpleasant to scroll past. Aggregate before charting rather than plotting raw transactions:
import pandas as pd
# Chart the monthly summary, not the 400,000 underlying rows.
monthly = (
raw.groupby(pd.Grouper(key="date", freq="MS"))
.agg(revenue=("amount", "sum"), margin=("margin", "mean"))
.reset_index()
)
That is the same aggregation described in grouping Excel rows by month and quarter, and it improves the chart as well as the file.
Three further notes. Put the chart's source data on its own sheet when it is a summary — a hidden _chartdata sheet keeps the visible report clean while the chart still has a real range to reference, using the hiding technique from hiding sheets, rows and columns.
openpyxl cannot preserve a chart it did not create. Loading a workbook that already contains charts and saving it drops them, so a script that adds a combo chart to an existing report must recreate every chart in that workbook, not just the new one.
Charts cannot be added in write_only mode. A large report needing both streaming and a chart has to be written in two passes: stream the data, then re-open normally and add the chart. Since the chart only references ranges, that second pass touches almost nothing and is cheap.
Conclusion
A combo chart is two openpyxl chart objects merged with +=, and the three settings on the secondary chart are what make it work: a distinct y_axis.axId, crosses = "max" so the scale draws on the right, and x_axis.delete = True so the category axis is not drawn twice. Put the chart that owns the title and axes on the left of the operator. Then be deliberate about whether a second axis is the right answer at all — it makes a small-scale series legible, but with two free scales it can imply any relationship you like, so when the point is comparison, stack two honest charts or index both series to a common base instead.
Frequently asked questions
How do I combine two chart types in openpyxl?
Build each chart separately, then add the second to the first with the += operator. The first chart owns the axes and the title; the second contributes only its series and, if configured, a secondary axis.
Why does my secondary series not appear?
Almost always the axis identifiers. The secondary chart's y_axis.axId must differ from the primary's, and its x_axis.delete must be True so the two charts do not both draw a category axis over each other.
How do I put the second axis on the right?
Set the secondary chart's y_axis.crosses to "max" so it crosses the category axis at the far end. Without that it draws on the left, overlapping the primary scale.
When is a secondary axis a bad idea? Whenever the two scales have no relationship, because the reader can be led to any conclusion by the choice of scale. Prefer two stacked charts sharing a category axis, or index both series to a common base.
Can I do this with xlsxwriter instead?
Yes, and its API is arguably clearer: build both charts, call combine on the primary, and set y2_axis on the secondary series. The concepts map directly.
Related
- Up to the parent: Creating Charts in Excel with openpyxl — the chart types this combines.
- Add a Line Chart to an Excel Report with Python — the secondary series on its own.
- Create a Bar Chart in Excel with openpyxl — the primary chart in depth.
- Add a Chart to an Excel File with xlsxwriter — the other engine's charting API.
- Group Excel Rows by Month and Quarter with pandas — producing the aggregate a chart should plot.