Keep Charts and Images When Filling an Excel Template
You fill a template with openpyxl, save it, and the chart your colleague spent an afternoon on has vanished. Nothing errored. The cause is structural: openpyxl parses a workbook into an object model and writes a new file from that model, so anything it does not represent has nothing to write back. This guide explains exactly what survives, then gives three practical ways to fill a template without losing its visuals — ranked by how much control you have over the template itself. It belongs to Generating Excel Reports from Templates.
Prerequisites
pip install openpyxl pandas xlsxwriter
Establish what your template loses
Before choosing a strategy, measure. Fill nothing, save, and compare the parts inside the two zip files:
"""Diff the internal parts of a template and its re-saved copy."""
import zipfile
from openpyxl import load_workbook
wb = load_workbook("template.xlsx")
wb.save("resaved.xlsx")
def parts(path: str) -> set[str]:
with zipfile.ZipFile(path) as zf:
return {n for n in zf.namelist() if not n.startswith("docProps/")}
lost = parts("template.xlsx") - parts("resaved.xlsx")
for name in sorted(lost):
print("lost:", name)
lost: xl/charts/chart1.xml
lost: xl/drawings/drawing1.xml
lost: xl/pivotCache/pivotCacheDefinition1.xml
That list tells you exactly which strategy you need. A template that loses nothing can be filled directly, and many simple templates do survive intact.
Strategy 1: fill only the cells, let Excel redraw
The best outcome is a template whose chart points at a range that grows, so writing data is all your code has to do. Define the source as a dynamic named range in the template — using OFFSET/COUNTA or an Excel table — and the chart follows the data without any code touching the chart.
from openpyxl import load_workbook
wb = load_workbook("template.xlsx")
ws = wb["Data"]
for r, (region, revenue) in enumerate(rows, start=2):
ws.cell(row=r, column=1, value=region)
ws.cell(row=r, column=2, value=float(revenue))
wb.save("filled.xlsx")
This works only if the chart survives the round trip, so run the diff above first. Where it does survive — common for charts openpyxl can model — this is by far the cheapest option, and the template's owner keeps control of the visual.
Strategy 2: rebuild the chart after filling
If the chart does not survive, recreate it in code. You lose the designer's fine-tuning, but the output is reliable and reproducible:
from openpyxl import load_workbook
from openpyxl.chart import BarChart, Reference
wb = load_workbook("template.xlsx")
ws = wb["Data"]
# … write the data rows as above …
chart = BarChart()
chart.type = "col"
chart.title = "Revenue by region"
chart.y_axis.title = "Revenue (GBP)"
data = Reference(ws, min_col=2, min_row=1, max_row=ws.max_row)
cats = Reference(ws, min_col=1, min_row=2, max_row=ws.max_row)
chart.add_data(data, titles_from_data=True)
chart.set_categories(cats)
chart.height, chart.width = 8, 15
ws.add_chart(chart, "E2")
wb.save("filled.xlsx")
Keep the chart-building code beside the template so the two stay in step, and treat the template as owning the layout and styling while the code owns the chart. The chart options are covered in Creating Charts in Excel with openpyxl.
Strategy 3: drive an application that understands the file
When the template is too elaborate to reproduce — a formatted dashboard with pivots, slicers and hand-placed shapes — let something that fully understands the format do the writing. xlwings edits through a real Excel:
import xlwings as xw
with xw.App(visible=False, add_book=False) as app:
app.display_alerts = False
book = app.books.open("template.xlsx")
try:
sheet = book.sheets["Data"]
sheet.range("A2").value = rows # a list of lists
book.api.RefreshAll()
book.save("filled.xlsx")
finally:
book.close()
Everything is preserved because nothing was rebuilt. The cost is a Windows or macOS machine with Excel and an interactive session, which rules it out for most servers — the trade-offs are set out in Refresh Excel pivot tables and queries with Python.
Keep images by re-adding them
An image that does not survive is easy to restore, because it is just a file plus an anchor:
from openpyxl.drawing.image import Image
logo = Image("assets/logo.png")
logo.width, logo.height = 180, 48 # pixels
ws.add_image(logo, "A1")
Keep the source image beside the template in version control rather than extracting it from the workbook each run — the extraction is fragile and the file is small. Placement details are in Add a logo image to an Excel report with openpyxl.
Consider building the whole workbook instead
Templates exist so a non-developer can own the design. When that is not actually true — when the template is a file someone made once and nobody maintains — building from code with xlsxwriter is more robust: nothing to preserve, nothing to lose, and the layout is in version control with the rest of the job. The reasonable rule is to keep a template when a business owner genuinely edits it, and to generate from code otherwise. The generation route is covered in Write a formatted Excel report with xlsxwriter.
Check the output before delivering it
Whichever strategy you choose, assert that the visuals are present rather than trusting they are:
import zipfile
def assert_visuals(path: str, expect_charts: int = 1) -> None:
with zipfile.ZipFile(path) as zf:
charts = [n for n in zf.namelist() if n.startswith("xl/charts/chart")]
drawings = [n for n in zf.namelist() if n.startswith("xl/drawings/drawing")]
if len(charts) < expect_charts:
raise ValueError(f"{path}: expected {expect_charts} chart(s), found {len(charts)}")
print(f"{path}: {len(charts)} chart(s), {len(drawings)} drawing(s)")
assert_visuals("filled.xlsx", expect_charts=1)
Add it to the pre-delivery checks so a library upgrade that changes what survives is caught by the job rather than by the recipient — the same idea as Validate an Excel report before sending it.
Split the template into a data sheet and a presentation sheet
The structural fix that makes strategy 1 work reliably is to stop mixing the two concerns. Give the template a plain Data sheet that code writes to, and a Report sheet that holds every chart, formula and piece of formatting, referencing Data by range. Code then touches only cells on a sheet with nothing to lose.
from openpyxl import load_workbook
wb = load_workbook("template.xlsx")
data = wb["Data"]
# clear last run's rows without touching anything else
if data.max_row > 1:
data.delete_rows(2, data.max_row - 1)
for r, (region, revenue) in enumerate(rows, start=2):
data.cell(row=r, column=1, value=region)
data.cell(row=r, column=2, value=float(revenue))
wb.save("filled.xlsx")
delete_rows keeps the sheet clean between runs, so a month with fewer rows does not leave last month's tail behind the new data — a subtle bug that shows up as a chart with a flat, wrong final segment.
That separation also settles ownership: the business owner edits the report sheet freely, and the only contract between them and the code is the column layout of the data sheet.
Common pitfalls and gotchas
- Assuming an error would have told you. The loss is silent; only a check catches it.
keep_vbaas a fix. It preserves macros in.xlsm, not charts or pivots.- Renaming a sheet the chart references. That breaks the chart even when the object survives.
- Writing over the template. Always write to a new path, so a bad run does not destroy the source.
- A chart range that does not grow. Filling more rows than the template's fixed range leaves the extra data out of the chart.
Performance and scale notes
Strategies 1 and 2 are pure Python and run in milliseconds per report, which matters when generating one workbook per region in a loop. Strategy 3 costs an application start of seconds and serialises — one Excel instance at a time — so a hundred reports become a batch job rather than a loop. That difference is usually decisive: if the report set is large, invest in reproducing the template in code once rather than paying for an application launch on every run. Where a template must be filled per recipient, the loop pattern is in Generate one Excel report per region in a loop.
Conclusion
openpyxl rewrites the workbook rather than editing it, so anything outside its object model disappears on save. Diff the zip parts to learn what your template actually loses, then pick the cheapest strategy that works: write only cells when the visuals survive, rebuild charts in code when they do not, and drive Excel only when the template is genuinely too elaborate to reproduce. Whichever you choose, assert that the charts and drawings are present in the output before it goes out.
Frequently asked questions
Why does openpyxl delete my chart? openpyxl reads a workbook into its own object model and writes a new file from it. Anything it does not model — most charts it did not create, some images, pivot tables, slicers, form controls — has nothing to write back and disappears.
Which objects survive a round trip? Cell values, formulas, most styles, conditional formatting, merged cells, defined names and print settings generally survive. Charts, pivot caches, slicers, form controls and some drawings often do not.
What is the safest way to fill a template with a chart? Point the chart at a dynamic range and write only the data cells, then have Excel redraw the chart on open. Where a chart must be redrawn server-side, rebuild it with openpyxl after filling.
Does keep_vba help?
It preserves macros in an .xlsm, which is a different problem. It does not preserve charts or pivot tables.
Can I avoid templates entirely? Often yes, and it is usually the more robust choice — build the whole workbook from code with xlsxwriter, so nothing depends on preserving objects a library cannot model.
Related
- Up: Generating Excel Reports from Templates — the template workflow this problem interrupts.
- Populate an Excel template without losing formatting — the styles side of the same round trip.
- Fill an Excel template with Python and openpyxl — the basic fill this page hardens.
- Creating Charts in Excel with openpyxl — rebuilding the chart the template lost.
- Work with macro-enabled xlsm files in openpyxl — the related preservation problem for VBA.