Dynamic Mail Merge with Python

Word's built-in mail merge tops out fast: it requires a running Word instance, chokes on conditional logic beyond simple if/else, and produces no audit trail. Python with docxtpl replaces it with a repeatable script that renders hundreds of documents in a single pass — no GUI, no manual field mapping, no version drift between runs.

This guide covers the full workflow: authoring a .docx template with Jinja2 {{ placeholders }}, loading a CSV or Excel data source with pandas, rendering one document per data row, using conditional sections and in-template loops, and naming the output files deterministically. Once a batch is rendered you can chain it straight into Converting DOCX to PDF with Python for print-ready output, or embed per-row charts and logos as shown in Inserting Images into Word Documents.

Prerequisites

# pip install docxtpl pandas openpyxl
pip install docxtpl pandas openpyxl

docxtpl builds on python-docx internally — you do not install it separately. openpyxl is needed only when the data source is an .xlsx file.

Create the working tree before running any script:

mkdir -p project/{templates,data,output}

Expected files:

  • project/templates/letter_template.docx — your Jinja2-tagged Word template
  • project/data/recipients.csv — one row per output document
  • project/output/ — rendered .docx files land here

Inspect the Data Source First

Before writing the template, confirm exact column names. Typos between a CSV header and a template placeholder are the leading cause of UndefinedError at render time.

# pip install pandas openpyxl
import pandas as pd
from pathlib import Path

DATA = Path("project/data/recipients.csv")

try:
    df = pd.read_csv(DATA)
except FileNotFoundError as exc:
    raise SystemExit(f"Data file not found: {exc}")

print("Columns:", df.columns.tolist())
print("Rows:", len(df))
print(df.head(3).to_string())

For an Excel source swap pd.read_csv for pd.read_excel(DATA, engine="openpyxl"). The rest of the pipeline is identical. See Reading Excel Files with Python for multi-sheet and engine selection details.

Sample output you want to verify before proceeding:

Columns: ['first_name', 'last_name', 'company', 'invoice_date', 'total_due', 'tier']
Rows: 42
  first_name last_name        company invoice_date  total_due    tier
0      Alice     Smith  Acme Corp Ltd   2026-06-01    1250.00  premium
1        Bob     Jones      Beta LLC   2026-06-03     480.50  standard
2    Charlie     Brown    Gamma Inc.   2026-06-05    3200.00  premium

Write down these exact column names — they become your template placeholder names.

Step 1 — Author the Word Template

Open a new .docx in Word (or LibreOffice Writer). Type your letter content and insert {{ column_name }} exactly where each data value belongs. The delimiters must appear as a single continuous text run; if Word splits them across formatting runs the placeholder will silently fail to render.

Tip: Type the placeholder, select it, and apply a character style. This forces Word to keep it in one XML run.

A minimal invoice letter template body:

Dear {{ first_name }} {{ last_name }},

Thank you for your business with {{ company }}.

{% if tier == 'premium' %}
As a Premium client you receive priority processing on all orders.
{% endif %}

Invoice date: {{ invoice_date }}
Amount due:   {{ total_due }}

Items ordered:
{%tr for item in line_items %}
  {{ item.description }}   Qty: {{ item.qty }}   Price: {{ item.unit_price }}
{%tr endfor %}

Regards,
Billing Team

Key syntax:

  • {{ variable }} — scalar substitution
  • {% if condition %}...{% endif %} — conditional section (the entire paragraph is removed when false)
  • {%tr for item in items %}...{%tr endfor %} — table row loop; must live inside a Word table row, not a paragraph

Save as project/templates/letter_template.docx.

Step 2 — Build the Context Dictionary

Each call to template.render() takes a plain Python dict. Build one per data row, converting types that Jinja2 cannot format automatically.

# pip install pandas
import pandas as pd


def build_context(row: pd.Series) -> dict:
    """Convert a DataFrame row to a render-ready context dict."""
    ctx: dict = row.to_dict()

    # Format date — raw pandas Timestamp renders as ugly repr
    try:
        ctx["invoice_date"] = pd.to_datetime(ctx["invoice_date"]).strftime("%B %d, %Y")
    except (ValueError, TypeError):
        ctx["invoice_date"] = str(ctx.get("invoice_date", ""))

    # Format currency
    try:
        ctx["total_due"] = f"${float(ctx['total_due']):,.2f}"
    except (ValueError, TypeError):
        ctx["total_due"] = str(ctx.get("total_due", "0.00"))

    # Provide a default for every optional key the template references
    ctx.setdefault("tier", "standard")

    # Nested list for the table loop — empty list collapses the loop cleanly
    ctx["line_items"] = ctx.get("line_items", [])

    return ctx

