Embed a matplotlib Chart in an Excel Report
Excel's own charts cover the common shapes well, and where they do, a native chart is the better answer because it recalculates when a reader edits the numbers. But some plots have no Excel equivalent — a heatmap, a distribution, a regression fit, a grid of small multiples — and for those, rendering with matplotlib and embedding the result is the practical route.
This guide covers the mechanics that matter: getting the figure into the workbook without touching the disk, sizing it so it is sharp rather than blurry, and keeping the numbers on a sheet so the picture is not the only record. It is part of Inserting Images and Logos into Excel.
Prerequisites
pip install matplotlib openpyxl pandas
matplotlib pulls in a plotting stack of a few tens of megabytes, which is worth noting if the script is destined for a packaged executable — it is one of the biggest single contributors to bundle size.
Step 1: Render without a display
On a server or in a scheduled job there is no window system, and matplotlib's default backend will try to find one. Select the file-only Agg backend before importing pyplot:
import io
import matplotlib
matplotlib.use("Agg") # must come before pyplot is imported
import matplotlib.pyplot as plt
import pandas as pd
df = pd.DataFrame({
"month": pd.period_range("2026-01", periods=12, freq="M").astype(str),
"actual": [412, 388, 501, 466, 590, 623, 641, 598, 655, 702, 688, 731],
"budget": [400, 400, 480, 480, 560, 600, 620, 620, 660, 680, 700, 720],
})
def render_chart(frame, width_in=8.0, height_in=4.0, dpi=150):
"""Return a PNG of the figure in an in-memory buffer."""
fig, ax = plt.subplots(figsize=(width_in, height_in), dpi=dpi)
ax.plot(frame["month"], frame["actual"], marker="o", linewidth=2,
color="#5B5CF0", label="Actual")
ax.plot(frame["month"], frame["budget"], linestyle="--", linewidth=2,
color="#0F766E", label="Budget")
ax.fill_between(frame["month"], frame["actual"], frame["budget"],
where=frame["actual"] >= frame["budget"],
color="#0F766E", alpha=0.12)
ax.set_ylabel("Revenue (£000)")
ax.legend(frameon=False)
ax.grid(axis="y", color="#E4E8F2")
ax.spines[["top", "right"]].set_visible(False)
fig.autofmt_xdate(rotation=45)
fig.tight_layout()
buffer = io.BytesIO()
fig.savefig(buffer, format="png", dpi=dpi)
plt.close(fig) # release the figure, or memory climbs
buffer.seek(0)
return buffer
plt.close(fig) is the line that matters in a loop. matplotlib keeps every figure alive until it is closed, so a job generating one chart per region leaks them all and eventually warns about too many open figures — shortly before it runs out of memory.
Returning a buffer rather than a path keeps the whole operation in memory: no temporary files to clean up, no permissions to worry about, nothing left behind if the job fails halfway.
Step 2: Anchor the image with openpyxl
from openpyxl import Workbook
from openpyxl.drawing.image import Image as XLImage
wb = Workbook()
data_ws = wb.active
data_ws.title = "Data"
data_ws.append(list(df.columns))
for row in df.itertuples(index=False):
data_ws.append(list(row))
chart_ws = wb.create_sheet("Chart", 0) # first tab
buffer = render_chart(df)
img = XLImage(buffer)
img.anchor = "B3"
chart_ws.add_image(img, "B3")
chart_ws["B1"] = "Actual against budget, 2026"
chart_ws.column_dimensions["A"].width = 3
wb.save("with-chart.xlsx")
openpyxl accepts a file-like object, so the BytesIO buffer goes straight in. One caveat with in-memory images: openpyxl reads the buffer when the workbook is saved, not when add_image is called, so the buffer must still be open at save time — do not close it in between, and do not reuse one buffer for two images.
Writing the numbers to a Data sheet alongside is not optional in practice. An image is a picture; the first question anyone asks about a chart is "what was the number for June", and a workbook that cannot answer it sends them back to you.
Step 3: Size it so it stays sharp
An image inserted into Excel is placed at its pixel dimensions. Those come from figsize × dpi, so a figure at 8×4 inches and 150 DPI is 1200×600 pixels:
buffer = render_chart(df, width_in=8.0, height_in=4.0, dpi=150) # 1200 x 600 px
img = XLImage(buffer)
img.width = 800 # displayed size in pixels
img.height = 400
chart_ws.add_image(img, "B3")
Rendering larger than the display size and scaling down keeps the chart crisp on a high-DPI screen and when printed; scaling up is what produces the soft, blurry chart people complain about. A 1.5× ratio between rendered and displayed pixels is a good default.
Set the figure's font sizes explicitly if the chart will be shown small — matplotlib's defaults are sized for a full-screen figure and become unreadable at 400 pixels wide:
plt.rcParams.update({"font.size": 11, "axes.titlesize": 13,
"axes.labelsize": 11, "legend.fontsize": 10})
Step 4: The same thing with xlsxwriter
If the report is being generated with xlsxwriter, the in-memory route uses image_data:
import xlsxwriter
wb = xlsxwriter.Workbook("with-chart.xlsx")
ws = wb.add_worksheet("Chart")
buffer = render_chart(df)
ws.insert_image("B3", "trend.png", {
"image_data": buffer, # the buffer, not a file on disk
"x_scale": 0.67, "y_scale": 0.67,
"object_position": 1, # move and size with cells
})
wb.close()
The filename argument is still required even with image_data — xlsxwriter uses it only to name the part inside the archive and to infer the format, so it can be any sensible name.
object_position controls what happens when rows and columns are resized: 1 moves and sizes with cells, 2 moves but does not size, 3 does neither. For a chart beside a table whose columns a reader might widen, 2 is usually the least surprising.
Step 4b: One figure, many sheets
Where the same chart belongs on several sheets — a trend line repeated at the top of each regional tab — render it once and insert the same bytes repeatedly. Rendering per sheet costs a tenth of a second each and produces a larger file, because every insertion carries its own copy of the image:
With openpyxl, give each insertion its own XLImage wrapping a fresh BytesIO built from the same bytes — one buffer cannot be read twice — while xlsxwriter deduplicates identical images itself, so passing the same image_data to several insert_image calls stores it once.
Step 5: Choose the image only when it earns it
A rendered image cannot be restyled, does not recalculate, and does not tell the reader which cells it came from. Those are real costs, so use it where matplotlib does something Excel cannot:
| Plot | Native Excel chart? | Verdict |
|---|---|---|
| Column, bar, line, pie, scatter | Yes | Use a native chart — it recalculates |
| Heatmap of a matrix | No | Image, or conditional formatting on the cells |
| Distribution, histogram with a fitted curve | Partly | Image |
| Small multiples, a grid of facets | No | Image |
| Regression fit with a confidence band | No | Image |
| Anything the reader will edit | Yes | Native chart, always |
The middle ground worth knowing about: for a matrix heatmap, applying a colour scale to the actual cells gives you a heatmap made of real numbers, which is better than a picture of one on every count.
Common pitfalls and gotchas
| Symptom | Cause | Fix |
|---|---|---|
| Crash or hang on a server | Interactive backend, no display | matplotlib.use("Agg") before importing pyplot |
| Memory grows across a batch | Figures never closed | plt.close(fig) after each save |
| Image missing from the saved file | Buffer closed before wb.save() | Keep the buffer open until after the save |
| The same image appears twice | One buffer reused for two insertions | Render into a fresh buffer per image |
| Blurry chart | Rendered smaller than displayed | Render at 1.5× the display size |
| Labels unreadable | Default font sizes on a small figure | Set rcParams sizes explicitly |
| Image covers the table | Anchored inside the data range | Anchor to the right of the last column |
| Workbook is huge | Several high-DPI PNGs | Lower the DPI, or reuse one chart per sheet |
Performance and scale notes
Rendering is the slow part: a simple matplotlib figure takes on the order of a hundred milliseconds, so a report with a chart per region across forty regions spends several seconds purely in rendering. If the same chart appears on several sheets, render it once and insert the same PNG bytes repeatedly rather than re-plotting.
File size grows with DPI squared, so a 300-DPI chart is four times the bytes of a 150-DPI one for a difference nobody sees on screen. 150 is a good default; go to 200 only when the workbook is genuinely going to be printed.
Conclusion
Render with the Agg backend, save into a BytesIO buffer, keep that buffer open until the workbook is saved, and close every figure so a batch does not leak. Render at about 1.5 times the display size so the result is sharp rather than soft, set the font sizes for the size it will actually appear at, and write the underlying numbers to a data sheet so the picture is not the only record. Then check the plot is one Excel could not have drawn — if it is a column chart, a native chart serves the reader better.
Frequently asked questions
Should I embed an image or build a native Excel chart? A native chart when the reader may edit the numbers or wants to restyle it — it recalculates. An image when the plot is something Excel cannot draw, such as a heatmap, a regression fit or a small-multiples grid.
Do I need to write the PNG to disk first?
No. Save the figure into an io.BytesIO buffer and pass that to openpyxl's Image or xlsxwriter's insert_image with the image_data argument. Nothing touches the filesystem.
Why does the image look blurry in Excel?
It was rendered at screen DPI and then scaled up. Render at dpi=150 or 200 and set the figure size in inches so the pixel dimensions match the space it will occupy.
Does the image resize when someone changes a column width? With openpyxl the anchor is a cell reference and the image keeps its size. Excel's own move-and-size behaviour is not something openpyxl exposes, so treat the placement as fixed.
Related
Up to the parent guide:
- Inserting Images and Logos into Excel — anchoring, sizing and the image formats Excel accepts.
Related guides:
- Add a Logo Image to an Excel Report with openpyxl — the same anchoring for branding rather than data.
- Create a Bar Chart in Excel with openpyxl — the native chart to prefer where it fits.
- Add Data Bars and Colour Scales with openpyxl — a heatmap made of real cells instead of a picture.
- Add a Summary Sheet to an Excel Report in Python — where an embedded figure usually belongs.