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.

Page break versus section break A page break is a run-level character that pushes the following text to the next page. Orientation, page size, margins and headers stay the same because the document still has one section. A section break starts a new section, so the pages after it can be landscape, have different margins and carry a different header, and the break itself also starts a new page when its type is new page. Page break run.add_break(WD_BREAK.PAGE) next content on a new page same orientation same margins and headers one section Section break doc.add_section(NEW_PAGE) next content on a new page own orientation and size own margins and headers new section

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.

Three sections, one document Section one is portrait with 2.5 centimetre margins and contains the heading and summary. A new-page section break starts section two, which is landscape with 1.5 centimetre margins and holds the wide cost table. A second new-page section break starts section three, which returns to portrait with 2.5 centimetre margins for the appendix. Orientation applies per section, so the wide table gets its own pages. report.docx, section layout 1 1 Section 1 — portrait summary, margins 2.5 cm 2 2 Section 2 — landscape wide table, margins 1.5 cm 3 3 Section 3 — portrait appendix, margins restored

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.

Which layout tool for which need A page break moves following content to the next page and is fragile when content above it changes. A new-page section break does the same and additionally allows different orientation, margins and headers. Keep-with-next stops a heading being stranded and adapts automatically. Keep-together stops a paragraph splitting across pages and adapts automatically. Repeating a table header row makes long tables readable on every page. Tool Achieves Survives content changes Page break next page fragile Section break (new page) next page + new properties structural keep_with_next heading with its text yes keep_together no split inside yes tblHeader repeat header on every page yes

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.

Part of Automating Word Document Creation.