Flowing Large Attribute Tables with ReportLab Platypus
A parcel register, a species observation log, or a pipeline segment inventory can carry tens of thousands of rows — far more than fits on one page and, handled naively, more than fits in memory. ReportLab’s LongTable flowable is built for exactly this: it lays out incrementally, splits cleanly across pages, and repeats the header on every page. This technique extends ReportLab Programmatic Layout, filling the body frame from Building a ReportLab PageTemplate for Map Frames with an attribute table that stays fast and memory-bounded no matter how many features the dataset holds.
Prerequisites
- Python 3.10+ with
reportlab>=4.0 - Attribute rows already serialised to plain Python types — strings and numbers, not GeoDataFrame cells
- A working
BaseDocTemplatewith a bodyFrame, as built in the map-frame PageTemplate guide - Familiarity with the broader pagination trade-offs in Table Pagination Strategies for Large Attribute Tables
Step 1: Choose LongTable over Table
The ordinary Table flowable computes the entire grid layout up front and keeps every cell in memory — acceptable for a summary block, fatal for 40,000 rows. LongTable shares the same API but lays out lazily as it splits, so peak memory tracks a page, not the whole dataset. Swapping one class name is the single most important change for large tables.
# table_builder.py
from __future__ import annotations
from reportlab.platypus import LongTable
from reportlab.lib.units import mm
COLUMNS = ["Parcel ID", "Zone", "Area (ha)", "Risk", "Surveyed"]
COL_WIDTHS = [32 * mm, 26 * mm, 22 * mm, 18 * mm, 28 * mm]
def build_long_table(rows: list[list[str]]) -> LongTable:
data = [COLUMNS] + rows
return LongTable(data, colWidths=COL_WIDTHS, repeatRows=1)
Step 2: Repeat the Header with repeatRows
repeatRows=1 tells LongTable to reprint the first data row — the header — at the top of every page after a split. Pass an integer for multi-row headers (for example, a group label above column names). Without it, only page one carries column titles and every later page is an unlabelled wall of numbers.
table = LongTable(data, colWidths=COL_WIDTHS, repeatRows=1)
# For a two-line header (super-header + column names):
# table = LongTable(data, colWidths=COL_WIDTHS, repeatRows=2)
The repeated rows are drawn from the top of the data list, so the header must be the first element(s) of data — which is why build_long_table prepends COLUMNS.
Step 3: Apply a TableStyle for Banding and Grid
TableStyle commands address cell ranges by (col, row) coordinates, with -1 meaning “last”. Row banding aids legibility in dense attribute data; keep the font small (7–8pt) so wide GIS tables fit the column widths.
# styling.py
from reportlab.platypus import TableStyle
from reportlab.lib import colors
def attribute_style(row_count: int) -> TableStyle:
cmds = [
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e8e8e8")), # header
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 7.5),
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#bbbbbb")),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ALIGN", (2, 1), (2, -1), "RIGHT"), # area column
]
# Zebra banding on data rows
for r in range(1, row_count + 1):
if r % 2 == 0:
cmds.append(("BACKGROUND", (0, r), (-1, r), colors.HexColor("#f6f6f6")))
return TableStyle(cmds)
Building banding as explicit commands (rather than ROWBACKGROUNDS) gives you per-row control — useful when you want to tint rows by a risk threshold rather than by parity.
Step 4: Prevent Mid-Row Splits and Control Memory
Two problems dominate at scale: a row splitting across a page boundary, and memory blowing up. Fixed rowHeights keep LongTable from splitting an individual row’s content across pages, and using plain strings instead of a Paragraph in every cell is the biggest memory win — per-cell Paragraph objects are the usual cause of a table build ballooning past a gigabyte.
# assemble.py
from reportlab.platypus import LongTable
from reportlab.lib.units import mm
from styling import attribute_style
from table_builder import COLUMNS, COL_WIDTHS
def large_attribute_table(rows: list[list[str]]) -> LongTable:
data = [COLUMNS] + rows
table = LongTable(
data,
colWidths=COL_WIDTHS,
repeatRows=1,
rowHeights=[6 * mm] * len(data), # fixed height → no intra-row page split
splitByRow=1, # split between rows, never inside one
)
table.setStyle(attribute_style(len(rows)))
return table
When a single cell genuinely needs wrapping (a long place name), wrap only that column in a Paragraph, not the whole table — the general pagination discipline is covered in Preventing Table Row Splits Across PDF Page Breaks. For genuinely massive datasets, chunk the rows and emit several LongTables so no single flowable holds the entire grid:
def chunked_tables(rows: list[list[str]], chunk: int = 2000) -> list[LongTable]:
return [large_attribute_table(rows[i:i + chunk]) for i in range(0, len(rows), chunk)]
Key Parameters / Configuration Reference
| Parameter | Type | Default | Effect |
|---|---|---|---|
repeatRows |
int |
0 |
Number of leading rows reprinted atop each page after a split |
colWidths |
list[float] |
auto | Fixed column widths in points; avoids costly auto-measurement |
rowHeights |
list[float] |
auto | Fixed row heights; prevents a row’s content splitting across pages |
splitByRow |
int |
1 |
When 1, splits occur only between rows, never inside a cell |
spaceBefore / spaceAfter |
float |
0 |
Vertical gap around the table flowable in points |
Common Pitfalls
- Using
Tableinstead ofLongTable.Tablelayout is computed in full before splitting; on tens of thousands of rows it exhausts memory.LongTableis a drop-in fix. - Wrapping every cell in a
Paragraph. Per-cellParagraphobjects multiply memory and render time. Use plain strings and reserveParagraphfor the rare column that must wrap. - Omitting
repeatRows. Later pages lose the header, leaving columns unlabelled — a frequent review rejection for attribute appendices. - Auto column widths on huge tables. Letting ReportLab measure widths across all rows is slow; pass explicit
colWidthscomputed once from a sample.
Verification
Render a large synthetic table and assert both that it paginates and that the header repeats, checking the second page’s text with pypdf:
from pypdf import PdfReader
from reportlab.platypus import SimpleDocTemplate
from reportlab.lib.pagesizes import A4
from assemble import large_attribute_table
rows = [[f"P-{i}", "Flood", f"{i * 0.5:.1f}", "High", "2025-06-01"] for i in range(5000)]
SimpleDocTemplate("out/big_table.pdf", pagesize=A4).build([large_attribute_table(rows)])
reader = PdfReader("out/big_table.pdf")
assert len(reader.pages) > 10, "expected many pages for 5000 rows"
assert "Parcel ID" in reader.pages[2].extract_text(), "header must repeat on later pages"
print(f"Paginated {len(rows)} rows over {len(reader.pages)} pages")
Profile memory with tracemalloc around the build call to confirm peak usage tracks a page rather than the full dataset.
Related
- Building a ReportLab PageTemplate for Map Frames — sibling guide providing the body frame these tables flow into
- ReportLab Programmatic Layout — the parent pipeline and canvas-vs-flowables context
- Table Pagination Strategies for Large Attribute Tables — engine-agnostic pagination patterns for dense attribute data
Parent: ReportLab Programmatic Layout