Guide

Automating Reporting WorkflowsDeep dive

Fill an Excel Template with Python and openpyxl

Load a prepared .xlsx template with openpyxl, set named cells for title, date and totals, fill a repeating data table from a list or DataFrame, and save a new file.

This guide is the hands-on companion to Generating Excel Reports from Templates. The job is concrete: you have a branded .xlsx, and on every run you need to drop today's data into it and emit a fresh file. With openpyxl that is a load, a handful of cell assignments, a loop over your data rows, and a save under a new name. The care is in the details — the right start row, merged cells, and not writing over formulas.

Prerequisites and install

You need Python 3.8+ and openpyxl:

Bash
pip install openpyxl pandas

pandas is optional — the table-fill loop works from any iterable — but it is the usual source of report data, so we show both.

Step 1: Generate a sample template

In production this file is handed to you. Here we build it so every later block runs. It has a merged title, a label/value pair for the report date, a styled header row at row 4, and a totals formula at row 10:

Python
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment

wb = Workbook()
ws = wb.active
ws.title = "Report"

ws["A1"] = "Weekly Orders Report"
ws["A1"].font = Font(size=16, bold=True, color="1F4E78")
ws.merge_cells("A1:C1")

ws["A2"] = "Report date:"
ws["A2"].font = Font(italic=True)

header_fill = PatternFill("solid", fgColor="4472C4")
for col, name in enumerate(["Customer", "Orders", "Total"], start=1):
    c = ws.cell(row=4, column=col, value=name)
    c.font = Font(bold=True, color="FFFFFF")
    c.fill = header_fill
    c.alignment = Alignment(horizontal="center")

ws["A10"] = "Total"
ws["A10"].font = Font(bold=True)
ws["C10"] = "=SUM(C5:C9)"          # live formula over the data range
ws["C10"].number_format = "#,##0.00"

wb.save("orders_template.xlsx")
print("Wrote orders_template.xlsx")

Step 2: Load the template and set named cells

Open the file with load_workbook, select the sheet, and assign the one-off cells: the report date and anything else that lives at a fixed address. Writing ws["B2"] = value sets the value and leaves that cell's existing style untouched:

Python
from datetime import date
from openpyxl import load_workbook

wb = load_workbook("orders_template.xlsx")
ws = wb["Report"]

ws["B2"] = date.today().isoformat()    # the report-date value cell

print("Date cell set to", ws["B2"].value)

These are your "named" cells in the informal sense — known fixed addresses for known fields. If you prefer real Excel named ranges, look them up with wb.defined_names and resolve the destination, but fixed addresses are simpler and just as robust for a template you control.

Step 3: Fill the repeating data table

The body of the report starts at the first row under the header. Our header is row 4, so data begins at row 5. Loop your records, computing each target row as START_ROW + offset:

Python
from datetime import date
from openpyxl import load_workbook

# Your data, as a list of tuples (or DataFrame rows — see below)
rows = [
    ("Acme Co", 14, 2380.00),
    ("Globex", 9, 1755.50),
    ("Initech", 21, 3990.75),
]

wb = load_workbook("orders_template.xlsx")
ws = wb["Report"]
ws["B2"] = date.today().isoformat()

START_ROW = 5
for offset, (customer, orders, total) in enumerate(rows):
    r = START_ROW + offset
    ws.cell(row=r, column=1, value=customer)
    ws.cell(row=r, column=2, value=orders)
    ws.cell(row=r, column=3, value=total)

wb.save("orders_report.xlsx")
print(f"Filled {len(rows)} rows starting at row {START_ROW}")

From a DataFrame, the only change is the source of the loop. Use itertuples so column access stays explicit:

Python
import pandas as pd
from openpyxl import load_workbook

df = pd.DataFrame({
    "customer": ["Acme Co", "Globex", "Initech"],
    "orders": [14, 9, 21],
    "total": [2380.00, 1755.50, 3990.75],
})

wb = load_workbook("orders_template.xlsx")
ws = wb["Report"]

START_ROW = 5
for offset, rec in enumerate(df.itertuples(index=False)):
    r = START_ROW + offset
    ws.cell(row=r, column=1, value=rec.customer)
    ws.cell(row=r, column=2, value=rec.orders)
    ws.cell(row=r, column=3, value=rec.total)

wb.save("orders_report.xlsx")
print(f"Wrote {len(df)} rows from DataFrame")

Step 4: Save as a new file — do not overwrite the template

Notice every save above targets orders_report.xlsx, never orders_template.xlsx. The template is input; overwrite it once and the next run has nowhere to start from. Make it impossible to get wrong by stamping the output name and guarding against clobbering the source:

Python
from datetime import date
from pathlib import Path
from openpyxl import load_workbook

TEMPLATE = "orders_template.xlsx"
out = f"orders_report_{date.today():%Y%m%d}.xlsx"
assert Path(out).resolve() != Path(TEMPLATE).resolve(), "Refusing to overwrite the template"

wb = load_workbook(TEMPLATE)
wb["Report"]["B2"] = date.today().isoformat()
wb.save(out)
print(f"Wrote {out}")

Common pitfalls

SymptomCauseFix
Template loses all data next runSaved output back over the template pathAlways save() to a new, dated filename; assert it differs from the template
First data row lands on the headerOff-by-one start rowSet START_ROW to the row below the header (header at 4 → data at 5)
Value written to a merged cell does not appearWrote to a non-anchor cell of a mergeWrite only to the top-left (anchor) cell of the merged range
Totals show a static number, not a live sumWrote a value into the formula cellNever write to formula cells; fill the data they reference and let Excel recalc
Date sorts as text in ExcelStored an ISO stringAssign a datetime.date/datetime object and set cell.number_format
InvalidFileException on loadFile is legacy .xlsConvert to .xlsx first; openpyxl reads only .xlsx/.xlsm/.xltx

A note on merged cells

A merged range in Excel is backed by a single real cell — the top-left anchor. Assigning to any other cell in the range silently does nothing visible. If your template merges, say, A1:C1 for a title, write to A1. To find anchors programmatically, inspect ws.merged_cells.ranges.

Frequently asked questions

How do I fill more rows than the template's formula covers? Track your row count and rewrite the formula to match: ws[f"C{last+1}"] = f"=SUM(C5:C{last})". Or size the template's data block for your largest run so the formula never needs touching.

Can I clear old data before filling? If the template ships empty there is nothing to clear. If it carries sample rows, set those cells to None first, but leave the styling alone — assigning None to cell.value clears the value without disturbing the format.

Do I need data_only=True when loading? No, and avoid it here. data_only=True returns Excel's last cached values and reading mode loses formulas. Load normally so formulas pass through untouched.

What if the sheet name is unknown? Use wb.active for the default sheet, or wb.sheetnames to list them and pick by position. Hard-coding the name is fine when you own the template.

Conclusion

Filling a template is load, set the fixed cells, loop the data table from the correct start row, and save a new dated file. The mistakes are predictable — overwriting the template, an off-by-one start row, writing to a merged non-anchor cell, or clobbering a formula — and every one is avoided by writing only to data cells and never to the template path. With those guardrails the same script runs unattended for years against the same designer-owned file.

Where to go next