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.
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.
Variant Fix 2: Hyperlinks Hide Their Runs
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.
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.
Related
- Find and Replace Text in Word Documents with Python — the complete replacement workflow
- Batch Replace Placeholders in Many DOCX Files — applying the fix across a folder
- Fix docxtpl Jinja2 UndefinedError — split Jinja tags cause errors instead of silent misses
- Set Fonts and Styles with python-docx — run properties and style inheritance
Part of Find and Replace Text in Word Documents with Python.