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.
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.
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.
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.
Related
- Generating PDF Reports Dynamically — the report-building workflow this extends
- Create Dynamic Invoice PDFs Automatically — numbered multi-page invoices
- Fix ReportLab Unicode Font Errors — when header text renders as black squares
- Watermarking and Securing PDFs — stamping finished documents
- Generating Reports from Pipeline Data — feeding real data into these templates
Part of Generating PDF Reports Dynamically.