How-to
Automating Reporting WorkflowsDeep dive

Generate Word Documents from Excel Rows with Python

Do a mail merge in Python: one Word letter, statement or certificate per row of a spreadsheet, filled from a .docx template. Replace placeholders even when Word has split them across formatting runs, fill header, footer and table placeholders, add a row per line item, format money and dates, catch unfilled fields and convert the results to PDF.

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.

One template, one document per spreadsheet row A Word template with placeholders and the Accounts sheet are combined into one statement per account: Acme Ltd, Brightside and Co, and Corex GmbH, each with their own open invoices listed in a table. statement_template.docx {{placeholders}} + Accounts sheet Acme Ltd 2 invoices Brightside & Co 1 invoice Corex GmbH 3 invoices

Prerequisites

Bash
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:

Python
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:

Python
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:

Python
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.

Where placeholders can live in a Word document A Word document has several places to search for placeholders. The header, such as the statement number. The body paragraphs, such as the contact and balance. Table cells, such as the invoice line. The footer, such as the account number. doc.paragraphs covers only the body, so each part needs its own loop. Header {{statement_no}} Body paragraphs {{contact}}, {{balance}} Table cells {{invoice}}, {{amount}} Footer {{account}}

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:

Python
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:

Python
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:

Python
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)
The invoice table in the Corex GmbH statement The table in the generated statement for Corex GmbH after filling: a header row, then one row per open invoice in date order — INV-2071 on 30 May 2026 for 1,800.00, INV-2120 on 25 July 2026 for 6,900.00 and INV-2230 on 2 September 2026 for 450.50. The placeholder row from the template has been removed. statement_C-307_Corex_GmbH.docx — invoice table A B C 1 Invoice Date Amount 2 INV-2071 30 May 2026 1,800.00 3 INV-2120 25 July 2026 6,900.00 4 INV-2230 2 September 2026 450.50

Step 6 — Convert the documents to PDF

Statements are usually sent as PDFs. LibreOffice converts a whole folder of documents in one headless call:

Python
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.0 and 2026-10-15 00:00:00 look 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.

More on report layouts & templates

More on report layouts & templates →