Insert Page Breaks and Landscape Sections with python-docx
The generated report needs its appendix to start on a new page, and the wide cost table in the middle has to be landscape while everything around it stays portrait. Two attempts fail in instructive ways: add_page_break() starts the appendix correctly but rotating the page afterwards turns the whole document landscape, and setting orientation = WD_ORIENT.LANDSCAPE alone leaves the page portrait-shaped with the text rotated inside it.
section = doc.sections[0]
section.orientation = WD_ORIENT.LANDSCAPE # page stays 21.0 x 29.7 cm
Root Cause
Word has two different concepts that both "break" a page. A page break is a character inside a paragraph run: it pushes the following content to the next page and changes nothing else. A section break starts a new section, and section properties — orientation, page size, margins, headers and footers, column count, page numbering — apply to a whole section. Orientation is one of those properties, so a document with one section can only be entirely portrait or entirely landscape; a landscape page in the middle requires three sections. The second failure has a separate cause: orientation is a flag Word stores, but the actual page dimensions come from page_width and page_height, which python-docx does not swap for you. Setting the flag without swapping the dimensions gives a portrait-sized page marked as landscape, which Word renders as rotated text on a portrait sheet.
Minimal Diagnostic
List the document's sections with their type, orientation, dimensions and header linkage, and count explicit page breaks.
# pip install python-docx
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
from docx.shared import Cm
SOURCE = Path("in/report.docx")
def layout_report(path: Path) -> None:
doc = Document(path)
for i, section in enumerate(doc.sections):
width, height = section.page_width, section.page_height
shape = "landscape" if width > height else "portrait"
flag = str(section.orientation).split(".")[-1]
mismatch = " <- flag and dimensions disagree" if (width > height) != (flag == "LANDSCAPE") else ""
print(f"section {i}: start={str(section.start_type).split('.')[-1]:<14} "
f"{Cm(width.cm).cm:.1f} x {Cm(height.cm).cm:.1f} cm ({shape}), flag={flag}{mismatch}")
print(f" margins L/R {section.left_margin.cm:.1f}/{section.right_margin.cm:.1f} cm, "
f"header linked={section.header.is_linked_to_previous}")
breaks = len(doc.element.body.findall(f".//{qn('w:br')}[@{qn('w:type')}='page']"))
print(f"explicit page breaks: {breaks}")
if __name__ == "__main__":
layout_report(SOURCE)
section 0: start=NEW_PAGE 21.0 x 29.7 cm (portrait), flag=LANDSCAPE <- flag and dimensions disagree
margins L/R 2.5/2.5 cm, header linked=False
explicit page breaks: 3
One section for the whole document, and its orientation flag contradicts its dimensions — exactly the two mistakes described above.
Fix: Three Sections Around the Landscape Block
Wrap the wide table in its own section, swap both the flag and the dimensions, and restore portrait afterwards. Changed lines carry comments.
# pip install python-docx
from pathlib import Path
from docx import Document
from docx.enum.section import WD_ORIENT, WD_SECTION
from docx.enum.text import WD_BREAK
from docx.shared import Cm
DEST = Path("out/report.docx")
def set_orientation(section, landscape: bool) -> None:
"""Set the flag and the page dimensions together — Word needs both."""
width, height = section.page_width, section.page_height
is_landscape = width > height
if landscape != is_landscape:
section.page_width, section.page_height = height, width # changed: swap dimensions
section.orientation = WD_ORIENT.LANDSCAPE if landscape else WD_ORIENT.PORTRAIT # changed: and the flag
def build_report(rows: list[tuple[str, str, float]], dest: Path) -> Path:
doc = Document()
doc.add_heading("Quarterly review", level=1)
doc.add_paragraph("Summary of the quarter, in portrait.")
wide = doc.add_section(WD_SECTION.NEW_PAGE) # changed: section 2 starts here
set_orientation(wide, landscape=True)
wide.left_margin = wide.right_margin = Cm(1.5) # changed: more width for the table
doc.add_heading("Cost breakdown", level=2)
table = doc.add_table(rows=1, cols=3)
table.style = "Light Grid Accent 1"
for cell, text in zip(table.rows[0].cells, ("Cost centre", "Description", "Amount")):
cell.text = text
for centre, description, amount in rows:
cells = table.add_row().cells
cells[0].text, cells[1].text, cells[2].text = centre, description, f"{amount:,.2f}"
back = doc.add_section(WD_SECTION.NEW_PAGE) # changed: section 3, back to portrait
set_orientation(back, landscape=False)
back.left_margin = back.right_margin = Cm(2.5)
doc.add_heading("Appendix", level=1)
doc.add_paragraph("Notes and definitions.")
dest.parent.mkdir(parents=True, exist_ok=True)
doc.save(dest)
return dest
if __name__ == "__main__":
print(build_report([("CC-100", "Licences", 12400.0), ("CC-220", "Travel", 8210.5)], DEST))
Each new section inherits the previous section's properties, so the third section must be set back to portrait explicitly and its margins restored — otherwise the appendix inherits the landscape block's narrow margins. Headers and footers also inherit through is_linked_to_previous; leave them linked unless the landscape pages need their own, in which case set wide.header.is_linked_to_previous = False and add content to it.
WD_SECTION.NEW_PAGE is the usual break type. CONTINUOUS starts a section on the same page, which is right for changing column count mid-page but cannot change orientation, since a single sheet has only one.
Variant Fix 1: Page Breaks Without a New Section
When only the position of a break matters — an appendix on a fresh page, a one-page-per-customer document — use a run-level page break, which is cheaper and does not disturb headers:
# pip install python-docx
from docx import Document
from docx.enum.text import WD_BREAK
def add_page_break(doc: Document) -> None:
doc.add_paragraph().add_run().add_break(WD_BREAK.PAGE) # an empty paragraph carrying the break
def page_per_record(records: list[dict], dest) -> None:
doc = Document()
for i, record in enumerate(records):
if i:
add_page_break(doc) # break before every record except the first
doc.add_heading(record["title"], level=1)
doc.add_paragraph(record["body"])
doc.save(dest)
Adding the break before each record except the first avoids a trailing blank page — the most common cosmetic complaint about generated documents. doc.add_page_break() exists as a convenience and does the same thing.
Variant Fix 2: Keeping Content Together Instead of Breaking
Often the goal is not "break here" but "do not break inside this": a table row split across pages, or a heading stranded at the bottom. Paragraph formatting controls that, and it adapts when content changes:
# pip install python-docx
from docx import Document
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
def keep_with_next(paragraph) -> None:
paragraph.paragraph_format.keep_with_next = True # heading stays with the following paragraph
def keep_together(paragraph) -> None:
paragraph.paragraph_format.keep_together = True # do not split this paragraph across pages
def repeat_table_header(table) -> None:
header = table.rows[0]._tr
properties = header.get_or_add_trPr()
repeat = OxmlElement("w:tblHeader") # repeat the header row on every page
repeat.set(qn("w:val"), "true")
properties.append(repeat)
def rows_unbreakable(table) -> None:
for row in table.rows:
properties = row._tr.get_or_add_trPr()
cant_split = OxmlElement("w:cantSplit") # keep each row on one page
properties.append(cant_split)
Repeating the header row is what makes a multi-page table readable, and cantSplit stops a row's text being cut in half — the Word equivalent of the print settings described in add subtotals to Excel reports with pandas. Prefer these properties to manual page breaks: a break inserted for today's content lands in the wrong place as soon as a paragraph above it grows.
Section-Specific Headers and Page Numbering
A landscape section often needs its own header, and long documents restart numbering per part:
# pip install python-docx
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
def unlink_header(section, text: str) -> None:
section.header.is_linked_to_previous = False # changed: own header part
paragraph = section.header.paragraphs[0]
paragraph.text = text
def restart_page_numbering(section, start_at: int = 1) -> None:
properties = section._sectPr
existing = properties.find(qn("w:pgNumType"))
if existing is None:
existing = OxmlElement("w:pgNumType")
properties.append(existing)
existing.set(qn("w:start"), str(start_at)) # numbering restarts in this section
Unlinking a header creates a new header part for that section; every later section linked to it inherits the new text, which is usually desirable for the appendix and surprising if you expected the original. Check the result with the diagnostic — it prints header linked per section — before assuming the document looks the way the code implies.
Verification
Assert the section structure and dimensions of the saved file, rather than trusting the code that wrote it.
# pip install python-docx
from pathlib import Path
from docx import Document
from docx.enum.section import WD_ORIENT
def verify_layout(path: Path, expected: list[tuple[str, bool]]) -> None:
doc = Document(path)
assert len(doc.sections) == len(expected), f"{len(doc.sections)} sections, expected {len(expected)}"
for i, (section, (label, landscape)) in enumerate(zip(doc.sections, expected)):
wider = section.page_width > section.page_height
assert wider == landscape, f"section {i} ({label}): dimensions are {'landscape' if wider else 'portrait'}"
flag_landscape = section.orientation == WD_ORIENT.LANDSCAPE
assert flag_landscape == landscape, f"section {i} ({label}): orientation flag disagrees with dimensions"
assert section.left_margin.cm >= 1.0, f"section {i}: margin too small to print"
print(f"{path.name}: {len(expected)} sections verified")
if __name__ == "__main__":
verify_layout(Path("out/report.docx"),
[("summary", False), ("cost table", True), ("appendix", False)])
Checking flag and dimensions separately is the point: they are the two halves of the orientation fix, and a document where they disagree opens looking wrong in Word while every python-docx property reads as expected. For a final visual check, convert the document to PDF and look at the page shapes, using the conversion in Converting DOCX to PDF with Python.
FAQ
Why is there a blank page at the end? A trailing page break or an empty paragraph after the last content. Add breaks before content rather than after it.
Can one page be landscape without section breaks? No. Orientation is a section property, so a landscape page needs its own section.
Do section breaks affect the table of contents? No. The TOC is built from headings; sections affect page numbering, which the TOC picks up when fields are updated.
How do I set A4 explicitly?section.page_width, section.page_height = Cm(21.0), Cm(29.7) for portrait; swap them for landscape. Templates often default to Letter on US systems.
Related
- Automating Word Document Creation — building documents end to end
- Add Headers and Footers with python-docx — per-section headers in depth
- Building and Editing Word Tables — the wide tables that need landscape pages
- Converting DOCX to PDF with Python — checking the finished layout
Part of Automating Word Document Creation.