Providing defaults with setdefault prevents UndefinedError when a CSV row has a missing value. For a full explanation of that error and all its variants see Fix docxtpl Jinja2 UndefinedError.

Template + Data → Render Loop → One File per Row

Every mail merge job has the same shape: a single static template plus a table of variable data, pushed through a render step that emits one .docx per row. Keep this flow in mind for the rest of the page — each step below maps to one box in the diagram.

Mail merge data flow A static template.docx carrying Jinja2 placeholders and a CSV or Excel data source with rows 1 through N both feed into a Python render loop that calls build_context(row) then template.render(ctx). The loop emits one rendered .docx file per data row: invoice_001.docx, invoice_002.docx, through invoice_N.docx. template.docx {{ placeholders }} CSV / Excel row 1 … row N Render loop for each row → build_context(row) template.render(ctx) invoice_001.docx row 1 rendered invoice_002.docx row 2 rendered invoice_N.docx row N rendered one output file per data row

Step 3 — Render One Document per Row

# pip install docxtpl pandas openpyxl
import logging
from pathlib import Path

import pandas as pd
from docxtpl import DocxTemplate

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(message)s",
    handlers=[logging.FileHandler("merge.log"), logging.StreamHandler()],
)

TEMPLATE = Path("project/templates/letter_template.docx")
DATA     = Path("project/data/recipients.csv")
OUT_DIR  = Path("project/output")


def build_context(row: pd.Series) -> dict:
    ctx: dict = row.to_dict()
    try:
        ctx["invoice_date"] = pd.to_datetime(ctx["invoice_date"]).strftime("%B %d, %Y")
    except (ValueError, TypeError):
        ctx["invoice_date"] = str(ctx.get("invoice_date", ""))
    try:
        ctx["total_due"] = f"${float(ctx['total_due']):,.2f}"
    except (ValueError, TypeError):
        ctx["total_due"] = "0.00"
    ctx.setdefault("tier", "standard")
    ctx["line_items"] = ctx.get("line_items", [])
    return ctx


def render_row(template_path: Path, ctx: dict, output_path: Path) -> bool:
    try:
        tpl = DocxTemplate(str(template_path))   # fresh instance per row — prevents state bleed
        tpl.render(ctx)
        output_path.parent.mkdir(parents=True, exist_ok=True)
        tpl.save(str(output_path))
        return True
    except Exception as exc:
        logging.error("Render failed for %s: %s", output_path.name, exc)
        return False


def run_batch() -> None:
    if not TEMPLATE.exists():
        raise FileNotFoundError(f"Template not found: {TEMPLATE}")

    try:
        df = pd.read_csv(DATA)
    except FileNotFoundError as exc:
        raise SystemExit(f"Data file missing: {exc}") from exc

    logging.info("Loaded %d rows from %s", len(df), DATA)
    ok = 0

    for idx, row in df.iterrows():
        ctx = build_context(row)
        # Deterministic filename: sanitize the primary-key column
        safe_id = str(ctx.get("client_id", idx)).replace(" ", "_").replace("/", "-")
        out = OUT_DIR / f"invoice_{safe_id}.docx"

        if render_row(TEMPLATE, ctx, out):
            ok += 1
            logging.info("OK  %s", out.name)

    logging.info("Done: %d/%d succeeded", ok, len(df))


if __name__ == "__main__":
    run_batch()

The template is re-opened per iteration (DocxTemplate(...) inside the loop). This is intentional — reusing a single instance can carry rendered content from one row into the next.

Fresh DocxTemplate per row prevents state bleed Two panels compared. Left, reuse one instance: row 1 and row 2 both feed the same DocxTemplate object; out 1 is correct but out 2 contains rows 1 and 2 because render() mutates the loaded document in place, so text leaks between rows. Right, a fresh DocxTemplate(path) is constructed each loop iteration; row 1 goes through instance 1 to a clean out 1 and row 2 through instance 2 to a clean out 2, keeping every render isolated. render() mutates the loaded document in place Reuse one instance one DocxTemplate object Row 1 Row 2 tpl shared Row 1 Rows 1 + 2 row 1 text leaks into row 2 Fresh instance per row DocxTemplate(path) each loop Row 1 Row 2 tpl #1 fresh tpl #2 fresh Row 1 Row 2 each render fully isolated

