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

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.

Python
# 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.

Python
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.

Python
# 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.

Python
# 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:

Python
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 Table instead of LongTable. Table layout is computed in full before splitting; on tens of thousands of rows it exhausts memory. LongTable is a drop-in fix.
  • Wrapping every cell in a Paragraph. Per-cell Paragraph objects multiply memory and render time. Use plain strings and reserve Paragraph for 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 colWidths computed 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:

Python
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.


Parent: ReportLab Programmatic Layout