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.

Paragraph-level versus run-aware replacement The left panel shows a paragraph whose runs are plain text, a bold run with the split placeholder, and plain text. Assigning paragraph.text collapses it into one run with the first run's formatting, losing the bold. The right panel shows the run-aware approach, which puts the replacement into the first run the match touches, empties the other runs it spans, and leaves surrounding runs and their formatting untouched. paragraph.text = ...replace() runs: 'with ' B'[CLIENT' B'_' B'NAME]' after: one plain run 'with Northwind (the..' bold client name: lost links, fields: lost Run-aware replace runs: 'with ' B'[CLIENT' B'_' B'NAME]' after: 'with ' B'Northwind' B'' B'' (emptied) bold client name: kept other runs: untouched

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.

Every place replacement has to reach One document fans out to four kinds of paragraph containers. Body paragraphs are top-level. Table cells include nested tables and merged cells visited once. Headers and footers include first-page and even-page variants for each unlinked section. Text boxes are reached through XPath on w:txbxContent. All paragraphs pass through the same run-aware replacement function. Document one .docx Body paragraphs top level Table cells nested and merged Headers and footers first and even pages Text boxes w:txbxContent replace_in_paragraph run-aware

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.

Replacement approaches compared Assigning paragraph.text handles split matches but loses run formatting and should not be used on real documents. Run-level replace keeps formatting but misses split matches. The run-aware cross-run replace handles split matches and keeps formatting and is the right choice for editing existing documents. docxtpl templating avoids the problem for new documents generated from templates designed with Jinja tags. Approach Split matches Keeps formatting Use for paragraph.text = ... found lost never run.text.replace missed kept simple one-run text Cross-run replace found kept editing documents docxtpl template n/a by design kept generating documents

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

SymptomRoot causeFix
Replacement "worked" but text unchanged in WordMatch split across runsCross-run replacement
Bold, italics or links vanishedAssigned paragraph.textReplace inside runs only
Header or footer still shows old textOnly body paragraphs visitedIterate all header/footer variants per section
Text in tables unchangeddoc.paragraphs excludes cellsRecurse into table cells
Text in a sidebar unchangedText box content not in paragraphsXPath over w:txbxContent
re.error: bad escapeBackslash in replacement stringPass 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.

Part of Word Document Templating & Batch Processing.

Explore next