Fix python-docx Replace Text Split Across Runs

The replacement loop runs without errors and the output still contains {{invoice_number}}:

for p in doc.paragraphs:
    for run in p.runs:
        if "{{invoice_number}}" in run.text:
            run.text = run.text.replace("{{invoice_number}}", "INV-2231")

Printing the paragraph shows the placeholder is there — "Invoice: {{invoice_number}}" — but no individual run contains it. The common workaround, p.text = p.text.replace(...), makes the placeholder disappear and takes the document's formatting with it: a bold label becomes plain, a hyperlink stops being a link, and a different font on the value reverts to the paragraph default.

Root Cause

A Word paragraph is a sequence of runs, and each run holds a stretch of text with one set of character properties. Word splits runs far more often than formatting changes suggest. Typing a placeholder, pausing, and continuing creates separate runs tagged with different revision-save ids (w:rsidR). The spelling checker marks invoice_number as an error and wraps it in proofing markers. AutoCorrect converts straight quotes or dashes as you type. Pasting part of a word from elsewhere brings its own run. The result is a placeholder stored as {{, invoice, _number, }} — four runs with identical formatting, which look like one word in Word's interface. Replacing inside runs cannot see a string that no run contains. Assigning paragraph.text deletes every run and creates a single new one, so the text is right and all per-run formatting is gone.

Minimal Diagnostic

Print each run with its text and the attributes that commonly cause splits. Seeing the boundaries explains the failure immediately.

# pip install "python-docx>=1.1"
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn

SOURCE = Path("in/invoice-template.docx")
TARGET = "{{invoice_number}}"

def explain_split(path: Path, target: str) -> None:
    try:
        doc = Document(path)
    except Exception as exc:
        raise SystemExit(f"cannot open {path}: {exc}")
    for i, p in enumerate(doc.paragraphs):
        if target not in p.text:
            continue
        print(f"paragraph {i}: {p.text!r}")
        for j, run in enumerate(p.runs):
            r = run._r
            rsid = r.get(qn("w:rsidR"))
            props = r.find(qn("w:rPr"))
            prop_names = [c.tag.split("}")[-1] for c in props] if props is not None else []
            print(f"   run {j}: {run.text!r:<22} rsid={rsid} props={prop_names}")

if __name__ == "__main__":
    explain_split(SOURCE, TARGET)
paragraph 3: 'Invoice: {{invoice_number}}'
   run 0: 'Invoice: '             rsid=00A1 props=['b']
   run 1: '{{'                    rsid=00A1 props=[]
   run 2: 'invoice'               rsid=00C7 props=['noProof']
   run 3: '_number'               rsid=00C7 props=[]
   run 4: '}}'                    rsid=00D2 props=[]

The label is bold in its own run; the placeholder is spread over four runs split by revision id and a proofing flag, none of which change how it looks.

One visible placeholder, four runs Run 0 holds the bold label Invoice colon. Run 1 holds the opening braces with revision id 00A1. Run 2 holds the word invoice with a different revision id and a noProof flag from the spelling checker. Run 3 holds underscore number. Run 4 holds the closing braces with a third revision id. The placeholder looks like one word in Word but no single run contains it. Run 0: 'Invoice: ' bold, rsid 00A1 label formatting that must survive Run 1: '{{' rsid 00A1 split by pausing while typing Run 2: 'invoice' rsid 00C7, noProof split by the spelling checker Run 3: '_number' rsid 00C7 same revision as run 2 but split by proofing Run 4: '}}' rsid 00D2 split by a later edit session

Fix: Match on Joined Text, Edit Only the Runs a Match Touches

Join all run texts, find matches in the joined string, and translate each match's start and end offsets back to run positions. The replacement goes into the first run the match touches — inheriting that run's formatting — and the matched characters are removed from the other runs. Runs outside the match are not modified. Changed lines are commented.

# pip install "python-docx>=1.1"
import re
from pathlib import Path
from docx import Document

SOURCE = Path("in/invoice-template.docx")
DEST = Path("out/invoice-2231.docx")

def replace_across_runs(paragraph, old: str, new: str) -> int:
    runs = paragraph.runs
    full = "".join(run.text for run in runs)                  # changed: search the joined text
    starts = [m.start() for m in re.finditer(re.escape(old), full)]
    for start in reversed(starts):                            # changed: right to left, offsets stay valid
        end = start + len(old)
        pos, placed = 0, False
        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                                      # changed: untouched runs keep everything
            cut_a = max(start, run_start) - run_start
            cut_b = min(end, run_end) - run_start
            if not placed:
                run.text = run.text[:cut_a] + new + run.text[cut_b:]   # changed: new text in first run
                placed = True
            else:
                run.text = run.text[:cut_a] + run.text[cut_b:]         # changed: trim the rest
    return len(starts)