Step 4 — Conditional Sections

A conditional section shows or hides a paragraph depending on a value in the context dict.

In the template (one paragraph per branch):

{% if tier == 'premium' %}
As a Premium client you receive priority SLA and a dedicated account manager.
{% endif %}

{% if total_overdue_days > 30 %}
IMPORTANT: Your account is {{ total_overdue_days }} days overdue. Please remit immediately.
{% endif %}

In build_context, map the raw column to a typed Python value:

# pip install pandas
import pandas as pd


def build_context(row: pd.Series) -> dict:
    ctx: dict = row.to_dict()
    ctx["tier"] = str(ctx.get("tier", "standard")).lower()
    try:
        ctx["total_overdue_days"] = int(ctx.get("total_overdue_days", 0))
    except (ValueError, TypeError):
        ctx["total_overdue_days"] = 0
    return ctx

The {% if %} tag evaluates Python truthiness, so passing an integer 0 correctly suppresses the overdue block while 31 renders it.

Step 5 — Table Row Loops

When each recipient has a variable number of line items the template needs a {%tr %} loop. Place the loop tag in a Word table:

| {%tr for item in line_items %} | | | | {{ item.description }} | {{ item.qty }} | {{ item.unit_price }} | | {%tr endfor %} | | |

(In the actual .docx the tags occupy a full table row each, not a Markdown table.)

Build the line_items list from a second CSV or a JSON column:

# pip install pandas
import json
import pandas as pd


def build_context_with_items(row: pd.Series) -> dict:
    ctx: dict = row.to_dict()

    # Option A: items encoded as JSON string in a CSV column
    raw_items = ctx.get("line_items_json", "[]")
    try:
        items = json.loads(raw_items) if isinstance(raw_items, str) else []
    except json.JSONDecodeError:
        items = []

    # Option B: pull from a related DataFrame keyed on client_id
    # items_df = all_items_df[all_items_df["client_id"] == row["client_id"]]
    # items = items_df[["description","qty","unit_price"]].to_dict("records")

    ctx["line_items"] = [
        {
            "description": str(i.get("description", "")),
            "qty":         str(i.get("qty", "")),
            "unit_price":  f"${float(i.get('unit_price', 0)):,.2f}",
        }
        for i in items
    ]
    return ctx

Passing line_items=[] when there are no items collapses the loop without raising an error.

Edge Cases & Variants

Excel as Data Source

Swap one line in run_batch:

# pip install pandas openpyxl
import pandas as pd
from pathlib import Path

DATA = Path("project/data/recipients.xlsx")

df = pd.read_excel(DATA, engine="openpyxl", sheet_name="Sheet1")
# dtype normalization — Excel dates arrive as datetime objects, not strings
df["invoice_date"] = pd.to_datetime(df["invoice_date"], errors="coerce")

See Cleaning Messy CSV Data with Pandas for handling encoding issues and mixed-type columns in flat files.

Parallel Rendering for Large Batches

For datasets over 500 rows the GIL is not the bottleneck — disk I/O is. ThreadPoolExecutor gives a meaningful speedup:

# pip install docxtpl pandas
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
import pandas as pd
from docxtpl import DocxTemplate
import logging

TEMPLATE = Path("project/templates/letter_template.docx")
OUT_DIR  = Path("project/output")


def render_one(args: tuple) -> tuple[int, bool]:
    idx, ctx = args
    safe_id  = str(ctx.get("client_id", idx)).replace(" ", "_")
    out      = OUT_DIR / f"invoice_{safe_id}.docx"
    try:
        tpl = DocxTemplate(str(TEMPLATE))
        tpl.render(ctx)
        out.parent.mkdir(parents=True, exist_ok=True)
        tpl.save(str(out))
        return idx, True
    except Exception as exc:
        logging.error("Row %d failed: %s", idx, exc)
        return idx, False


def run_parallel(contexts: list[dict]) -> None:
    with ThreadPoolExecutor(max_workers=8) as pool:
        futures = {pool.submit(render_one, (i, ctx)): i for i, ctx in enumerate(contexts)}
        ok = sum(1 for f in as_completed(futures) if f.result()[1])
    logging.info("Parallel batch: %d/%d OK", ok, len(contexts))

Rendering a docx Template from an In-Memory Buffer

