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.

Bash
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 (weasyprint or reportlab).
  • 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.

Loop Mapping Pipeline for Dynamic Attribute Tables Five stages shown as labeled boxes connected by arrows from left to right: Spatial Source (GeoPackage / GeoJSON / SHP), Extract and Normalize (required fields filter, snake_case keys, dropna), Jinja2 for-loop Template (for row in records, loop.index, default filter), Document Renderer (WeasyPrint HTML to PDF or ReportLab), and Paginated Table Output (row-count assertion, archival). Below the pipeline, a feedback arrow labelled Validation failure loops back from the output stage to the extract stage. Spatial Source GeoPackage GeoJSON / SHP Extract & Normalize required_fields filter snake_case keys dropna(how="all") Jinja2 for-loop {% for row in records %} loop.index / else-block | default("—", true) Document Renderer WeasyPrint HTML→PDF or ReportLab canvas chunked pages Table Output Row-count assert Paginated PDF Archived artifact Validation failure → re-extract with corrected field map Loop mapping pipeline — attribute records flow stage-by-stage, with a validation feedback path

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.

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

HTML
{# 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 when records is empty, preventing a table body with zero rows — which causes layout breakage in both WeasyPrint and most PDF renderers.
  • | default("—", true) handles both None and the empty-string case; the second argument true activates the filter for falsy values, not just undefined ones.
  • | round(4) applies only to numeric values (guarded by the is number test), preventing floating-point drift like 1.2300000000000002 appearing 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.

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

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

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

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

  1. Precompile the template once. Call env.get_template() outside any loop and pass the compiled object between calls. Each call to get_template re-reads and re-parses the file unless the environment is configured with a BytecodeCache.
  2. Use a BytecodeCache in long-running processes. jinja2.FileSystemBytecodeCache persists compiled bytecode to disk; subsequent runs skip the parse step entirely.
  3. 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.
  4. Parallel rendering of independent reports. Use concurrent.futures.ProcessPoolExecutor (not ThreadPoolExecutor) 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:

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

DOCKERFILE
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

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.