Add Page Numbers and Headers to PDF Reports

A generated report without page numbers is unusable the moment it is printed, and the naive attempt fails in a specific way:

# Inside a flowable-based document, this prints the wrong total on every page
canvas.drawString(500, 20, f"Page {canvas.getPageNumber()} of {total_pages}")
# NameError: name 'total_pages' is not defined

The current page number is available while drawing; the total is not, because ReportLab does not know how many pages the story will fill until it has laid all of it out. Headers and footers therefore need a page-level drawing hook, and "Page X of Y" needs one extra trick on top: draw everything twice, or defer the total until the document is closed.

Root Cause

SimpleDocTemplate.build() flows a list of flowables — paragraphs, tables, spacers — onto pages until it runs out. Page breaks are decided during that flow, so the final page count only exists afterwards. Meanwhile, anything you want painted on every page (a letterhead, a confidentiality footer, a page number) is not part of the story at all: it belongs to the page template and is drawn by the onPage callback each time a page begins or ends.

Why the page total is unknown while drawing A timeline of a ReportLab build. The story of flowables enters the layout engine. As each page fills, the onPage callback fires and draws the header and footer for that page, at which point only the current page number is known. After the last page, the document is finalised and the total page count becomes available. A two pass build or a deferred canvas is what makes the total reachable from the footer. story = [flowables] paragraphs and tables Layout engine fills page 1, 2, 3 … breaks decided here total still unknown onPage callback draws header and footer Document closed page total now known Second pass stamps "of Y" per page Chrome is drawn per page; the total is a document-level fact, so one of them has to wait

Prerequisites

python -m venv .venv
source .venv/bin/activate
pip install "reportlab>=4,<5"
mkdir -p out

Fix: Headers and Footers via an onPage Callback

The callback receives the canvas and the document, draws directly, and must save and restore canvas state so it cannot leak fonts or colours into the story.

# pip install "reportlab>=4,<5"
from datetime import date
from pathlib import Path

from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer

OUT = Path("out/report.pdf")
TITLE = "Q3 Reconciliation Report"

def draw_chrome(canvas, doc) -> None:
    """Paint the running header and footer for one page."""
    canvas.saveState()                       # never leak state back into the story
    width, height = A4

    canvas.setFont("Helvetica-Bold", 9)
    canvas.drawString(20 * mm, height - 15 * mm, TITLE)
    canvas.setFont("Helvetica", 8)
    canvas.drawRightString(width - 20 * mm, height - 15 * mm, date.today().isoformat())
    canvas.setLineWidth(0.5)
    canvas.line(20 * mm, height - 17 * mm, width - 20 * mm, height - 17 * mm)

    canvas.setFont("Helvetica", 8)
    canvas.drawString(20 * mm, 12 * mm, "Commercial in confidence")
    canvas.drawRightString(width - 20 * mm, 12 * mm, f"Page {canvas.getPageNumber()}")
    canvas.restoreState()

def build(dest: Path) -> Path:
    styles = getSampleStyleSheet()
    story = []
    for section in range(1, 9):
        story.append(Paragraph(f"Section {section}", styles["Heading2"]))
        story.append(Paragraph("Reconciled ledger movements for the period. " * 25, styles["BodyText"]))
        story.append(Spacer(1, 6 * mm))

    dest.parent.mkdir(parents=True, exist_ok=True)
    doc = SimpleDocTemplate(
        str(dest),
        pagesize=A4,
        topMargin=25 * mm,        # leave room for the header the callback draws
        bottomMargin=20 * mm,     # and for the footer
        leftMargin=20 * mm,
        rightMargin=20 * mm,
        title=TITLE,
    )
    try:
        doc.build(story, onFirstPage=draw_chrome, onLaterPages=draw_chrome)
    except Exception as exc:
        raise RuntimeError(f"Report build failed: {exc}") from exc
    return dest

if __name__ == "__main__":
    print(f"Wrote {build(OUT)}")

The margins are the part people miss. onPage draws outside the story's frame, so if topMargin is left at its default the header lands on top of the first paragraph. Reserve the space explicitly, and keep the header's baseline inside the reserved band.

Passing the same function to onFirstPage and onLaterPages gives uniform chrome. Pass different functions when the cover page should carry a full letterhead and the rest a slim running head — a common house style for board packs.

Fix: "Page X of Y" with a Deferred Canvas

To know Y, subclass Canvas, buffer each page's state instead of emitting it, and write them all out at save() time — at which point the count is simply the length of the buffer.

# pip install "reportlab>=4,<5"
from pathlib import Path

from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas as pdfcanvas

