Replace Text in Headers, Footers and Tables with python-docx
The rebrand script replaced the old company name in every paragraph of 120 policy documents, and the review still found it: in the footer on every page, in the header of the cover page only, in the "Document owner" table at the top, in a nested table inside the approvals block, and on even pages of the documents printed double-sided. The body is clean; everything around it is not.
for p in doc.paragraphs: # body only
replace_in_paragraph(p, pattern, "Northwind Group plc")
Root Cause
Document.paragraphs lists paragraphs that are direct children of the main document body — nothing else. Table cells hold their own paragraphs (and possibly further tables), reachable only through doc.tables → rows → cells. Headers and footers are separate parts of the package, one per section, and each section can have up to three variants of each: default, first page (when "Different First Page" is on) and even page (when "Different Odd & Even Pages" is enabled in the document settings). A section whose header is "linked to previous" has no header part of its own and shows the previous section's. Each container needs to be visited explicitly, and visiting some twice — a merged table cell that appears once per grid column, a linked header counted per section — causes double replacement, which matters for counters and appended text.
Minimal Diagnostic
List every container that holds the target text, with its section and variant, so you can see which ones the current script skips.
# pip install "python-docx>=1.1"
from pathlib import Path
from docx import Document
SOURCE = Path("in/policies/information-security.docx")
TARGET = "Acme Holdings Ltd"
def where_is(path: Path, target: str) -> None:
try:
doc = Document(path)
except Exception as exc:
raise SystemExit(f"cannot open {path}: {exc}")
hits = []
hits += [("body", p.text) for p in doc.paragraphs if target in p.text]
for ti, table in enumerate(doc.tables):
for ri, row in enumerate(table.rows):
for ci, cell in enumerate(row.cells):
if target in cell.text:
nested = " (contains nested table)" if cell.tables else ""
hits.append((f"table {ti} r{ri}c{ci}{nested}", cell.text[:40]))
settings = doc.settings.odd_and_even_pages_header_footer
for si, section in enumerate(doc.sections):
variants = [("header", section.header), ("footer", section.footer)]
if section.different_first_page_header_footer:
variants += [("first-page header", section.first_page_header),
("first-page footer", section.first_page_footer)]
if settings:
variants += [("even-page header", section.even_page_header),
("even-page footer", section.even_page_footer)]
for name, part in variants:
linked = " (linked to previous)" if part.is_linked_to_previous else ""
text = "\n".join(p.text for p in part.paragraphs)
text += "\n".join(c.text for t in part.tables for r in t.rows for c in r.cells)
if target in text:
hits.append((f"section {si} {name}{linked}", ""))
for where, sample in hits:
print(f"{where:<45} {sample!r}")
if __name__ == "__main__":
where_is(SOURCE, TARGET)
table 0 r1c1 'Acme Holdings Ltd'
table 2 r3c0 (contains nested table) 'Approved by:\nAcme Holdings Ltd'
section 0 footer ''
section 0 first-page header ''
section 0 even-page footer ''
Five occurrences, none in the body. The nested-table flag also warns that cell.text already includes the nested table's text, so replacing both the cell and the nested table naively would process that text twice.
Fix: One Iterator for Every Paragraph, Each Visited Once
Build a single generator that yields each paragraph in the document exactly once — body, tables at any depth, and every unlinked header and footer variant — and run the replacement over it. Changed lines carry comments.
# pip install "python-docx>=1.1"
import re
from pathlib import Path
from docx import Document
from docx.text.paragraph import Paragraph
from wordreplace import replace_in_paragraph # cross-run replacement helper
SOURCE = Path("in/policies/information-security.docx")
DEST = Path("out/policies/information-security.docx")
def container_paragraphs(container, seen_cells: set):
yield from container.paragraphs # paragraphs directly in container
for table in container.tables:
for row in table.rows:
for cell in row.cells:
key = id(cell._tc)
if key in seen_cells: # changed: merged cells once
continue
seen_cells.add(key)
yield from container_paragraphs(cell, seen_cells) # changed: recurse into nesting
def every_paragraph(doc):
seen_cells: set = set()
yield from container_paragraphs(doc, seen_cells)
seen_parts: set = set()
for section in doc.sections:
for part in (section.header, section.first_page_header, section.even_page_header,
section.footer, section.first_page_footer, section.even_page_footer):
if part.is_linked_to_previous: # changed: no own part to edit
continue
if id(part.part) in seen_parts: # changed: shared part once
continue
seen_parts.add(id(part.part))
yield from container_paragraphs(part, seen_cells) # changed: header tables too
def rebrand(src: Path, dest: Path, old: str, new: str) -> int:
doc = Document(src)
pattern = re.compile(re.escape(old))
count = sum(replace_in_paragraph(p, pattern, lambda m: new) for p in every_paragraph(doc))
dest.parent.mkdir(parents=True, exist_ok=True)
doc.save(dest)
return count
if __name__ == "__main__":
try:
print(rebrand(SOURCE, DEST, "Acme Holdings Ltd", "Northwind Group plc"), "replacement(s)")
except Exception as exc:
raise SystemExit(f"rebrand failed: {exc}")
Recursing through cell.paragraphs and cell.tables — never cell.text — is what prevents nested-table text from being handled twice: each paragraph element belongs to exactly one container. Iterating all three header variants unconditionally is safe even when "different first page" is off: accessing first_page_header on such a section returns a linked, part-less object, which the is_linked_to_previous check skips. Checking the part identity guards the rare document where two sections share a header part explicitly.
Variant Fix 1: Text Boxes and Shapes in Headers
Logos with a company name overlaid, or a "CONFIDENTIAL – Acme" banner, are often text boxes anchored in the header. They are in the header part's XML, not in header.paragraphs. Query them per header part and wrap them as paragraphs:
# pip install "python-docx>=1.1"
from docx.oxml.ns import qn
from docx.text.paragraph import Paragraph
MC_FALLBACK = "{http://schemas.openxmlformats.org/markup-compatibility/2006}Fallback"
def header_textbox_paragraphs(doc):
for section in doc.sections:
for part in (section.header, section.first_page_header, section.even_page_header,
section.footer, section.first_page_footer, section.even_page_footer):
if part.is_linked_to_previous:
continue
for box in part._element.iter(qn("w:txbxContent")):
for p in box.iter(qn("w:p")):
yield Paragraph(p, part)
Unlike extraction, replacement should include the VML fallback copy as well as the DrawingML version, so older readers do not display the old text; do not filter mc:Fallback here. Text in the body's own text boxes is covered in fix python-docx missing text in text boxes.
Variant Fix 2: Fields That Show the Old Value
Some footers display the company name through a field — DOCPROPERTY Company or { AUTHOR } — rather than typed text. The visible text is a cached result, so replacing it lasts only until Word updates fields. Change the source instead: the document property the field reads.
# pip install "python-docx>=1.1"
from docx import Document
def update_core_properties(doc, company_old: str, company_new: str) -> list[str]:
changed = []
props = doc.core_properties
for name in ("title", "subject", "author", "last_modified_by", "keywords", "comments"):
value = getattr(props, name) or ""
if company_old in value:
setattr(props, name, value.replace(company_old, company_new))
changed.append(name)
return changed
The Company property lives in the extended properties part (docProps/app.xml), which python-docx does not expose; edit that XML part directly or update it once in Word for templates. After changing properties, set the document to refresh fields on open so readers see the new value immediately:
# pip install "python-docx>=1.1"
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
def update_fields_on_open(doc) -> None:
settings = doc.settings.element
flag = settings.find(qn("w:updateFields"))
if flag is None:
flag = OxmlElement("w:updateFields")
settings.append(flag)
flag.set(qn("w:val"), "true")
Word shows a prompt asking to update fields when such a document opens; for documents sent externally, consider converting fields to plain text instead.
Batch Rebranding a Folder
Applying the iterator across a document set is a loop with per-file counts and a check for the old name anywhere in the saved file, including parts the iterator does not cover:
# pip install "python-docx>=1.1"
import zipfile
from pathlib import Path
def old_name_anywhere(path: Path, old: str) -> list[str]:
"""Search every XML part of the saved package for the old name."""
hits = []
with zipfile.ZipFile(path) as zf:
for name in zf.namelist():
if name.endswith(".xml") and old.encode("utf-8") in zf.read(name):
hits.append(name)
return hits
def rebrand_folder(src: Path, out: Path, old: str, new: str) -> None:
for path in sorted(src.glob("*.docx")):
if path.name.startswith("~$"):
continue
dest = out / path.name
count = rebrand(path, dest, old, new)
leftovers = old_name_anywhere(dest, old)
status = "ok" if not leftovers else f"old name still in {leftovers}"
print(f"{path.name}: {count} replaced; {status}")
The package-wide byte search is deliberately blunt. It finds the old name in docProps/app.xml, footnotes, comments and chart labels — places a paragraph iterator will never see — and it misses only occurrences split across runs, which the run-aware replacement already handled in the parts it visited. Anything it reports is a part to add to the job or to fix by hand.
Verification
Confirm that no container still shows the old name, that each container kept its paragraph count, and that header and footer variants were not unlinked or duplicated by the edit.
# pip install "python-docx>=1.1"
from pathlib import Path
from docx import Document
def verify_rebrand(original: Path, edited: Path, old: str, new: str) -> None:
a, b = Document(original), Document(edited)
texts_b = [p.text for p in every_paragraph(b)]
assert not [t for t in texts_b if old in t], "old name still visible in a container"
texts_a = [p.text for p in every_paragraph(a)]
assert len(texts_a) == len(texts_b), "paragraph count changed across containers"
assert [t.replace(old, new) for t in texts_a] == texts_b, "unexpected text changes"
links_a = [(s.header.is_linked_to_previous, s.footer.is_linked_to_previous) for s in a.sections]
links_b = [(s.header.is_linked_to_previous, s.footer.is_linked_to_previous) for s in b.sections]
assert links_a == links_b, "header/footer linking changed"
print(f"{edited.name}: all containers rebranded, structure unchanged")
Linking can change silently if code accesses section.header.paragraphs on a linked header and then adds content — python-docx creates a new header part to hold it. The replacement never adds content, so links should be identical; the assertion guards against future edits to the job that do.
FAQ
Why does editing a linked header change every section? A linked header has no part of its own; it displays the previous section's part. Editing the first section's header changes all sections linked to it, which is usually what you want.
How do I replace only in the first-page header?
Target section.first_page_header directly and skip the others. Check section.different_first_page_header_footer first; without it, Word never shows that header.
Do table cells with vertical merges repeat too?
Vertically merged cells are separate w:tc elements with continuation markers and usually empty text, so they are not repeated in the same way; the _tc identity check is for horizontal merges.
Can I replace text in footnotes with the same iterator? No; footnotes are a separate part that python-docx does not expose. Use the package-wide search above to detect them and handle that part's XML directly.
Related
- Find and Replace Text in Word Documents with Python — the full replacement workflow
- Fix python-docx Replace Text Split Across Runs — the replacement helper used here
- Add Headers and Footers with python-docx — how variants and linking are created
- Building and Editing Word Tables — table structure, merges and nesting
Part of Find and Replace Text in Word Documents with Python.