Add Headers and Footers with python-docx
Setting header text is one line, and the two things people actually want — a page number and a different first page — are not:
>>> section.header.paragraphs[0].text = "Quarterly report" # works
>>> section.footer.paragraphs[0].text = f"Page {page_number}" # there is no page_number
NameError: name 'page_number' is not defined
python-docx has no page concept. Pagination happens when Word lays the document out, so a page number can only be a field — an instruction Word evaluates at render time — and fields have no wrapper in the library. Everything else about headers is straightforward once the section model is clear.
Root Cause
A Word document is a list of sections, and every section owns its own header and footer objects. A one-section document has one header, so setting it covers every page. Add a section break — for landscape pages, a new chapter, a different margin set — and the new section starts with is_linked_to_previous = True, inheriting the previous header until you explicitly unlink it.
Each section actually has three headers: the default, a first-page header used when different_first_page_header_footer is true, and an even-page header used when the document is set for odd/even variation. Writing to section.header when the first-page flag is set leaves page 1 showing an empty header, which reads as "the header did not work".
Minimal Diagnostic
Report every section's flags and header contents before changing anything.
# pip install "python-docx>=1.1,<2"
from pathlib import Path
from docx import Document
DOCX = Path("data/report.docx")
def header_report(docx_path: Path) -> None:
"""Show each section's header/footer text and the flags that select them."""
if not docx_path.exists():
raise FileNotFoundError(f"No such document: {docx_path}")
try:
document = Document(str(docx_path))
except Exception as exc:
raise RuntimeError(f"Could not open {docx_path}: {exc}") from exc
for index, section in enumerate(document.sections):
print(f"section {index}: start={section.start_type}")
print(f" different first page: {section.different_first_page_header_footer}")
print(f" header linked to previous: {section.header.is_linked_to_previous}")
print(f" header text: {[p.text for p in section.header.paragraphs]}")
print(f" first-page header text: {[p.text for p in section.first_page_header.paragraphs]}")
print(f" footer text: {[p.text for p in section.footer.paragraphs]}")
if __name__ == "__main__":
header_report(DOCX)
section 0: start=NEW_PAGE (0)
different first page: True
header linked to previous: False
header text: ['Quarterly report']
first-page header text: [''] <- page 1 shows nothing
footer text: ['']
That output diagnoses the most common complaint in one line: the flag is on and the first-page header was never written.
The Fix: Write Every Header the Document Will Use
# pip install "python-docx>=1.1,<2"
from pathlib import Path
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.shared import Pt
OUT = Path("out/report.docx")
def set_headers(document: Document, running_title: str, first_page_title: str | None = None) -> None:
"""Write the default header, and the first-page header when that flag is on."""
for section in document.sections:
if section is not document.sections[0]:
section.header.is_linked_to_previous = False # unlink before writing
section.footer.is_linked_to_previous = False
paragraph = section.header.paragraphs[0]
paragraph.text = running_title
paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
for run in paragraph.runs:
run.font.size = Pt(9)
if first_page_title is not None:
section.different_first_page_header_footer = True
first = section.first_page_header.paragraphs[0]
first.text = first_page_title
first.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in first.runs:
run.bold = True
run.font.size = Pt(12)
if __name__ == "__main__":
document = Document()
document.add_heading("Quarterly report", level=1)
for _ in range(60):
document.add_paragraph("Reconciled ledger movements for the period.")
set_headers(document, running_title="Quarterly report — commercial in confidence",
first_page_title="ACME LIMITED — QUARTERLY REPORT")
OUT.parent.mkdir(parents=True, exist_ok=True)
document.save(OUT)
print(f"Wrote {OUT}")
Unlinking before writing is the rule to internalise. Assigning to a linked section's header silently writes into an object Word will never render, which produces the "my change did nothing" report with no error to go on.
Adding a Page Number Field
A page number is a field code, built from three XML elements: a begin marker, the instruction text, and an end marker. Word evaluates it on open.
# pip install "python-docx>=1.1,<2"
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
def add_field(paragraph, instruction: str) -> None:
"""Insert a Word field (e.g. 'PAGE', 'NUMPAGES') into a paragraph."""
run = paragraph.add_run()
begin = OxmlElement("w:fldChar")
begin.set(qn("w:fldCharType"), "begin")
instr = OxmlElement("w:instrText")
instr.set(qn("xml:space"), "preserve")
instr.text = f" {instruction} "
end = OxmlElement("w:fldChar")
end.set(qn("w:fldCharType"), "end")
for element in (begin, instr, end):
run._r.append(element)
def page_x_of_y(section, prefix: str = "Page ") -> None:
"""Footer reading 'Page X of Y', evaluated by Word at render time."""
paragraph = section.footer.paragraphs[0]
paragraph.text = ""
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
paragraph.add_run(prefix)
add_field(paragraph, "PAGE") # current page
paragraph.add_run(" of ")
add_field(paragraph, "NUMPAGES") # total pages
Two consequences follow from this being a field rather than text. Extracting the footer with python-docx returns "Page of " — the numbers do not exist until a renderer evaluates them, which matters when writing tests. And a converter that does not evaluate fields renders them blank, so verify the PDF as well as the .docx when the pipeline ends in DOCX to PDF conversion.
The same problem exists on the PDF side of the house, where the total page count is only known after layout; the ReportLab solution is in add page numbers and headers to PDF reports.
Variant: A Logo in the Header
Headers accept the same content as the body, including images.
# pip install "python-docx>=1.1,<2"
from pathlib import Path
from docx.enum.text import WD_TAB_ALIGNMENT
from docx.shared import Cm
def logo_header(section, logo: Path, title: str) -> None:
"""Logo on the left, title on the right, on one header line."""
if not logo.exists():
raise FileNotFoundError(f"Logo not found: {logo}")
paragraph = section.header.paragraphs[0]
paragraph.text = ""
run = paragraph.add_run()
run.add_picture(str(logo), height=Cm(1.0)) # height only: width scales proportionally
# A right tab stop at the page width pushes the title to the margin
paragraph.paragraph_format.tab_stops.add_tab_stop(
section.page_width - section.left_margin - section.right_margin,
WD_TAB_ALIGNMENT.RIGHT,
)
paragraph.add_run("\t" + title)
Setting only height keeps the aspect ratio, which avoids the distortion covered in fix image too large in python-docx. If the logo overlaps the body text, increase section.header_distance or the top margin — the header band does not push the body down automatically.
Verification
Assert on the saved file, remembering that field results are not present in the XML.
# pip install "python-docx>=1.1,<2"
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
def assert_headers(docx_path: Path, running_title: str, expect_page_field: bool = True) -> None:
"""Confirm each section has the intended header text and footer field."""
document = Document(str(docx_path))
for index, section in enumerate(document.sections):
header_text = " ".join(p.text for p in section.header.paragraphs).strip()
assert running_title in header_text, (
f"section {index}: header is {header_text!r}, expected to contain {running_title!r}"
)
if section.different_first_page_header_footer:
first_text = " ".join(p.text for p in section.first_page_header.paragraphs).strip()
assert first_text, f"section {index}: first-page header is empty but the flag is on"
if expect_page_field:
instructions = [
node.text or "" for node in section.footer._element.iter(qn("w:instrText"))
]
assert any("PAGE" in text for text in instructions), (
f"section {index}: no PAGE field in the footer"
)
print(f"{len(document.sections)} section(s) verified")
if __name__ == "__main__":
assert_headers(Path("out/report.docx"), "Quarterly report")
Searching for w:instrText rather than for rendered digits is what makes the page-number assertion possible at all. For end-to-end proof, convert to PDF and extract the text of page 2 — a real renderer evaluates the field, so Page 2 of 5 appears there and nowhere else.
FAQ
Why is my header missing on page 1 only?different_first_page_header_footer is on and only the default header was written. Write section.first_page_header as well, or turn the flag off.
Why did my change to section 2's header do nothing?
It is still linked to section 1. Set section.header.is_linked_to_previous = False before writing, otherwise the object you edited is not the one Word renders.
Can I get the real page count in Python?
Not from python-docx — pagination is a rendering result. Convert to PDF and count pages there, or use the NUMPAGES field and let Word compute it.
How do I stop the header appearing on landscape pages? Landscape pages are their own section. Unlink that section and clear its header, or write a shorter variant sized for the wider page.
Does the header count towards the top margin?
No. The header band lives inside the margin area, and content taller than top_margin minus header_distance overlaps the body. Increase top_margin to make room.
Testing Chrome Without Opening Word
Headers, footers and page fields are the parts of a generated document least likely to be checked, because verifying them appears to require opening the file. Two cheap assertions remove that excuse.
The first is structural and runs on the .docx directly: every section has non-empty header text, the first-page header is populated whenever its flag is set, and a PAGE field instruction exists in the footer. That catches the unlinked-section and empty-first-page failures without rendering anything.
The second runs on a converted PDF and is the only way to see a field's value: extract the text of page two and assert it contains the expected numbering. Converting one representative document per build is enough — the chrome is identical across the set, so a single sample proves it for all of them.
Related
- Automating Word Document Creation — the document-building workflow this extends
- Set Fonts and Styles with python-docx — styling header runs consistently
- Building and Editing Word Tables with Python — repeating table headers across pages
- Converting DOCX to PDF with Python — making sure fields evaluate in the output
- Add Page Numbers and Headers to PDF Reports — the same problem on the PDF side
Part of Automating Word Document Creation.