class NumberedCanvas(pdfcanvas.Canvas):
    """Canvas that defers page output until the total page count is known."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._saved_states: list[dict] = []

    def showPage(self) -> None:
        # Buffer this page instead of writing it out now.
        self._saved_states.append(dict(self.__dict__))
        self._startPage()

    def save(self) -> None:
        total = len(self._saved_states)
        for state in self._saved_states:
            self.__dict__.update(state)
            self._draw_page_number(total)
            super().showPage()
        super().save()

    def _draw_page_number(self, total: int) -> None:
        width, _height = A4
        self.saveState()
        self.setFont("Helvetica", 8)
        self.drawRightString(width - 20 * mm, 12 * mm, f"Page {self._pageNumber} of {total}")
        self.restoreState()

Build with it by passing the class, not an instance:

# pip install "reportlab>=4,<5"
doc.build(story, onFirstPage=draw_chrome, onLaterPages=draw_chrome, canvasmaker=NumberedCanvas)

Remove the drawRightString(... f"Page {canvas.getPageNumber()}") line from draw_chrome when you adopt this, or every page gets two numbers stacked on the same baseline. Memory is the trade-off: buffering holds one dictionary per page, which is negligible for a 50-page report and worth measuring beyond a few thousand pages.

Single pass against deferred canvas Two strategies side by side. In the single pass strategy each page is written to the file as soon as it is complete, so the footer can only print the current page number. In the deferred strategy each page is buffered in memory, and at save time the buffer length gives the total, so every page can be stamped Page X of Y before being written. Single pass Deferred canvas page 1 page 2 page 3 each written immediately footer can print "Page 2" but never "of 3" page 1 page 2 page 3 held in a buffer save() knows len(buffer) = 3 stamps "Page 2 of 3" Cost of the deferred route: one buffered state dictionary per page, released at save

Variant: Section-Aware Running Heads

For a report whose header should name the current section, record the section title as the story flows using a bookmark flowable, then read it in the callback.

# pip install "reportlab>=4,<5"
from reportlab.platypus import Paragraph
from reportlab.lib.styles import getSampleStyleSheet

class SectionHeading(Paragraph):
    """A heading that records itself so the page header can name it."""

    def __init__(self, text: str, style, tracker: dict):
        super().__init__(text, style)
        self._text = text
        self._tracker = tracker

    def draw(self):
        self._tracker["current"] = self._text   # last heading drawn on this page wins
        super().draw()

TRACKER = {"current": ""}

def draw_section_header(canvas, doc) -> None:
    canvas.saveState()
    canvas.setFont("Helvetica-Oblique", 8)
    canvas.drawString(72, doc.pagesize[1] - 40, TRACKER["current"] or "Introduction")
    canvas.restoreState()

Two details keep this honest. The tracker updates when the heading is drawn, not when it is added to the story, so it reflects the page the heading actually landed on. And the fallback string matters: page 1 fires the callback before any heading has been drawn.

Reserving space for the chrome A page outline with three zones. The top margin of twenty five millimetres reserves a band where the onPage callback draws the running header. The centre is the story frame that the flowables fill. The bottom margin of twenty millimetres reserves the footer band. A note records that leaving the margins at their defaults makes the header overlap the first paragraph, because the callback draws outside the frame. header band (onPage) story frame (flowables) footer: Page X of Y topMargin 25mm bottomMargin 20mm defaults are too small for chrome

Verification

Assert on the produced file rather than eyeballing it — page count, and the presence of the number string on the last page.

# pip install "pypdf>=4.2,<6"
from pathlib import Path
from pypdf import PdfReader

def assert_numbered(pdf_path: Path) -> None:
    """Every page must carry a 'Page X of Y' footer with a consistent Y."""
    reader = PdfReader(str(pdf_path))
    total = len(reader.pages)
    for index, page in enumerate(reader.pages, start=1):
        text = page.extract_text() or ""
        expected = f"Page {index} of {total}"
        assert expected in text, f"page {index}: footer {expected!r} not found"
    print(f"{total} page(s), all numbered consistently")

if __name__ == "__main__":
    assert_numbered(Path("out/report.pdf"))

Running that assertion in CI catches the classic regression where a template change pushes the footer off the printable area: the text disappears from extraction before a human notices it on screen. Pair it with a check that the header string appears on page 2 as well as page 1, which is where an onFirstPage-only wiring mistake shows up. When these reports are produced on a schedule, fold the assertion into the job described in scheduling and logging automation jobs so a broken build fails loudly instead of being emailed.

FAQ

Why is my header drawn on top of the first paragraph?onPage draws outside the story frame, so the frame must be shrunk to make room. Increase topMargin on SimpleDocTemplate until the gap is comfortable — the header baseline should sit inside the margin band, not inside the frame.

Can I use canvas.getPageNumber() in a flowable? No — flowables are laid out before page assignment is final. Anything that depends on the page number belongs in the onPage callback or in the deferred canvas.

Does the deferred canvas break bookmarks or links? Internal links survive because they are resolved by destination name, not by page order. Outline entries added with bookmarkPage also survive. What does break is anything that inspects the output file mid-build, since nothing is written until save().

How do I restart numbering per section? Track a base offset in the canvas subclass and subtract it: store the page index where each section starts, then print self._pageNumber - offset. Keep the "of Y" total as the section length, not the document length, or the two halves of the footer disagree.

Can I add a watermark the same way? Yes — draw it in the same callback before the story is painted, using a low-alpha fill. For stronger control over stacking and for existing documents, use the overlay approach in watermarking and securing PDFs instead.

Keeping the Chrome Consistent Across Reports

Once more than one report exists, the header and footer stop being a detail of a single script and become house style. The cheapest way to keep them consistent is to move the callback and the numbered canvas into a small shared module and import them, rather than copying the functions into each report.

That also gives one place to change the things that inevitably change: a confidentiality wording, a logo, the date format. A report set where four scripts each carry their own copy of the footer will drift within two quarters, and the drift is usually noticed by a recipient rather than by the team.

Keep the module free of report-specific content. Anything that varies per report — the title, the classification, whether the first page is a cover — belongs in arguments passed to the builder, not in branches inside the shared code.

Part of Generating PDF Reports Dynamically.