When the template is fetched from cloud storage (S3, Azure Blob) you can pass a BytesIO object directly:

# pip install docxtpl boto3
import io
import boto3
from docxtpl import DocxTemplate
from pathlib import Path

s3 = boto3.client("s3")
obj = s3.get_object(Bucket="my-bucket", Key="templates/letter_template.docx")
buf = io.BytesIO(obj["Body"].read())

tpl = DocxTemplate(buf)
tpl.render({"first_name": "Alice", "last_name": "Smith"})
tpl.save(str(Path("project/output/alice_smith.docx")))

Validation — Confirm Output Correctness

After the batch completes, spot-check that placeholders were resolved:

# pip install python-docx
from docx import Document
from pathlib import Path
import re

OUT_DIR = Path("project/output")

unrendered_pattern = re.compile(r"\{\{.*?\}\}")

for docx_file in sorted(OUT_DIR.glob("*.docx"))[:5]:
    doc = Document(str(docx_file))
    full_text = " ".join(p.text for p in doc.paragraphs)
    matches = unrendered_pattern.findall(full_text)
    if matches:
        print(f"UNRENDERED in {docx_file.name}: {matches}")
    else:
        print(f"OK: {docx_file.name}")

Any {{ }} remaining in the output means a context key was missing at render time. Cross-reference column names printed in the diagnostic step.

Performance & Scale Notes

  • Memory: DocxTemplate loads the whole .docx XML into memory. A typical 50 KB template uses ~2 MB of working memory after parsing. 500 parallel renders sit comfortably inside 1 GB.
  • Disk: Each output file is roughly the size of the template plus text expansion. Budget ~70 KB per rendered invoice.
  • Bottleneck: Disk write throughput dominates at scale, not CPU. Threads beat processes for I/O-bound work.
  • Very large batches: Chunk the DataFrame with pd.read_csv(DATA, chunksize=200) and process one chunk at a time to cap peak memory.
Tag forms and what each one removes Three tag forms. The plain percent form removes only the tag text and leaves the paragraph, which breaks list items. The p form removes the whole paragraph, which is right for loops around block content. The tr form removes the whole table row, which is what keeps a repeated row from collapsing the table. There is also a tc form for cells. Pick the tag form by what should disappear {% for ... %} removes the tag text only — leaves an empty list bullet behind {%p for ... %} removes the whole paragraph — right for block content {%tr for ... %} removes the whole table row — keeps the table intact

Troubleshooting

ErrorRoot causeFix
jinja2.exceptions.UndefinedError: 'xxx' is undefinedContext dict missing a key that the template referencesAdd ctx.setdefault('xxx', '') or align column names; see Fix docxtpl Jinja2 UndefinedError
XMLSyntaxError on renderPlaceholder split across XML runs in the templateRetype the {{ }} tag as one continuous run in Word
Output file is 0 bytestpl.save() path parent directory does not existCall output_path.parent.mkdir(parents=True, exist_ok=True) before saving
Table loop only renders one rowLoop tag is in a paragraph, not a table rowMove {%tr for %} / {%tr endfor %} into separate Word table rows
Date column renders as 2026-06-01 00:00:00pandas Timestamp not formatted before renderApply .strftime("%B %d, %Y") in build_context

Complete Working Script

# pip install docxtpl pandas openpyxl
"""
mailmerge_batch.py — render one .docx per CSV/Excel row.
Usage: python mailmerge_batch.py --data recipients.csv --template letter_template.docx --out output/
"""
import argparse
import logging
from pathlib import Path

import pandas as pd
from docxtpl import DocxTemplate

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(message)s",
    handlers=[logging.FileHandler("merge.log"), logging.StreamHandler()],
)


def build_context(row: pd.Series) -> dict:
    ctx: dict = row.to_dict()
    try:
        ctx["invoice_date"] = pd.to_datetime(ctx["invoice_date"]).strftime("%B %d, %Y")
    except (ValueError, TypeError, KeyError):
        ctx["invoice_date"] = str(ctx.get("invoice_date", ""))
    try:
        ctx["total_due"] = f"${float(ctx['total_due']):,.2f}"
    except (ValueError, TypeError, KeyError):
        ctx["total_due"] = "$0.00"
    ctx.setdefault("tier", "standard")
    ctx["line_items"] = ctx.get("line_items", [])
    return ctx


