DEV Community

Lv Shao Mu
Lv Shao Mu

Posted on Originally published at csvtriage.com

Replacing Word Mail-Merge with Python for Conditional Paragraphs

Word mail-merge works fine for one letter with 40 names. Then the request changes: "include the bonus paragraph only for people who get a bonus," and "name each file after the person so I can email them individually." Mail-merge cannot do either without macros, and the macros break the next time someone edits the template.

Both are trivial in Python. Here is the pattern, and the three details that decide whether it works on real documents.

The pattern

from docx import Document
import re

def render(template, row):
    def sub(m):
        kind, field, body = m.group(1), m.group(2), m.group(3)
        has = str(row.get(field, "")).strip() not in ("", "None")
        if kind == "#":
            return body if has else ""
        if kind == "^":
            return "" if has else body

    # conditional blocks first, repeatedly for nesting
    text = re.sub(r"\{\{([#^])([A-Za-z0-9_]+)\}\}(.*?)\{\{/\2\}\}",
                  sub, template, flags=re.S)
    # then plain values
    return re.sub(r"\{\{([A-Za-z0-9_]+)\}\}",
                  lambda m: str(row.get(m.group(1), "")), text)

doc = Document("tpl.docx")
for p in doc.paragraphs:                 # also loop tables / headers / footers
    full = "".join(r.text for r in p.runs)
    if "{{" in full:
        new = render(full, row)
        p.runs[0].text = new             # keeps the first run's formatting
        for r in p.runs[1:]:
            r.text = ""
doc.save(f"out/{row['name']}-letter.docx")
Enter fullscreen mode Exit fullscreen mode

The whole thing is about 60 lines with the file loop and the encoding handling. That is the entire replacement for a feature that has been in Word for decades, and it covers the two cases mail-merge never will.

Order matters in render. Conditional blocks are resolved first, and repeatedly, so a {{#bonus}} block can contain another conditional inside it. Only then are plain values substituted. If you do it the other way around, a placeholder inside a false branch has already been replaced by the time you decide to drop the branch.

Empty string and the string "None" both count as absent. That second check is not superstition: it is what str(row.get(...)) produces when a spreadsheet cell is genuinely blank and pandas hands you a null.

Detail one: runs split your text

Word stores a paragraph as a list of runs, each with its own formatting. The moment someone bolds half a placeholder, or the spell checker touches it, {{na and me}} become two separate runs. Search the paragraph text and you will not find {{name}}, because it does not exist as a contiguous string anywhere in the document object model.

That is why the loop joins the runs first, replaces on the joined string, writes the result into the first run, and blanks the rest. It is a blunt approach — everything in that paragraph inherits the first run's formatting — but it is predictable, and predictable beats clever when the template is maintained by someone else.

The same caution applies to the containers you forget. doc.paragraphs does not include tables, headers, or footers. An address block in a table and a footer with the company name will both go out with {{client_name}} still visible, and it will be the client who tells you.

Detail two: formatting the values is the job

13943 is not an offer letter. 13,943.00 is. The same gap applies to dates: 2026-07-22 reads as machine output, while 22 July 2026 reads as a document.

Do that formatting in the template, not in the data. If you pre-format the spreadsheet, the same number cannot be shown as 13,943.00 in the body and 13943 in a filename. Build a small filter syntax instead, because the template is what people edit:

def apply_filter(value, spec):
    if not spec:
        return str(value)
    name, _, arg = spec.partition(":")
    if name == "money":
        return f"{float(value):,.2f}"
    if name == "date":
        return datetime.strptime(str(value)[:10], "%Y-%m-%d").strftime(arg)
    if name == "pad":
        return str(value).zfill(int(arg))
    if name == "upper":
        return str(value).upper()
    if name == "title":
        return str(value).title()
    return str(value)
Enter fullscreen mode Exit fullscreen mode

Then extend the value regex to carry the filter: {{salary|money}}, {{start|date:%d %B %Y}}, {{ref|pad:6}}. The date filter takes its format string as the argument, so 22 July 2026 and July 22, 2026 come from the same cell without touching the data.

Note the [:10] slice before strptime. Real spreadsheets contain 2026-07-22 00:00:00 often enough that hardcoding %Y-%m-%d against the raw string will break on the first file exported from a system that appends a time.

Detail three: encoding, the silent killer

Excel exports Chinese CSVs as GBK, or adds a UTF-8 BOM. Read defensively: try utf-8-sig first, fall back to gbk.

def read_table(path):
    try:
        return pd.read_csv(path, encoding="utf-8-sig")
    except UnicodeDecodeError:
        return pd.read_csv(path, encoding="gbk")
Enter fullscreen mode Exit fullscreen mode

If you skip this, half your documents contain ??? in place of the client's name, and you find out after sending 200 of them. The traceback is what you want here — a decode error stops the run. A silent misread does not, because pandas will happily produce mojibake and your template will happily render it.

utf-8-sig first, not plain utf-8, because the BOM otherwise lands in your first column name. You then spend twenty minutes wondering why row["name"] returns an empty string.

Filenames and the loop

Per-record output is the second thing mail-merge cannot do. doc.save(f"out/{row['name']}-letter.docx") is the whole implementation, with two caveats.

Sanitize the name. A row containing Acme / Ltd writes to a path that does not exist and raises. Strip or replace \ / : * ? " < > | before building the filename, and keep a stable ID from the sheet as the fallback when the name is empty.

Create the subfolder before saving. openpyxl-style libraries do not make parent directories, and doc.save into a missing folder fails on the last record of a 200-row batch, after all the work is done. Add os.makedirs(outdir, exist_ok=True) once, before the loop.

Then loop over rows, not over files:

for _, row in df.iterrows():
    build(row.to_dict())
Enter fullscreen mode Exit fullscreen mode

Reload the template inside the loop, or re-open it per iteration. Saving mutates the Document object you are holding, so reusing one instance across rows produces a second letter that contains the first letter's text plus the second's. It looks like a rendering bug and it is a state bug.

What to check before the batch runs

Render one row, open the file, and read it. Confirm the conditional paragraph appears and disappears correctly by toggling the field. Confirm the table and footer blocks are filled. Confirm the filename has no path separators.

Then run 200. The failure modes above all produce documents that open cleanly and are wrong, which is exactly why a single inspected sample beats a successful exit code.


Working with messy spreadsheets? I build small browser-based tools for exactly these jobs — CSV merge, PII masking, date normalisation — all free to run locally, nothing uploaded.

→ Sandbox Tools


If you'd rather not rebuild this from scratch, I packaged the exact scripts I use: DocFiller — fill a folder of Word templates from a spreadsheet.

Top comments (1)

Collapse
 
komopdf profile image
KomoPDF •

The run-splitting issue in python-docx is brutal in real production templates. Word frequently splits tokens around curly braces or punctuation simply because someone paused while typing or the spell checker flagged the word, leaving {{ in one run and the variable name in another.

Joining paragraph runs and writing the rendered result into p.runs[0] is definitely the most stable way to prevent broken tags, but one subtle trap with that approach is inline styling: if the template has Dear {{name}}, where only {{name}} was bolded, wiping subsequent runs collapses the whole paragraph into run 0's font weight. If someone needs to preserve mixed character styles across runs, tokenizing the paragraph text and splitting runs at exact character boundaries before mapping styles back keeps the layout intact.

Another edge case worth noting on tables: nested tables (tables inside cells) don't appear in doc.tables. If your template puts an address block inside an outer layout table, you have to recursively walk cell.tables to catch those placeholders.