Loop Mapping for Dynamic Attribute Tables
Compliance documents, environmental assessments, and municipal planning briefs all share a common bottleneck: attribute data behind spatial features must be presented clearly, consistently, and at scale — yet most GIS pipelines stop at static map exports. Without a programmatic binding layer, analysts resort to copying feature attributes into spreadsheets by hand, a process that introduces version drift, formatting errors, and audit gaps. Loop mapping for dynamic attribute tables closes this gap by iterating a Jinja2 for loop over a normalized list of feature records to produce a paginated, templated table that is regenerated directly from the source dataset every time the report runs. This workflow sits inside the broader Jinja2 Templating & Theme Logic system, where data extraction, template logic, and document rendering are kept strictly decoupled.
Prerequisites
Install dependencies before writing a single line of template code. Mismatched library versions are the most common source of silent rendering failures.
pip install geopandas>=0.14 jinja2>=3.1 weasyprint>=60 pandas>=2.1
Baseline requirements:
- Python 3.10+ with
geopandas,jinja2,pandas, and a document renderer (weasyprintorreportlab). - Spatial data in GeoPackage, GeoJSON, or Shapefile format with a consistent, documented attribute schema.
- A base HTML template containing table markup structured for Jinja2 iteration.
- Familiarity with Jinja2 control structures —
{% for %},{% if %}, filters, and context injection. The Variable Scoping in Nested Jinja Templates page covers namespace boundaries relevant to multi-table reports. - Data validation to handle null geometries and missing fields before any record reaches a template. See Conditional Rendering for Missing Spatial Data for template-side guard patterns.
Pipeline Architecture
The pipeline has five discrete stages. Each stage owns one concern and passes a clean output to the next, so that debugging, testing, and caching can be applied per-stage without touching adjacent code.
Step-by-Step Implementation
Step 1: Extract and Normalize Spatial Attributes
Query the spatial dataset and convert features to a plain list of dictionaries. Raw GeoDataFrame objects carry geometry objects and dtype metadata that the Jinja2 renderer cannot serialize. Isolate the tabular attributes first.
import geopandas as gpd
import pandas as pd
from pathlib import Path
def extract_attributes(
source_path: Path,
required_fields: list[str],
chunk_size: int | None = None,
) -> list[dict]:
"""Read a spatial file and return normalized attribute records for templating."""
gdf = gpd.read_file(source_path, columns=required_fields)
# Drop geometry — the template cannot serialize shapely objects
df = pd.DataFrame(gdf[required_fields])
# Normalize keys: lowercase, strip whitespace, replace spaces with underscores
df.columns = [c.strip().lower().replace(" ", "_") for c in df.columns]
# Coerce ambiguous object columns to numeric where possible
for col in df.select_dtypes(include=["object"]).columns:
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(df[col])
# Remove rows that are entirely empty (null geometry artefacts)
df = df.dropna(how="all")
if chunk_size:
# Return a list of page-sized sublists for chunked rendering
return [
df.iloc[i : i + chunk_size].to_dict(orient="records")
for i in range(0, len(df), chunk_size)
]
return df.to_dict(orient="records")
The columns=required_fields argument passed to gpd.read_file avoids loading all attribute columns into memory, which matters for GeoPackage files that may carry dozens of irrelevant geometry metadata fields alongside the reporting attributes.
For a detailed walkthrough of the attribute-iteration stage when the output renderer is ReportLab rather than WeasyPrint, see Iterating through Shapefile Attributes in ReportLab.
Step 2: Define the Jinja2 Table Template
Author the HTML table template using Jinja2 control structures. Keep all presentation logic — column ordering, row striping, fallback symbols — inside the template so the Python layer stays format-agnostic.
{# table_template.html #}
<table class="attribute-table">
<thead>
<tr>
{% for col in headers %}
<th scope="col">{{ col | replace("_", " ") | title }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in records %}
<tr class="{{ 'row-even' if loop.index is even else 'row-odd' }}"
data-row="{{ loop.index }}">
{% for col in headers %}
<td>{{ row[col] | default("—", true) | round(4) if row[col] is number
else row[col] | default("—", true) }}</td>
{% endfor %}
</tr>
{% else %}
<tr>
<td colspan="{{ headers | length }}" class="no-records">
No records returned for this query.
</td>
</tr>
{% endfor %}
</tbody>
</table>
Key template decisions:
{% else %}on the{% for %}block executes whenrecordsis empty, preventing a table body with zero rows — which causes layout breakage in both WeasyPrint and most PDF renderers.| default("—", true)handles bothNoneand the empty-string case; the second argumenttrueactivates the filter for falsy values, not just undefined ones.| round(4)applies only to numeric values (guarded by theis numbertest), preventing floating-point drift like1.2300000000000002appearing in measurement columns.data-row="{{ loop.index }}"provides a hook for CSS page-break rules (page-break-after: avoid) applied by CSS Grid Systems for Report Layouts.
Step 3: Implement the Mapping Engine
The mapping layer bridges normalized Python data and the compiled template. Configure jinja2.Environment once and reuse the compiled template object across all rendering calls to avoid repeated filesystem reads.
import jinja2
from pathlib import Path
def build_environment(template_dir: Path) -> jinja2.Environment:
"""Create a reusable Jinja2 Environment with strict undefined handling."""
return jinja2.Environment(
loader=jinja2.FileSystemLoader(template_dir),
autoescape=jinja2.select_autoescape(["html"]),
undefined=jinja2.StrictUndefined, # Fail fast on missing keys
keep_trailing_newline=True,
)
def render_attribute_table(
env: jinja2.Environment,
template_name: str,
headers: list[str],
records: list[dict],
) -> str:
"""Render one page of attribute records to an HTML string."""
template = env.get_template(template_name)
return template.render(headers=headers, records=records)
jinja2.StrictUndefined raises an UndefinedError at compile time when the template references a key that is absent from the context dictionary. This is essential in automated reporting pipelines where a silent empty cell would silently corrupt a compliance table. Switch to jinja2.Undefined only in interactive preview contexts where partial renders are acceptable.
When reports contain multiple table sections — grouped by administrative region or project phase — namespace leakage between loop iterations becomes a real risk. The Variable Scoping in Nested Jinja Templates page explains how {% with %} blocks and scoped {% include %} directives prevent parent-context values from bleeding into nested loops.
Step 4: Render and Validate the Document
Pass the rendered HTML string to WeasyPrint (or your chosen renderer) and validate the output before archiving or distributing.
import logging
from pathlib import Path
from weasyprint import HTML
def export_to_pdf(
html_string: str,
output_path: Path,
expected_row_count: int,
css_path: Path | None = None,
) -> None:
"""Render an HTML string to PDF and assert the output is non-empty."""
logger = logging.getLogger(__name__)
logger.info("Rendering PDF: %s", output_path)
stylesheets = []
if css_path and css_path.exists():
from weasyprint import CSS
stylesheets.append(CSS(filename=str(css_path)))
HTML(string=html_string).write_pdf(output_path, stylesheets=stylesheets)
if not output_path.exists() or output_path.stat().st_size < 1024:
raise RuntimeError(
f"PDF generation failed or produced a suspiciously small file at {output_path}"
)
logger.info("Exported %d rows → %s (%.1f KB)",
expected_row_count, output_path, output_path.stat().st_size / 1024)
A st_size < 1024 guard catches WeasyPrint producing a minimal “error page” PDF (typically around 500 bytes) rather than a genuine document — a failure mode the library does not always surface as an exception.
Production-Ready Script
This complete script ties all four stages together with logging, configurable parameters, and error handling suitable for a headless CI/CD environment.
#!/usr/bin/env python3
"""
render_attribute_table.py — generate a paginated PDF attribute table from
a GeoPackage or GeoJSON layer using Jinja2 for-loop mapping.
Usage:
python render_attribute_table.py \
--source data/parcels.gpkg \
--fields parcel_id,area_ha,zone_code,owner_name \
--template templates/table_template.html \
--output reports/parcels_table.pdf \
--chunk 500
"""
import argparse
import logging
import sys
from pathlib import Path
import geopandas as gpd
import jinja2
import pandas as pd
from weasyprint import CSS, HTML
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
stream=sys.stderr,
)
logger = logging.getLogger(__name__)
def extract_attributes(
source_path: Path,
required_fields: list[str],
) -> list[dict]:
gdf = gpd.read_file(source_path, columns=required_fields)
df = pd.DataFrame(gdf[required_fields])
df.columns = [c.strip().lower().replace(" ", "_") for c in df.columns]
for col in df.select_dtypes(include=["object"]).columns:
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(df[col])
return df.dropna(how="all").to_dict(orient="records")
def build_environment(template_dir: Path) -> jinja2.Environment:
return jinja2.Environment(
loader=jinja2.FileSystemLoader(template_dir),
autoescape=jinja2.select_autoescape(["html"]),
undefined=jinja2.StrictUndefined,
keep_trailing_newline=True,
)
def render_pages(
env: jinja2.Environment,
template_name: str,
headers: list[str],
records: list[dict],
chunk_size: int,
) -> str:
"""Render all record chunks into a single HTML document."""
template = env.get_template(template_name)
chunks = [
records[i : i + chunk_size] for i in range(0, len(records), chunk_size)
]
pages = [template.render(headers=headers, records=chunk) for chunk in chunks]
# Wrap in a minimal HTML shell for WeasyPrint
body = '\n<div class="page-break"></div>\n'.join(pages)
return f"<!DOCTYPE html><html lang='en'><body>{body}</body></html>"
def export_to_pdf(html_string: str, output_path: Path, expected_rows: int) -> None:
HTML(string=html_string).write_pdf(output_path)
if not output_path.exists() or output_path.stat().st_size < 1024:
raise RuntimeError(f"PDF export failed at {output_path}")
logger.info("Exported %d rows → %s (%.1f KB)",
expected_rows, output_path, output_path.stat().st_size / 1024)
def main() -> None:
parser = argparse.ArgumentParser(description="Render attribute table PDF from spatial data")
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--fields", required=True, help="Comma-separated field names")
parser.add_argument("--template", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--chunk", type=int, default=500, help="Rows per page")
args = parser.parse_args()
fields = [f.strip() for f in args.fields.split(",")]
headers = [f.strip().lower().replace(" ", "_") for f in fields]
logger.info("Reading %s, fields: %s", args.source, fields)
records = extract_attributes(args.source, fields)
logger.info("Extracted %d records", len(records))
env = build_environment(args.template.parent)
html = render_pages(env, args.template.name, headers, records, args.chunk)
args.output.parent.mkdir(parents=True, exist_ok=True)
export_to_pdf(html, args.output, len(records))
if __name__ == "__main__":
main()
Edge Cases & Advanced Configuration
Handling Null Geometries and Missing Fields
Spatial datasets regularly contain features with null or invalid geometry that still carry valid attribute data. Dropping these rows entirely can violate regulatory requirements that mandate all parcels or assessment units appear in the output, even if their geometry could not be validated. Instead, preserve the row and inject a visual indicator.
At the template level, Conditional Rendering for Missing Spatial Data demonstrates how to inject fallback badges and data-quality footnotes without conditionally removing rows. At the Python level, test for None geometry before calling dropna:
# Preserve rows with null geometry — only drop geometry column, not the row
gdf["geometry_valid"] = gdf.geometry.notna() & gdf.geometry.is_valid
df = pd.DataFrame(gdf[required_fields + ["geometry_valid"]])
Pass geometry_valid into the template context so a {% if not row.geometry_valid %} guard can apply a data-quality="unverified" CSS class without removing the row from the audit trail.
Performance Optimization for Large Datasets
Loop mapping scales linearly with record count, but PDF rendering — especially with CSS @page rules — can become a severe bottleneck past 5,000 rows:
- Precompile the template once. Call
env.get_template()outside any loop and pass the compiled object between calls. Each call toget_templatere-reads and re-parses the file unless the environment is configured with aBytecodeCache. - Use a
BytecodeCachein long-running processes.jinja2.FileSystemBytecodeCachepersists compiled bytecode to disk; subsequent runs skip the parse step entirely. - Chunk before rendering, not after. Chunking the record list in Python and rendering one HTML page per chunk is faster than rendering a 10,000-row table and relying on WeasyPrint’s automatic page-break algorithm, which must traverse the entire DOM.
- Parallel rendering of independent reports. Use
concurrent.futures.ProcessPoolExecutor(notThreadPoolExecutor) for WeasyPrint — WeasyPrint releases the GIL during PDF generation, but its CSS engine is CPU-bound.
Multi-Format Output
Some reporting environments require both a print-ready PDF and a machine-readable CSV. Render both from the same normalized record list:
import csv
def export_csv(records: list[dict], headers: list[str], output_path: Path) -> None:
with output_path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=headers, extrasaction="ignore")
writer.writeheader()
writer.writerows(records)
This avoids a second pass through the spatial file and guarantees the CSV and PDF reflect exactly the same filtered, normalized dataset.
Headless Environments
WeasyPrint requires a functioning Cairo graphics library. In Docker-based CI/CD pipelines, install system dependencies explicitly:
RUN apt-get update && apt-get install -y \
libpango-1.0-0 \
libcairo2 \
libgdk-pixbuf2.0-0 \
libffi-dev \
&& rm -rf /var/lib/apt/lists/*
For table pagination strategies that complement the @page CSS rules controlling multi-page outputs, see Table Pagination Strategies for Large Attribute Tables.
Troubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
UndefinedError: 'row' is undefined |
Context key name mismatch between Python and template | Enable StrictUndefined in the Environment; print records[0].keys() and compare against template variable names |
| Table renders with misaligned columns | headers list order does not match dict key order |
Build headers from df.columns.tolist() after normalization so both are derived from the same source |
| PDF is generated but empty (< 2 KB) | WeasyPrint silently wrote an error page due to invalid HTML | Validate the HTML string with lxml.html.fromstring() before passing to write_pdf |
Numeric values show as 1.2300000000000002 |
Floating-point drift in float64 columns |
Apply | round(4) in the template for numeric cells, or cast to Decimal upstream before serialization |
| Memory error on datasets > 50,000 rows | Full GeoDataFrame loaded before column filtering |
Pass columns=required_fields directly to gpd.read_file to skip unused columns at the GDAL read stage |
| Chunked pages have inconsistent column widths | Each chunk rendered as a separate HTML document with its own table width calculation | Render all chunks into one <body> with CSS page-break-after rules rather than separate HTML documents |
Detailed Guides in This Section
- Iterating through Shapefile Attributes in ReportLab — step-by-step walkthrough of parsing
.shpattribute rows directly into a ReportLabTableobject, bypassing the HTML rendering stage entirely for high-fidelity PDF control.
Related
- Conditional Rendering for Missing Spatial Data — template-side
{% if %}patterns for injecting fallback content when feature attributes are null or unverified. - Fallback Content Strategies for Empty Map Layers — handling the case where a spatial query returns zero features and the table section must be replaced entirely.
- Variable Scoping in Nested Jinja Templates — namespace isolation techniques for multi-table and multi-region reports where loop context must not leak between sections.
- Table Pagination Strategies for Large Attribute Tables — CSS
@pagerules and Python chunking strategies for splitting large attribute tables across printed pages. - CSS Grid Systems for Report Layouts — layout rules for positioning attribute tables alongside map insets and chart panels in multi-column print documents.
Up: Jinja2 Templating & Theme Logic
Loop mapping converts a one-off copy-paste task into a repeatable, version-controlled pipeline step where every attribute table is regenerated from source data at report runtime. Combined with the broader Jinja2 Templating & Theme Logic system — covering conditional guards, fallback strategies, and variable scoping — this pattern forms the reliable backbone for compliance reporting, environmental assessments, and municipal planning automation at any scale.