Generate Word Documents from Excel Rows with Python
A spreadsheet of customers, employees or course attendees often has to become a set of documents: a statement per account, an offer letter per candidate, a certificate per attendee. Word's own mail merge works for simple letters but is awkward to automate and struggles with repeating rows such as a list of invoices. Python can fill a Word template directly: python-docx opens the .docx, replaces placeholders with values from each row and saves one document per row. This page, part of Generating Excel Reports from Templates, builds account statements from two sheets — one row per account and one per open invoice.
Prerequisites
pip install python-docx pandas openpyxl
statement_template.docx is an ordinary Word document with placeholders in double braces — {{contact}}, {{balance}}, {{due_date}} — in the body, the header, the footer and a table row. statements.xlsx has an Accounts sheet, one row per account, and an Invoices sheet with each account's open invoices. For LibreOffice PDF conversion at the end, LibreOffice must be installed.
Step 1 — Replace placeholders even when Word splits them
The obvious approach — replace text inside each run — fails as soon as Word has split a placeholder across runs, which it does whenever formatting changes mid-word, after edits, or for spell-checking markers. Look at the template's second paragraph:
from docx import Document
template = Document("statement_template.docx")
print([r.text for r in template.paragraphs[2].runs])
{{balance}} is stored as two runs, {{bal and ance}}, so no single run contains it. The fix is to search the paragraph's full text, then write the replacement into the runs the match spans — the value goes into the first of them, keeping its formatting, and the rest of the placeholder is removed from the others:
import re
PLACEHOLDER = re.compile(r"\{\{\s*(\w+)\s*\}\}")
def fill_paragraph(paragraph, values):
start = 0
while True:
text = "".join(run.text for run in paragraph.runs)
m = PLACEHOLDER.search(text, start)
if not m:
return
value = str(values[m.group(1)])
pos, first = 0, True
for run in paragraph.runs:
run_start, run_end = pos, pos + len(run.text)
pos = run_end
if run_end <= m.start() or run_start >= m.end():
continue
a, b = max(m.start(), run_start) - run_start, min(m.end(), run_end) - run_start
run.text = run.text[:a] + (value if first else "") + run.text[b:]
first = False
start = m.start() + len(value)
p = template.paragraphs[2]
fill_paragraph(p, {"balance": "11,600.00", "due_date": "15 October 2026"})
print([r.text for r in p.runs], "| bold:", [r.bold for r in p.runs])
The balance lands in the first bold run, so it stays bold. Searching again from after the inserted value means a value that happens to contain braces is never treated as a new placeholder. A missing key raises KeyError with the placeholder's name — which is what you want: a letter with an unfilled field should not be produced.
Step 2 — Walk the body, tables, headers and footers
doc.paragraphs is only the body text. Placeholders in table cells, headers and footers need their own loop. Collect every paragraph in the document into one generator:
def all_paragraphs(doc):
yield from doc.paragraphs
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
yield from cell.paragraphs
for section in doc.sections:
for part in (section.header, section.footer):
yield from part.paragraphs
for table in part.tables:
for row in table.rows:
for cell in row.cells:
yield from cell.paragraphs
fresh = Document("statement_template.docx")
print(sorted({m.group(1) for p in all_paragraphs(fresh) for m in PLACEHOLDER.finditer(p.text)}))
Listing every placeholder in the template is also a useful check before a run: each name must be a column in the spreadsheet, a computed field, or a line-item field.
Step 3 — Repeat a table row for each line item
The template's table has a header row and one row of line-item placeholders. For each account, copy that row once per invoice, fill each copy, and remove the original. python-docx has no "duplicate row" method, but a row is an XML element that can be deep-copied:
import copy
def fill_line_items(table, items, template_row=1):
proto = table.rows[template_row]._tr
for item in items:
new_tr = copy.deepcopy(proto)
proto.addprevious(new_tr)
new_rows = table.rows[template_row:template_row + len(items)]
for row, item in zip(new_rows, items):
for cell in row.cells:
for paragraph in cell.paragraphs:
fill_paragraph(paragraph, item)
proto.getparent().remove(proto)
Copying the row element keeps its borders, shading and fonts. Inserting each copy before the prototype keeps the invoices in order, and removing the prototype at the end leaves no row of raw placeholders behind.
Step 4 — Format values and generate every document
Values are formatted for a reader before they go into the document: money with thousands separators and two decimals, dates written out. Then for each account, fill the line items first — so the table's placeholders are gone — and the remaining fields after:
from pathlib import Path
import pandas as pd
accounts = pd.read_excel("statements.xlsx", sheet_name="Accounts", parse_dates=["due_date"])
invoices = pd.read_excel("statements.xlsx", sheet_name="Invoices", parse_dates=["invoice_date"])
out_dir = Path("statements"); out_dir.mkdir(exist_ok=True)
def money(x):
return f"{x:,.2f}"
def long_date(d):
return f"{d.day} {d:%B %Y}"
def safe(name):
return re.sub(r"[^\w\-]+", "_", name).strip("_")
made = []
for n, acct in enumerate(accounts.itertuples(index=False), start=1):
lines = invoices[invoices["account"] == acct.account].sort_values("invoice_date")
doc = Document("statement_template.docx")
fill_line_items(doc.tables[0], [{"invoice": r.invoice, "invoice_date": long_date(r.invoice_date), "amount": money(r.amount)}
for r in lines.itertuples()])
values = {"statement_no": f"2026-09-{n:03d}", "date": long_date(pd.Timestamp("2026-09-30")),
"contact": acct.contact, "balance": money(lines["amount"].sum()), "due_date": long_date(acct.due_date),
"account_manager": acct.account_manager, "account": acct.account}
for paragraph in all_paragraphs(doc):
fill_paragraph(paragraph, values)
leftover = [p.text for p in all_paragraphs(doc) if "{{" in p.text]
assert not leftover, f"{acct.account}: unfilled {leftover}"
path = out_dir / f"statement_{acct.account}_{safe(acct.company)}.docx"
doc.save(path)
made.append(path)
print([p.name for p in made])
The final check scans the finished document for any remaining {{ — a placeholder with a typo, or one added to the template without a matching value — and stops rather than sending a letter with a gap in it. File names come from the account number and a sanitised company name, so "Brightside & Co" becomes Brightside_Co.
Step 5 — Check a finished document
Read one generated statement back to confirm what a recipient will see:
check = Document(made[2])
print(check.sections[0].header.paragraphs[0].text)
for p in check.paragraphs:
if p.text:
print(p.text)
for row in check.tables[0].rows:
print(" | ".join(c.text for c in row.cells))
print(check.sections[0].footer.paragraphs[0].text)
Step 6 — Convert the documents to PDF
Statements are usually sent as PDFs. LibreOffice converts a whole folder of documents in one headless call:
import shutil
import subprocess
soffice = shutil.which("soffice") or shutil.which("libreoffice")
if soffice:
subprocess.run([soffice, "--headless", "--convert-to", "pdf", "--outdir", str(out_dir), *map(str, made)],
check=True, capture_output=True, timeout=180)
print(sorted(p.name for p in out_dir.glob("*.pdf")))
else:
print("LibreOffice not found — skipping PDF conversion")
On Windows with Word installed, docx2pdf drives Word itself for exact Word rendering. Exporting Excel Reports to PDF compares the conversion routes, and Send Each Recipient Their Own Excel Report covers emailing each statement to its account.
Common pitfalls
- Replacing inside single runs. Word splits placeholders; search the paragraph and rewrite the spanned runs.
- Only searching
doc.paragraphs. Tables, headers and footers need their own loops. - Unformatted values.
11600.0and2026-10-15 00:00:00look wrong in a letter; format first. - Unfilled placeholders. Scan the finished document for
{{and stop if any remain. - Unsafe file names. Company names contain
&,/and spaces; sanitise them. - Editing the template in place. Always open the template fresh for each document.
Performance notes
Opening and saving a small .docx takes a few milliseconds, so thousands of documents take seconds. PDF conversion is the slow step: batch many documents into one LibreOffice call rather than starting it once per file.
Conclusion
Put {{placeholders}} in an ordinary Word template, and fill them with a function that searches each paragraph's full text and rewrites the runs a match spans, so placeholders split by Word are still found. Apply it to body paragraphs, table cells, headers and footers; repeat a template table row for each line item by deep-copying it; format values for readers; and refuse to save a document that still contains a placeholder. Convert the finished documents to PDF in one LibreOffice call.
Frequently asked questions
How do I create a Word document for each row in Excel? Design a .docx template with placeholders such as , read the spreadsheet with pandas, and for each row open the template with python-docx, replace every placeholder with the row's values, and save it under a name built from the row.
Why does python-docx not replace my placeholder? Word often splits text into several runs — when formatting changes, after an edit, or for spell-checking — so may be stored as "". Replacing inside each run misses it. Join the runs' text, find the placeholder there, and write the replacement back into the runs it spans.
How do I fill placeholders in headers, footers and tables? doc.paragraphs only covers the body text. Also walk every table's cells, and each section's header and footer, and apply the same paragraph replacement to all of them.
Should I use python-docx or docxtpl? python-docx is enough for simple placeholders and repeating table rows, as shown here. docxtpl adds Jinja templating — loops, conditions and filters written in the Word document itself — which is worth it when non-programmers maintain complicated templates.
Related
- Up one level: Generating Excel Reports from Templates — templates for reports.
- Fill Excel Template with Python openpyxl — the same idea for workbooks.
- Build an Accounts Receivable Aging Report — where the open invoices come from.
- Exporting Excel Reports to PDF — PDF conversion options.
More on report layouts & templates
- GuideBuilding Finance and Operations Reports in Excel with Python
- How-toAllocate Shared Costs Across Departments with Python
- How-toBuild a Bank Reconciliation Report with Python and Excel
- How-toBuild a Bill of Materials Cost Rollup
- How-toBuild a Break-Even Analysis in Excel with Python
- ComparisonBuild a Budget vs Actual Variance Report in Excel with Python