if __name__ == "__main__":
    try:
        doc = Document(SOURCE)
    except Exception as exc:
        raise SystemExit(f"cannot open template: {exc}")
    total = sum(replace_across_runs(p, "{{invoice_number}}", "INV-2231") for p in doc.paragraphs)
    DEST.parent.mkdir(parents=True, exist_ok=True)
    doc.save(DEST)
    print(f"{total} replacement(s)")

After the edit, the runs are 'Invoice: ' (bold, untouched), 'INV-2231', '', '', ''. The value takes the formatting of run 1 — the run holding the opening braces — which is the formatting a person would expect, since whoever styled the placeholder styled it from its start.

Walking matches right to left is essential when a paragraph contains several occurrences: replacing the last one first means earlier offsets, computed from the original joined string, still point at the right characters.

Variant Fix 1: Make the Value Take the Placeholder's Dominant Formatting

Sometimes only the braces are plain and the word inside is bold, so "first run" picks the wrong formatting. Choose the run that contributes the most characters to the match instead:

# pip install "python-docx>=1.1"
import re

def replace_with_dominant_format(paragraph, old: str, new: str) -> int:
    runs = paragraph.runs
    full = "".join(r.text for r in runs)
    starts = [m.start() for m in re.finditer(re.escape(old), full)]
    for start in reversed(starts):
        end = start + len(old)
        spans, pos = [], 0
        for i, run in enumerate(runs):
            rs, re_ = pos, pos + len(run.text)
            pos = re_
            overlap = min(end, re_) - max(start, rs)
            if overlap > 0:
                spans.append((i, rs, overlap))
        host = max(spans, key=lambda s: s[2])[0]                # run holding most of the match
        for i, rs, _ in spans:
            run = runs[i]
            a, b = max(start, rs) - rs, min(end, rs + len(run.text)) - rs
            run.text = run.text[:a] + (new if i == host else "") + run.text[b:]
    return len(starts)

Placing the new text in a run other than the first can change where it sits relative to text that remains in earlier runs; because the matched characters are removed from all spanned runs and the new text is inserted at the host's cut point, the reading order is preserved as long as the match's runs are contiguous, which they always are.

Choosing the run that receives the new text The left panel uses the first-run strategy, so the inserted value inherits the plain formatting of the opening braces although the visible placeholder name was bold. The right panel uses the dominant-run strategy, so the value is placed in the bold run that contributed the most characters and keeps the bold formatting. First run receives text runs: '{{' B'invoice_number' '}}' host: run 0 (plain braces) result: 'INV-2231' plain bold on the value: lost Dominant run receives text runs: '{{' B'invoice_number' '}}' host: run 1 (14 of 18 chars) result: B'INV-2231' bold on the value: kept

Text inside a hyperlink belongs to w:r elements that are children of w:hyperlink, not of the paragraph. Older python-docx versions leave them out of paragraph.runs, and newer ones expose them through paragraph.iter_inner_content(). To replace reliably regardless of version, collect run elements from the paragraph XML directly:

# pip install "python-docx>=1.1"
from docx.oxml.ns import qn
from docx.text.run import Run

def all_runs(paragraph) -> list[Run]:
    """Runs in document order, including those inside hyperlinks and smart tags."""
    runs = []
    for r in paragraph._p.iter(qn("w:r")):
        parent = r.getparent()
        if parent.tag in (qn("w:p"), qn("w:hyperlink"), qn("w:smartTag"), qn("w:ins")):
            runs.append(Run(r, paragraph))
    return runs

Swap paragraph.runs for all_runs(paragraph) in the fix. Including w:ins also brings tracked insertions into scope; deleted text sits in w:delText rather than w:t, so Run.text returns an empty string for it and the offsets stay consistent. Replacing in documents with tracked changes still edits the accepted view — detect them first, as in extract comments and tracked changes from docx.

Preventing Splits in Templates You Control

The most robust fix for templates is to stop the splits from being saved. Two habits help. Type each placeholder in one go, then select it and turn off spelling for that selection (Review → Language → "Do not check spelling"), so proofing marks do not break it. And normalise templates once with a script that merges adjacent runs with identical formatting — after which placeholders are single runs and even naive tooling works:

# pip install "python-docx>=1.1"
import copy
from docx import Document
from docx.oxml.ns import qn
from lxml import etree

def rpr_key(r) -> bytes:
    rpr = r.find(qn("w:rPr"))
    if rpr is None:
        return b""
    clean = copy.deepcopy(rpr)
    for noisy in ("w:noProof", "w:lang", "w:proofErr"):         # proofing flags do not change looks
        for el in clean.findall(qn(noisy)):
            clean.remove(el)
    return etree.tostring(clean, method="c14n")                  # canonical bytes for comparison

def merge_identical_runs(paragraph) -> int:
    merged = 0
    runs = list(paragraph._p.iterchildren(qn("w:r")))
    for prev, cur in zip(runs, runs[1:]):
        if prev.getparent() is None:
            continue
        only_text = all(c.tag in (qn("w:rPr"), qn("w:t")) for c in list(prev) + list(cur))
        if only_text and rpr_key(prev) == rpr_key(cur):
            prev_t, cur_t = prev.find(qn("w:t")), cur.find(qn("w:t"))
            if prev_t is None or cur_t is None:
                continue
            prev_t.text = (prev_t.text or "") + (cur_t.text or "")
            prev_t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
            cur.getparent().remove(cur)
            merged += 1
    return merged

The merge only combines runs that contain nothing but text and have equivalent properties after ignoring proofing and language flags; runs with tabs, breaks, fields or drawings are left alone. Run it once when a template is checked in, not on every generated document. Templates written for docxtpl benefit the same way — the Jinja equivalent of this problem is covered in fix docxtpl formatting lost in output.

Normalising a template once The template is loaded, adjacent text-only runs with the same formatting are merged while ignoring proofing and language flags, a check confirms every placeholder now sits in a single run, the text and formatting are compared with the original, and the cleaned template is committed so every future generation starts from unsplit placeholders. Load template from version control Merge runs same formatting only Check placeholders one run each Compare text identical to original Commit template used by all jobs

Verification

Confirm the placeholder is gone everywhere, that the paragraph's visible text equals a plain string replacement of the original, and that the formatting of runs outside the match is unchanged.

# pip install "python-docx>=1.1"
from pathlib import Path
from docx import Document

def verify(original: Path, edited: Path, old: str, new: str) -> None:
    a, b = Document(original), Document(edited)
    assert len(a.paragraphs) == len(b.paragraphs), "paragraph count changed"
    for pa, pb in zip(a.paragraphs, b.paragraphs):
        assert old not in pb.text, f"placeholder remains: {pb.text!r}"
        assert pb.text == pa.text.replace(old, new), f"unexpected text change: {pb.text!r}"
        before = [(r.text, r.bold, r.italic, r.font.name) for r in pa.runs if old not in pa.text]
        after = [(r.text, r.bold, r.italic, r.font.name) for r in pb.runs if old not in pa.text]
        assert before == after, "formatting changed in a paragraph without the placeholder"
        if old in pa.text:
            label_before = [(r.bold, r.italic) for r in pa.runs if r.text and old not in r.text][:1]
            label_after = [(r.bold, r.italic) for r in pb.runs if r.text and new not in r.text][:1]
            assert label_before == label_after, "formatting of surrounding text changed"
    print(f"{edited.name}: replacement verified, formatting preserved")

if __name__ == "__main__":
    verify(Path("in/invoice-template.docx"), Path("out/invoice-2231.docx"), "{{invoice_number}}", "INV-2231")

The text-equality assertion catches both missed matches and collateral edits. The formatting checks are intentionally simple — bold, italic and font name — and catch the paragraph-level assignment mistake immediately.

FAQ

Why does paragraph.text show the placeholder when no run contains it?paragraph.text concatenates the text of all runs. The placeholder exists in the concatenation, not in any single run.

Can I avoid runs entirely by editing the XML string? String replacement on document.xml fails for the same reason: the placeholder is split by XML tags between runs. Only run-aware logic or a template engine solves it.

Does this work inside tables and headers? Yes — the function takes any paragraph. Collect paragraphs from all containers as in replace text in headers, footers and tables.

What if the replacement should itself contain bold text? Put the new text in the host run, then split that run into pieces with explicit formatting by inserting new w:r elements after it. For anything beyond one style, use a docxtpl template with rich text instead.

Part of Find and Replace Text in Word Documents with Python.