Batch Replace Placeholders in Many DOCX Files
A regional office sends 260 letter templates that need the new company name, address and registration number, and a second request follows: produce one engagement letter per client from a master template, filling [CLIENT], [FEE] and [START] from an Excel sheet. The one-document script works. Run over the folder, it overwrites three templates that were open in Word, crashes on ~$letter-17.docx, reports success for 40 files where a placeholder was misspelled as [CLEINT], and leaves no record of which files changed.
Root Cause
Batch replacement fails at the edges of the single-document logic, not in it. Word creates hidden lock files named ~$…docx beside every open document, and they are not valid packages. Writing outputs over inputs turns any bug into data loss and makes reruns non-idempotent. A replacement function that reports only "done" cannot distinguish "replaced 3 placeholders" from "replaced 0 because the template spells it differently". Templates drift: some use [CLIENT], others {{client}} or «Client» left from an old mail merge, and a mapping written for one convention silently skips the rest. And per-row generation from Excel inherits every data problem in the sheet — blank cells, dates formatted as serial numbers, numbers with float noise — which then appear verbatim in client-facing letters.
Minimal Diagnostic
Before replacing anything, scan the whole folder for placeholder-like tokens and compare them with the keys you plan to replace. Unknown tokens are the misspellings and legacy conventions that would otherwise ship unreplaced.
# pip install "python-docx>=1.1"
import re
from collections import Counter
from pathlib import Path
from docx import Document
SOURCE_DIR = Path("in/templates")
MAPPING_KEYS = {"[COMPANY]", "[ADDRESS]", "[REG_NO]"}
TOKEN = re.compile(r"\[[A-Z_]{3,}\]|\{\{\s*\w+\s*\}\}|«[^»]{2,30}»")
def scan(folder: Path) -> None:
found: Counter = Counter()
files_with: dict[str, list[str]] = {}
bad_files = []
for path in sorted(folder.glob("*.docx")):
if path.name.startswith("~$"):
continue
try:
doc = Document(path)
except Exception as exc:
bad_files.append(f"{path.name}: {exc}")
continue
text = "\n".join(p.text for p in doc.paragraphs)
text += "\n".join(c.text for t in doc.tables for r in t.rows for c in r.cells)
text += "\n".join(p.text for s in doc.sections for p in s.header.paragraphs + s.footer.paragraphs)
for token in set(TOKEN.findall(text)):
found[token] += 1
files_with.setdefault(token, []).append(path.name)
for token, n in found.most_common():
status = "mapped" if token in MAPPING_KEYS else "UNKNOWN"
print(f"{status:>8} {token:<20} in {n} file(s), e.g. {files_with[token][:2]}")
for missing in sorted(MAPPING_KEYS - set(found)):
print(f"{'UNUSED':>8} {missing}")
for line in bad_files:
print(f"{'ERROR':>8} {line}")
if __name__ == "__main__":
scan(SOURCE_DIR)
mapped [COMPANY] in 258 file(s), e.g. ['letter-001.docx', 'letter-002.docx']
mapped [ADDRESS] in 241 file(s), e.g. ['letter-001.docx', 'letter-003.docx']
UNKNOWN [ADRESS] in 17 file(s), e.g. ['letter-002.docx', 'letter-044.docx']
UNKNOWN «Company» in 2 file(s), e.g. ['legacy-a.docx', 'legacy-b.docx']
mapped [REG_NO] in 260 file(s), e.g. ['letter-001.docx', 'letter-002.docx']
Seventeen templates misspell the address placeholder and two still carry Word mail-merge fields. The mapping needs [ADRESS] as an alias, and the legacy files need separate handling.
Fix: A Batch Runner with Safe Outputs and a Manifest
The runner skips lock files, writes to a separate folder through a temporary name, records counts per placeholder per file, flags unknown tokens left after replacement, and never touches the inputs. The cross-run replacement comes from fix python-docx replace text split across runs; the paragraph iterator from Find and Replace Text in Word Documents. Changed lines carry comments.
# pip install "python-docx>=1.1" "pandas>=2.2"
import re
from pathlib import Path
import pandas as pd
from docx import Document
from wordreplace import replace_in_paragraph, iter_all_paragraphs # shared helpers
SOURCE_DIR = Path("in/templates")
OUT_DIR = Path("out/templates-2026")
MAPPING = {
"[COMPANY]": "Northwind Group plc",
"[ADDRESS]": "3 Wharf Road, Leeds LS1 4AB",
"[ADRESS]": "3 Wharf Road, Leeds LS1 4AB", # changed: alias for the misspelling
"[REG_NO]": "09876543",
}
LEFTOVER = re.compile(r"\[[A-Z_]{3,}\]|\{\{\s*\w+\s*\}\}|«[^»]{2,30}»")
def process(path: Path) -> dict:
record = {"file": path.name, "status": "ok", **{k: 0 for k in MAPPING}, "leftover": ""}
doc = Document(path)
keys = sorted(MAPPING, key=len, reverse=True)
pattern = re.compile("|".join(re.escape(k) for k in keys))
def repl(m):
record[m.group(0)] += 1 # changed: count per placeholder
return MAPPING[m.group(0)]
texts = []
for paragraph in iter_all_paragraphs(doc):
replace_in_paragraph(paragraph, pattern, repl)
texts.append(paragraph.text)
leftovers = sorted(set(LEFTOVER.findall("\n".join(texts))))
if leftovers:
record["status"] = "check" # changed: flag, do not fail silently
record["leftover"] = " ".join(leftovers)
tmp = OUT_DIR / f".{path.stem}.tmp.docx"
doc.save(tmp) # changed: write to a temp name ...
tmp.replace(OUT_DIR / path.name) # ... then move into place atomically
return record
def run_batch() -> pd.DataFrame:
if SOURCE_DIR.resolve() == OUT_DIR.resolve():
raise SystemExit("output folder must differ from input folder") # changed: never overwrite
OUT_DIR.mkdir(parents=True, exist_ok=True)
rows = []
for path in sorted(SOURCE_DIR.glob("*.docx")):
if path.name.startswith("~$"): # changed: skip Word lock files
continue
try:
rows.append(process(path))
except Exception as exc:
rows.append({"file": path.name, "status": "error", "leftover": str(exc)})
manifest = pd.DataFrame(rows)
manifest.to_csv(OUT_DIR / "manifest.csv", index=False)
return manifest
if __name__ == "__main__":
manifest = run_batch()
print(manifest["status"].value_counts().to_string())
status
ok 258
check 2
The manifest is the deliverable as much as the documents are: it shows every file, how many times each placeholder was replaced, and which files still contain something placeholder-shaped. The two check rows are the legacy mail-merge templates, now listed explicitly instead of discovered by a client.
Variant Fix 1: One Document per Row from Excel
Generating one filled document per client from a master template adds data cleaning between the sheet and the replacement. Format values for people, not for Python, and refuse rows with blank required fields:
# pip install "python-docx>=1.1" "pandas>=2.2" openpyxl
import re
from pathlib import Path
import pandas as pd
from docx import Document
from wordreplace import replace_in_paragraph, iter_all_paragraphs
TEMPLATE = Path("in/engagement-letter.docx")
SHEET = Path("in/clients.xlsx")
OUT = Path("out/letters")
REQUIRED = ["client", "fee", "start"]
def display(value, column: str) -> str:
if pd.isna(value):
return ""
if column == "fee":
return f"£{float(value):,.2f}"
if column == "start":
return pd.Timestamp(value).strftime("%-d %B %Y") # 1 October 2026
return str(value).strip()
def safe_name(text: str) -> str:
return re.sub(r"[^\w\-]+", "-", text).strip("-")[:60]
def letters_from_sheet() -> pd.DataFrame:
rows = pd.read_excel(SHEET, dtype={"client_id": "string"})
OUT.mkdir(parents=True, exist_ok=True)
results = []
for _, row in rows.iterrows():
values = {f"[{c.upper()}]": display(row[c], c) for c in REQUIRED}
missing = [c for c in REQUIRED if not values[f"[{c.upper()}]"]]
if missing:
results.append({"client_id": row["client_id"], "status": f"skipped: blank {missing}"})
continue
doc = Document(TEMPLATE)
pattern = re.compile("|".join(re.escape(k) for k in values))
for p in iter_all_paragraphs(doc):
replace_in_paragraph(p, pattern, lambda m: values[m.group(0)])
dest = OUT / f"{row['client_id']}-{safe_name(values['[CLIENT]'])}.docx"
doc.save(dest)
results.append({"client_id": row["client_id"], "status": "ok", "file": dest.name})
return pd.DataFrame(results)
Reopening the template for every row matters: python-docx edits the document in memory, so reusing one Document object would carry the first client's values into every letter. %-d removes the day's leading zero on Linux and macOS; on Windows use %#d. For richer per-row documents — tables of line items, optional clauses — move to a Jinja template as described in generate one DOCX per row from Excel.
Variant Fix 2: Large Folders in Parallel
Each document is independent, so the batch parallelises cleanly across processes. Keep results flowing back to the parent, which alone writes the manifest:
# pip install "python-docx>=1.1" "pandas>=2.2"
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import os
import pandas as pd
def run_parallel(paths: list[Path], workers: int | None = None) -> pd.DataFrame:
workers = workers or max(1, (os.cpu_count() or 2) - 1)
rows = []
with ProcessPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(process, p): p for p in paths} # process() from the fix
for future in as_completed(futures):
try:
rows.append(future.result())
except Exception as exc:
rows.append({"file": futures[future].name, "status": "error", "leftover": str(exc)})
return pd.DataFrame(rows).sort_values("file")
With a few hundred files, parallelism saves little because each document takes milliseconds; it pays off at thousands of documents or when templates are large. Network shares are the usual bottleneck — copy inputs locally first.
Verification
Assert on the manifest and on a sample of outputs. Every input must appear exactly once, no file may be error, every check file must be acknowledged, and each output must contain each mapped value when its template contained the key.
# pip install "python-docx>=1.1" "pandas>=2.2"
from pathlib import Path
import pandas as pd
from docx import Document
def verify_batch(source_dir: Path, out_dir: Path, acknowledged: set[str] = frozenset()) -> None:
manifest = pd.read_csv(out_dir / "manifest.csv")
inputs = {p.name for p in source_dir.glob("*.docx") if not p.name.startswith("~$")}
assert set(manifest["file"]) == inputs, "manifest does not cover every input exactly"
assert not manifest["file"].duplicated().any(), "file processed twice"
errors = manifest[manifest["status"] == "error"]
assert errors.empty, f"errors: {errors['file'].tolist()}"
unacknowledged = set(manifest.loc[manifest["status"] == "check", "file"]) - set(acknowledged)
assert not unacknowledged, f"files with leftover placeholders: {sorted(unacknowledged)}"
sample = manifest[manifest["status"] == "ok"].sample(min(10, len(manifest)), random_state=1)
for name in sample["file"]:
text = "\n".join(p.text for p in Document(out_dir / name).paragraphs)
assert "Northwind Group plc" in text, f"{name}: company name not found in output"
print(f"batch verified: {len(manifest)} files, {len(unacknowledged)} unacknowledged checks")
Acknowledging check files explicitly — passing their names — forces a person to decide about each one rather than letting a growing list of warnings become background noise. Sampling outputs with a fixed random seed makes the spot check reproducible between runs.
FAQ
Should I use docxtpl instead for batch jobs? For new templates, yes: Jinja tags handle loops and conditions and are designed for generation. For editing existing documents that were never designed as templates, run-aware replacement is the right tool.
How do I handle «Field» mail-merge fields?
They are Word fields (MERGEFIELD), not plain text. Replace the whole field element — w:fldSimple or the w:fldChar begin/separate/end sequence — with a run containing the value, or convert the template once to plain placeholders.
Can the job run while people have templates open? Reading is fine; Word's lock file is skipped. Never write outputs into a folder where people open files, or the atomic rename can fail on Windows with a sharing violation.
How do I roll back? Because inputs are untouched and outputs go to a new folder, rolling back is deleting the output folder. Keep the manifest from each run to know what was produced.
Related
- Find and Replace Text in Word Documents with Python — the replacement engine used here
- Replace Text in Headers, Footers and Tables — placeholders outside the body
- Dynamic Mail Merge with Python — generating documents from data with templates
- Move Processed Files to Archive Folders — safe file handling patterns for batch jobs
Part of Find and Replace Text in Word Documents with Python.