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.

Containers a replacement must visit The document branches into the body, which holds top-level paragraphs and tables whose cells hold paragraphs and nested tables; sections, each with default, first-page and even-page headers and footers, which are skipped when linked to the previous section; and text boxes that can appear inside any of them. Each leaf lists how python-docx reaches it. Document every place text can live body Paragraphs doc.paragraphs body Tables cells, then cell.tables per section Headers default, first, even per section Footers default, first, even Nested tables recurse once Skip if linked is_linked_to_previous Skip if linked is_linked_to_previous

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.

Which header variants Word actually shows The default header is shown on every page unless another variant applies and is always visited unless linked. The first page header is shown only when different first page is enabled for the section. The even page header is shown only when different odd and even pages is enabled in the document settings. Any variant that is linked to previous has no part of its own and is skipped, because the earlier section's part is edited instead. Variant Shown when Iterator action Default header always visit unless linked First-page header different first page on visit unless linked Even-page header odd and even pages on visit unless linked Linked variant inherits previous section skip; edit the source

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.

Typed footer text versus a field result The left panel shows a footer paragraph with a plain run reading Acme Holdings Ltd, which the run-aware replacement changes permanently. The right panel shows a footer paragraph built from fldChar begin, an instrText DOCPROPERTY Company run, fldChar separate, a cached result run reading Acme Holdings Ltd and fldChar end; replacing the cached run only lasts until Word updates the field, so the document property must be changed instead. Typed text in footer <w:r><w:t>Acme Holdings Ltd </w:t></w:r> replace run text: change is permanent DOCPROPERTY field fldChar begin instrText DOCPROPERTY Company fldChar separate <w:t>Acme Holdings Ltd (cached) reverts on field update

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.

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