Find and Replace Text in Word Documents with Python
Find-and-replace is the most common edit anyone automates in Word. The company renamed a product and 400 policy documents mention the old name. A contract template uses [CLIENT_NAME] and [START_DATE] placeholders filled from a spreadsheet. The legal entity's address changed and it appears in every letter footer. Word's own dialog handles one document at a time; the obvious Python version — paragraph.text = paragraph.text.replace(old, new) — handles many documents and quietly destroys them, stripping bold, italics, hyperlinks and fonts from every paragraph it touches, while missing the occurrences that live in headers, footers, table cells and text boxes.
The difficulty is structural. Word does not store a paragraph as a string; it stores a list of runs, each a stretch of text with uniform formatting. Word splits runs whenever formatting, spell-check state, revision ids or editing history change, so a placeholder you typed in one go is frequently stored as [CLIENT, _, NAME] across three runs. Replacing at the paragraph level loses the runs; replacing within runs misses split matches. This guide builds a replacement function that matches across runs while keeping formatting, applies it to every part of the document, and verifies the result — the editing counterpart of Extracting Data from Word Documents.
Prerequisites
python -m venv .venv && source .venv/bin/activate
pip install "python-docx>=1.1" "pandas>=2.2" openpyxl
mkdir -p in out
python-docx provides paragraph and run access across the body, tables, headers and footers; the underlying XML reaches text boxes. Keep untouched originals: every example writes to out/ rather than overwriting in/, because a replacement bug discovered after overwriting 400 documents cannot be undone. If documents are created from scratch rather than edited, templating with docxtpl is the better tool — see Dynamic Mail Merge with Python.
Diagnostic: See How the Target Text Is Split
Print the run structure of every paragraph that contains the target text when runs are joined. If the text never appears within a single run, a run-level replacement will never find it.
# pip install "python-docx>=1.1"
from pathlib import Path
from docx import Document
SOURCE = Path("in/service-agreement-template.docx")
TARGET = "[CLIENT_NAME]"
def show_runs(path: Path, target: str) -> None:
try:
doc = Document(path)
except Exception as exc:
raise SystemExit(f"cannot open {path}: {exc}")
containers = [("body", doc.paragraphs)]
for t_i, table in enumerate(doc.tables):
for row in table.rows:
for cell in row.cells:
containers.append((f"table{t_i}", cell.paragraphs))
for s_i, section in enumerate(doc.sections):
containers.append((f"header{s_i}", section.header.paragraphs))
containers.append((f"footer{s_i}", section.footer.paragraphs))
for where, paragraphs in containers:
for p in paragraphs:
if target in p.text:
in_one_run = any(target in r.text for r in p.runs)
print(f"[{where}] single-run={in_one_run} runs={[r.text for r in p.runs]}")
if __name__ == "__main__":
show_runs(SOURCE, TARGET)
[body] single-run=False runs=['This agreement is made with ', '[CLIENT', '_', 'NAME]', ' (the "Client").']
[body] single-run=True runs=['Client: ', '[CLIENT_NAME]']
[footer0] single-run=False runs=['[CLIENT_', 'NAME] – Confidential']
Three occurrences, two of them split. A run.text.replace loop would change one of three and report success.
Core Implementation
Step 1: Replace Across Runs While Keeping Formatting
Join the runs' text, find matches in the joined string, and map each match back to the runs it spans. Put the replacement text into the first affected run (which keeps that run's formatting), trim the matched portion out of the following runs, and leave everything else alone.
# pip install "python-docx>=1.1"
import re
from docx.text.paragraph import Paragraph
def replace_in_paragraph(paragraph: Paragraph, pattern: re.Pattern, replacement) -> int:
"""Replace every match of pattern, spanning runs if needed. Returns the number of replacements."""
runs = paragraph.runs
if not runs:
return 0
full = "".join(r.text for r in runs)
matches = list(pattern.finditer(full))
if not matches:
return 0
for m in reversed(matches): # right to left keeps offsets valid
new_text = replacement(m) if callable(replacement) else m.expand(replacement)
start, end, pos = m.start(), m.end(), 0
first = True
for run in runs:
run_start, run_end = pos, pos + len(run.text)
pos = run_end
if run_end <= start or run_start >= end:
continue # run not touched by this match
text = run.text
cut_from = max(start, run_start) - run_start
cut_to = min(end, run_end) - run_start
if first:
run.text = text[:cut_from] + new_text + text[cut_to:]
first = False
else:
run.text = text[:cut_from] + text[cut_to:]
# recompute nothing: runs to the left of this match are unchanged
return len(matches)
Processing matches from right to left is what makes the offsets safe: editing a later match never shifts the positions of earlier ones. Runs emptied by the edit remain as empty run elements, which Word ignores; removing them is optional tidying. The deep dive, including hyperlinks and fields that sit outside paragraph.runs, is fix python-docx replace text split across runs.
Step 2: Visit Every Paragraph in the Document
A document's text lives in the body, table cells (including nested tables), headers and footers for each section — with separate first-page and even-page variants — and text boxes. Yield all of them from one generator so the replacement code does not care where a paragraph came from.
# pip install "python-docx>=1.1"
from docx.document import Document as DocumentType
from docx.oxml.ns import qn
from docx.text.paragraph import Paragraph
def iter_container_paragraphs(container):
yield from container.paragraphs
for table in getattr(container, "tables", []):
seen = set()
for row in table.rows:
for cell in row.cells:
if id(cell._tc) in seen: # merged cells repeat
continue
seen.add(id(cell._tc))
yield from iter_container_paragraphs(cell)
def iter_all_paragraphs(doc: DocumentType):
yield from iter_container_paragraphs(doc)
seen_parts = set()
for section in doc.sections:
for hf in (section.header, section.first_page_header, section.even_page_header,
section.footer, section.first_page_footer, section.even_page_footer):
if hf.is_linked_to_previous or id(hf.part) in seen_parts:
continue
seen_parts.add(id(hf.part))
yield from iter_container_paragraphs(hf)
for box in doc.element.body.iter(qn("w:txbxContent")):
for p in box.iter(qn("w:p")):
yield Paragraph(p, doc)
Skipping linked headers and de-duplicating by part prevents replacing the same header text once per section — harmless for fixed replacements, wrong for counters or anything that appends. Text box paragraphs may appear twice because of Word's VML fallback copies; replacing in both keeps the copies consistent, which is what you want. Headers, footers and tables get their own treatment in replace text in headers, footers and tables.
Step 3: Apply a Mapping of Replacements
Most jobs replace several placeholders at once. Build one combined regular expression so each position is matched once — sequential replacements can otherwise replace text inserted by an earlier replacement.
# pip install "python-docx>=1.1"
import re
from pathlib import Path
from docx import Document
def replace_all(doc, mapping: dict[str, str], ignore_case: bool = False) -> dict[str, int]:
if not mapping:
return {}
keys = sorted(mapping, key=len, reverse=True) # longest first: [NAME_FULL] before [NAME]
lookup = {k.casefold() if ignore_case else k: v for k, v in mapping.items()}
pattern = re.compile("|".join(re.escape(k) for k in keys), re.IGNORECASE if ignore_case else 0)
counts = {k: 0 for k in mapping}
def repl(m: re.Match) -> str:
key = m.group(0).casefold() if ignore_case else m.group(0)
original = next(k for k in mapping if (k.casefold() if ignore_case else k) == key)
counts[original] += 1
return lookup[key]
for paragraph in iter_all_paragraphs(doc):
replace_in_paragraph(paragraph, pattern, repl)
return counts
if __name__ == "__main__":
doc = Document(Path("in/service-agreement-template.docx"))
counts = replace_all(doc, {"[CLIENT_NAME]": "Northwind Traders Ltd", "[START_DATE]": "1 October 2026"})
out = Path("out/service-agreement-northwind.docx")
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(out)
print(counts)
Returning per-key counts is the single most useful debugging aid in a replacement job: a placeholder replaced zero times is either absent from this template or split in a way the code does not see.
Step 4: Regex Replacements for Real-World Variants
Renaming a product has to catch Acme Cloud, ACME Cloud and Acme Cloud (double space) but not Acme Cloudburst. Patterns with word boundaries and flexible whitespace handle it:
# pip install "python-docx>=1.1"
import re
RENAMES = [
(re.compile(r"\bAcme\s+Cloud\b(?!burst)", re.IGNORECASE), "Acme Platform"),
(re.compile(r"\bLegacy Street 12, Leeds\b"), "3 Wharf Road, Leeds"),
]
def apply_renames(doc) -> int:
total = 0
for paragraph in iter_all_paragraphs(doc):
for pattern, new in RENAMES:
total += replace_in_paragraph(paragraph, pattern, lambda m, new=new: new)
return total
Passing a function instead of a replacement string avoids re interpreting backslashes and group references inside replacement text — a surprising failure when the new text contains a Windows path.
Edge Cases and Variants
Batch Updates Across a Folder
Running the same mapping over hundreds of documents adds concerns that one document does not have: skipping lock files, recording counts per file, and never overwriting originals. Batch replace placeholders in many DOCX files covers per-row mappings from Excel, error handling and a manifest of what changed.
Tracked Changes
Documents under review contain deleted text in w:delText, which run.text does not show, and inserted text inside w:ins, which it does. Replacing in a document with tracked changes edits the "accepted" view silently. Accept or reject changes first, or leave such documents for a person, which is easy to detect with the inventory in extract comments and tracked changes from docx.
Fields and Content Controls
Text produced by fields (DATE, PAGE, cross-references) is a cached result that Word regenerates, so replacing it is pointless. Content controls are better filled by tag than by searching their text. Treat both as out of scope for text replacement and handle them explicitly.
Keeping an Audit Trail of Edits
Bulk edits to policies and contracts are often subject to review, and "the script changed it" is not an acceptable answer to "who changed clause 7". Record, for every replacement, the file, the container, the paragraph text before and after, and the rule that fired. A replacement callback can capture this without changing the replacement logic:
# pip install "python-docx>=1.1"
import csv
from datetime import datetime, timezone
from pathlib import Path
def audited(rule_name: str, new_text: str, log: list[dict], file_name: str):
def repl(match):
log.append({"time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"file": file_name, "rule": rule_name,
"old": match.group(0), "new": new_text})
return new_text
return repl
def write_audit(log: list[dict], dest: Path) -> None:
with dest.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=["time", "file", "rule", "old", "new"])
writer.writeheader()
writer.writerows(log)
Store the audit CSV alongside the edited outputs and keep both with the job's run record. It also answers the practical question after a rollout: which documents actually contained the old wording, and how many times.
Validation
Verify three things on the saved file: no placeholder pattern remains anywhere, every expected replacement happened at least once, and text outside the replacements is unchanged.
# pip install "python-docx>=1.1"
import re
from pathlib import Path
from docx import Document
PLACEHOLDER = re.compile(r"\[[A-Z][A-Z0-9_]+\]")
def all_text(doc) -> str:
return "\n".join(p.text for p in iter_all_paragraphs(doc))
def verify_replacement(original: Path, edited: Path, mapping: dict[str, str]) -> None:
before, after = all_text(Document(original)), all_text(Document(edited))
leftover = sorted(set(PLACEHOLDER.findall(after)))
assert not leftover, f"placeholders remain: {leftover}"
for key, value in mapping.items():
if key in before:
assert value in after, f"{key} was in the template but {value!r} is not in the output"
expected = before
for key, value in sorted(mapping.items(), key=lambda kv: -len(kv[0])):
expected = expected.replace(key, value)
assert expected == after, "text outside the placeholders changed"
print(f"{edited.name}: all placeholders replaced, other text unchanged")
The final assertion — plain str.replace on the joined text must produce exactly the edited document's joined text — is a strong check: it passes only if the run-aware code made the same textual change as a naive replacement would, without collateral edits. Formatting is not covered by it; open one output per template in Word the first time a job runs and check that bold, links and fonts survived.
Performance and Scale Notes
python-docx loads the whole document into memory; replacement is dominated by parsing and saving, not by the regex work, so typical documents take tens of milliseconds end to end. For thousands of files, a process pool with one document per task scales with cores. Compile patterns once per job, not per paragraph. The cross-run function is linear in paragraph length, but a paragraph with thousands of tiny runs — common in documents pasted from PDFs — makes the inner loop slow; merging adjacent runs with identical formatting before replacing is a worthwhile optimisation for such sources. Save outputs to local disk and move them in a second step if the destination is a network share, which keeps a slow share from stalling the workers.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| Replacement "worked" but text unchanged in Word | Match split across runs | Cross-run replacement |
| Bold, italics or links vanished | Assigned paragraph.text | Replace inside runs only |
| Header or footer still shows old text | Only body paragraphs visited | Iterate all header/footer variants per section |
| Text in tables unchanged | doc.paragraphs excludes cells | Recurse into table cells |
| Text in a sidebar unchanged | Text box content not in paragraphs | XPath over w:txbxContent |
re.error: bad escape | Backslash in replacement string | Pass a function as the replacement |
Complete Working Script
#!/usr/bin/env python3
# pip install "python-docx>=1.1"
"""Replace text in every .docx in a folder, keeping formatting, and report counts per file."""
import argparse
import json
import re
import sys
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
from docx.text.paragraph import Paragraph
def replace_in_paragraph(paragraph, pattern, repl) -> int:
runs = paragraph.runs
full = "".join(r.text for r in runs)
matches = list(pattern.finditer(full))
for m in reversed(matches):
new, pos, first = repl(m), 0, True
for run in runs:
rs, re_ = pos, pos + len(run.text)
pos = re_
if re_ <= m.start() or rs >= m.end():
continue
a, b = max(m.start(), rs) - rs, min(m.end(), re_) - rs
run.text = run.text[:a] + (new if first else "") + run.text[b:]
first = False
return len(matches)
def paragraphs(container):
yield from container.paragraphs
for table in getattr(container, "tables", []):
seen = set()
for row in table.rows:
for cell in row.cells:
if id(cell._tc) not in seen:
seen.add(id(cell._tc))
yield from paragraphs(cell)
def all_paragraphs(doc):
yield from paragraphs(doc)
for s in doc.sections:
for hf in (s.header, s.first_page_header, s.even_page_header,
s.footer, s.first_page_footer, s.even_page_footer):
if not hf.is_linked_to_previous:
yield from paragraphs(hf)
for box in doc.element.body.iter(qn("w:txbxContent")):
for p in box.iter(qn("w:p")):
yield Paragraph(p, doc)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("src", type=Path)
ap.add_argument("out", type=Path)
ap.add_argument("--map", type=Path, required=True, help="JSON object of old -> new text")
args = ap.parse_args()
try:
mapping = json.loads(args.map.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
print(f"cannot read mapping: {exc}", file=sys.stderr)
return 1
keys = sorted(mapping, key=len, reverse=True)
pattern = re.compile("|".join(re.escape(k) for k in keys))
args.out.mkdir(parents=True, exist_ok=True)
failures = 0
for path in sorted(args.src.glob("*.docx")):
if path.name.startswith("~$"):
continue
try:
doc = Document(path)
count = sum(replace_in_paragraph(p, pattern, lambda m: mapping[m.group(0)])
for p in all_paragraphs(doc))
doc.save(args.out / path.name)
print(f"{path.name}: {count} replacement(s)")
except Exception as exc:
failures += 1
print(f"{path.name}: FAILED {exc}", file=sys.stderr)
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
Frequently Asked Questions
Can I replace text with a new paragraph or a line break?
A run can contain a line break (run.add_break()), but inserting whole paragraphs requires creating new w:p elements after the current one. For structural insertions, templating is simpler than replacement.
Does replacement update the table of contents?
No. The TOC is a field with cached text; Word updates it when fields are refreshed. Set w:updateFields in the settings part to prompt Word to refresh on open.
How do I replace text inside hyperlinks?
Hyperlink runs are children of w:hyperlink, not of the paragraph, so paragraph.runs omits them in older python-docx versions. Include them explicitly; the run-split guide shows how.
Can I preview changes before saving?
Run the replacement with a counting function that returns the original match (lambda m: m.group(0)) and collects the paragraph text around each hit. The document is unchanged, and the preview list doubles as the change log reviewers sign off before the real run.
What about footnotes and endnotes?
They live in separate parts that python-docx does not expose. Load word/footnotes.xml through doc.part.package parts, wrap its w:p elements as Paragraph objects, and run the same replacement over them before saving.
Is python-docx safe for documents with macros?
It reads .docx, not .docm, reliably; macro-enabled files should be handled with care or left to Word automation, since saving may drop the VBA project.
Related
- Fix python-docx Replace Text Split Across Runs — why runs split and how the algorithm maps offsets
- Batch Replace Placeholders in Many DOCX Files — per-document mappings, manifests and safe outputs
- Replace Text in Headers, Footers and Tables — every container a replacement must reach
- Fix docxtpl Formatting Lost in Output — the same run problem in Jinja templates
- Set Fonts and Styles with python-docx — how run formatting is stored