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") 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) 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") 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()) 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.
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.