def main() -> None:
    parser = argparse.ArgumentParser(description="Batch mail merge with docxtpl")
    parser.add_argument("--data",     required=True, help="CSV or .xlsx data source")
    parser.add_argument("--template", required=True, help="Path to .docx template")
    parser.add_argument("--out",      required=True, help="Output directory")
    parser.add_argument("--key",      default="client_id", help="Column for output filename")
    args = parser.parse_args()

    template_path = Path(args.template)
    out_dir       = Path(args.out)
    data_path     = Path(args.data)
    out_dir.mkdir(parents=True, exist_ok=True)

    if not template_path.exists():
        raise FileNotFoundError(f"Template not found: {template_path}")

    # Load data — supports both CSV and Excel
    try:
        if data_path.suffix.lower() in (".xlsx", ".xls"):
            df = pd.read_excel(data_path, engine="openpyxl")
        else:
            df = pd.read_csv(data_path)
    except Exception as exc:
        raise SystemExit(f"Failed to load data: {exc}") from exc

    logging.info("Loaded %d rows from %s", len(df), data_path)
    ok = 0

    for idx, row in df.iterrows():
        ctx      = build_context(row)
        safe_key = str(ctx.get(args.key, idx)).replace(" ", "_").replace("/", "-")
        out_path = out_dir / f"doc_{safe_key}.docx"
        try:
            tpl = DocxTemplate(str(template_path))
            tpl.render(ctx)
            tpl.save(str(out_path))
            ok += 1
            logging.info("OK  %s", out_path.name)
        except Exception as exc:
            logging.error("FAIL row %d (%s): %s", idx, safe_key, exc)

    logging.info("Batch complete: %d/%d rendered", ok, len(df))


if __name__ == "__main__":
    main()

Run:

python mailmerge_batch.py \
  --data project/data/recipients.csv \
  --template project/templates/letter_template.docx \
  --out project/output \
  --key client_id

Frequently Asked Questions

Does docxtpl need Microsoft Word installed to render? No. docxtpl writes the OOXML .docx ZIP directly through python-docx — no Word, no COM automation, no Windows dependency. The render loop runs unchanged on Linux, macOS, and Windows, which is what makes it viable on a server or in CI.

Why re-open the template inside the loop instead of loading it once?DocxTemplate.render() mutates the loaded document in place. Reusing one instance across rows carries rendered text from the previous row into the next and corrupts the output. Constructing a fresh DocxTemplate(path) per iteration is cheap (parsing a 50 KB template is sub-millisecond) and guarantees isolation.

How do I stop pandas dates rendering as 2026-06-01 00:00:00? pandas reads date-like columns as Timestamp objects, and Jinja2 formats them with their raw repr. Convert to a string in build_context with pd.to_datetime(value).strftime("%B %d, %Y") before the value reaches render(). The same coercion step is where currency and other typed fields get formatted.

A {{ placeholder }} still shows up literally in the output — why? Word split the tag across two XML runs, usually because you edited the text after typing it. Select the whole placeholder and reapply a single character style so the {{ }} lives in one continuous run. The validation script above flags any file where a {{ }} survived the render.

Can I merge into a single combined document instead of one file per row? Yes — render each row to a separate DocxTemplate, then append the bodies together. The cleanest path for a print run is to render per row and then concatenate the PDFs; see Converting DOCX to PDF with Python for the headless conversion step.

Why does the table loop only produce one row? The {%tr for %} / {%tr endfor %} tags must each occupy their own row inside a real Word table, not a paragraph. When they sit in a paragraph, docxtpl has no table row to repeat and renders the block once. Move each tag into its own table cell/row as shown in the table-loop step.

Reviewing a Merge Before It Goes Out

A mail merge fails in a way most pipelines do not: it produces hundreds of individually plausible documents, any one of which can be wrong without the batch looking wrong. Three checks catch nearly everything.

Render the first, the last and a random middle record and read them. Templating errors are rarely subtle once a human looks — a missing name, a doubled paragraph, a table that lost its header — and three documents take a minute.

Assert on the batch as a whole rather than per file: the document count equals the row count, no output contains an unrendered placeholder, and no output is within a few hundred bytes of the empty template. That last check is the one that catches a context key renamed on one side only, which otherwise produces a full set of letters addressed to nobody.

And hold the outputs somewhere staged until those checks pass. Writing straight into the folder a mail-out job watches means a failed run is already halfway sent by the time anybody looks.

Part of Word Document Templating & Batch Processing.

Explore next

/html>