Conditional Rendering for Missing Spatial Data

Field surveys return partial geometries. API feeds drop attributes mid-stream. Legacy shapefiles arrive with columns that exist in the schema but hold nothing except None. Without a structured approach to these gaps, automated report pipelines either crash on UndefinedError, produce maps with blank extents, or silently emit tables of empty cells — all of which invalidate the document before a reviewer reads line one. This guide covers the complete workflow for detecting absences at every level of a spatial dataset, converting those absences into explicit boolean signals, and using those signals in Jinja2 Templating & Theme Logic to route each report section to a safe fallback, a data-quality warning, or a full active render — without human intervention at publication time.

Prerequisites

  • Python 3.10+ with geopandas>=0.14, shapely>=2.0, and jinja2>=3.1.0
Bash
pip install geopandas>=0.14 shapely>=2.0 jinja2>=3.1.0
  • Spatial input formats: GeoJSON, GeoPackage, or Shapefile with a defined CRS (EPSG:4326 or projected). Inputs lacking a CRS definition should be assigned or rejected before entering the pipeline.
  • Template architecture: A modular structure that separates data preprocessing, context assembly, and Jinja2 template rendering into distinct Python modules. Mixing preprocessing logic inside template files is the most common source of conditional rendering failures.
  • Prior knowledge: Familiarity with geopandas.GeoDataFrame, the Document Architecture & Layout Rules for Spatial Reports layer model, and Jinja2 control-flow syntax (if, elif, else, is defined, default, macro scoping).

Pipeline Architecture

The end-to-end data flow routes each spatial dataset through a sequence of gates before any HTML or PDF output is emitted.

Conditional Rendering Decision Pipeline Five-stage pipeline. Stage 1: Raw spatial input (GeoJSON/GPKG/SHP). Stage 2: Sanitize — null invalid geometries, normalize NaN attributes. Stage 3: is_empty gate — yes routes down to Fallback block; no continues right. Stage 4: valid_geometries gate — no routes down to Warning (table only, map disabled); yes continues right. Stage 5: Active render — map plus attribute table. Input Sanitize is_empty? valid_geometries? Render Raw input GeoJSON / GPKG Sanitize null invalid geoms is_empty? feature_count == 0 yes Fallback block "Data Unavailable" no valid_geometries? topology check no Warning — table only map render disabled yes Active render map + attribute table

Step-by-Step Implementation

1. Data Ingestion and Geometric Sanitization

Load spatial datasets and immediately convert null geometries, missing attributes, and empty feature collections into predictable sentinel values. Perform vectorized validation rather than row-by-row iteration to keep preprocessing fast when feature counts reach the tens of thousands.

Python
import geopandas as gpd
import numpy as np
from shapely import make_valid


def sanitize_spatial_context(gdf: gpd.GeoDataFrame) -> dict:
    """
    Validate and normalize a GeoDataFrame into a Jinja2-safe context dict.
    Returns explicit boolean flags — never raw NaN or malformed WKT.
    """
    is_empty = gdf is None or gdf.empty

    if is_empty:
        return {
            "features": [],
            "feature_count": 0,
            "has_valid_geometries": False,
            "bbox": None,
            "is_empty": True,
        }

    # Attempt topology repair with shapely 2.x make_valid, then re-check
    gdf = gdf.copy()
    gdf["geometry"] = gdf["geometry"].apply(
        lambda g: make_valid(g) if g is not None and not g.is_valid else g
    )
    valid_mask = gdf.geometry.notna() & gdf.geometry.is_valid
    gdf.loc[~valid_mask, "geometry"] = None
    has_valid = bool(valid_mask.any())

    # Normalize missing numeric attributes to None (not NaN — JSON-safe)
    num_cols = gdf.select_dtypes(include=[np.number]).columns
    gdf[num_cols] = gdf[num_cols].where(gdf[num_cols].notna(), other=None)

    return {
        "features": gdf.to_dict(orient="records"),
        "feature_count": len(gdf),
        "has_valid_geometries": has_valid,
        "bbox": gdf.total_bounds.tolist() if has_valid else None,
        "is_empty": False,
    }

The make_valid call from shapely 2.x repairs self-intersections and ring-order errors before validation, reducing the proportion of features discarded at the topology gate.

2. Context Assembly and Boolean Flagging

Build the full template context dictionary with explicit boolean flags. Jinja2’s truthiness evaluation treats empty lists and the integer 0 as falsy, so a feature_count variable alone is insufficient — a separate is_empty flag removes ambiguity. Include ingestion metadata to support audit trails and report footers.

Python
from datetime import datetime, timezone
import geopandas as gpd

raw_gdf: gpd.GeoDataFrame = gpd.read_file("watershed_q2.gpkg")

context = {
    "report_title": "Quarterly Watershed Assessment",
    "spatial_data": sanitize_spatial_context(raw_gdf),
    "metadata": {
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "source_crs": "EPSG:4326",
        "source_file": "watershed_q2.gpkg",
        "processing_version": "2.4.1",
    },
}

Avoid passing gdf or any live GeoPandas object directly into the context. Template engines cannot serialize them and any attribute access inside a template will raise an AttributeError rather than a clean conditional branch.

3. Jinja2 Conditional Macros

Write conditional blocks that evaluate the precomputed flags and route to the correct rendering branch. Encapsulate repeated logic in macros to keep individual template files concise. This pattern is particularly important when Loop Mapping for Dynamic Attribute Tables generates column headers from the same dataset — both sections must agree on whether the data is present.

Jinja
{# macros/spatial.html #}

{% macro render_spatial_summary(data) %}
  {% if data.is_empty %}
    <div class="spatial-fallback" role="status" aria-live="polite">
      <h3>Spatial Data Unavailable</h3>
      <p>No features were returned for this region during the reporting window.
         Historical baselines are referenced in the statistical appendix instead.</p>
    </div>

  {% elif not data.has_valid_geometries %}
    <div class="spatial-warning" role="alert">
      <h3>Geometry Validation Failed</h3>
      <p>{{ data.feature_count }} feature(s) were ingested but failed topological
         checks after repair. Tabular attributes are shown below; map rendering is
         disabled for this section.</p>
      {{ render_attribute_table(data.features) }}
    </div>

  {% else %}
    <div class="spatial-active">
      <h3>Active Spatial Layer</h3>
      <p>{{ data.feature_count }} valid feature(s) loaded.
         Extent: {{ data.bbox | map('round', 4) | join(', ') }}</p>
      {% include "components/map_container.html" %}
      {{ render_attribute_table(data.features) }}
    </div>
  {% endif %}
{% endmacro %}

For detailed layer-specific toggling — including legend synchronization when individual layers go missing — see Using Jinja2 if-else Blocks to Hide Empty GIS Layers.

4. Fallback Injection and Layout Preservation

Define placeholder blocks for each missing component. Fallbacks must maintain the document’s typographic hierarchy and grid dimensions. When a map is omitted, replace it with a fixed-height placeholder rather than collapsing the container — collapsing breaks CSS grid flow in WeasyPrint and shifts surrounding elements unpredictably. When generating PDF output via the Document Architecture & Layout Rules for Spatial Reports pipeline, placeholder containers need explicit min-height to prevent page-break orphans.

Jinja
{% if spatial_data.feature_count > 0 %}
  {% include "components/map_container.html" %}
{% else %}
  <div class="map-placeholder" aria-label="Map not available">
    <p class="placeholder-label">Map visualization omitted — spatial data unavailable.</p>
  </div>
{% endif %}
CSS
/* report.css — keeps PDF pagination stable when map is absent */
.map-placeholder {
  min-height: 280px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px dashed currentColor;
  opacity: 0.4;
}

Empty attribute columns should be stripped before the context is built rather than rendered as blank cells. Blank cells confuse PDF pagination and break role="grid" accessibility semantics.

5. Production-Ready Script with Logging and CI Integration

Python
"""
render_spatial_report.py — production entry point
Runs geometry sanitization, context assembly, Jinja2 rendering,
and structural assertion checks suitable for CI pipelines.
"""
import logging
import sys
from datetime import datetime, timezone
from pathlib import Path

import geopandas as gpd
from jinja2 import Environment, FileSystemLoader, StrictUndefined

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger(__name__)

TEMPLATES_DIR = Path("templates")
OUTPUT_FILE = Path("output/spatial_report.html")
SOURCE_FILE = Path("data/watershed_q2.gpkg")


def build_context(source: Path) -> dict:
    try:
        gdf = gpd.read_file(source)
        log.info("Loaded %d features from %s", len(gdf), source)
    except Exception as exc:
        log.warning("Failed to load %s: %s — using empty context", source, exc)
        gdf = gpd.GeoDataFrame()

    spatial = sanitize_spatial_context(gdf)
    log.info(
        "Sanitized: is_empty=%s, has_valid_geometries=%s, feature_count=%d",
        spatial["is_empty"],
        spatial["has_valid_geometries"],
        spatial["feature_count"],
    )
    return {
        "report_title": "Quarterly Watershed Assessment",
        "spatial_data": spatial,
        "metadata": {
            "generated_at": datetime.now(timezone.utc).isoformat(),
            "source_crs": "EPSG:4326",
            "source_file": str(source),
            "processing_version": "2.4.1",
        },
    }


def render(context: dict, output: Path) -> str:
    env = Environment(
        loader=FileSystemLoader(str(TEMPLATES_DIR)),
        undefined=StrictUndefined,   # surface all missing keys immediately
        trim_blocks=True,
        lstrip_blocks=True,
    )
    template = env.get_template("spatial_report.html")
    html = template.render(context)
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(html, encoding="utf-8")
    log.info("Rendered %d bytes → %s", len(html), output)
    return html


def validate(html: str) -> None:
    """Structural assertions that gate CI deployment."""
    required_classes = {"spatial-fallback", "spatial-warning", "spatial-active"}
    found = any(cls in html for cls in required_classes)
    assert found, (
        "No recognized rendering branch found in output — "
        "conditional logic failed to execute."
    )
    assert "None" not in html, (
        "Raw Python None leaked into rendered output — "
        "check context preprocessing for unhandled null values."
    )
    log.info("Structural validation passed.")


if __name__ == "__main__":
    ctx = build_context(SOURCE_FILE)
    rendered = render(ctx, OUTPUT_FILE)
    validate(rendered)
    sys.exit(0)

Run python render_spatial_report.py in CI before any PDF conversion step. The StrictUndefined environment ensures that a missing context key fails the build immediately rather than silently rendering as an empty string.

Edge Cases and Advanced Configuration

Partial Topologies and CRS Mismatches

Spatial data often arrives with mixed coordinate reference systems or fragmented polygons that pass is_valid after make_valid but fail downstream rendering engines at specific zoom extents. If a GeoDataFrame carries no CRS or a non-standard SRID, inject a data-quality warning rather than attempting silent reprojection — silent reprojection can distort scale-dependent symbology by hundreds of meters.

Python
def check_crs(gdf: gpd.GeoDataFrame, expected_epsg: int = 4326) -> dict:
    if gdf.crs is None:
        return {"crs_ok": False, "crs_warning": "No CRS defined on input dataset."}
    if gdf.crs.to_epsg() != expected_epsg:
        return {
            "crs_ok": False,
            "crs_warning": (
                f"CRS mismatch: expected EPSG:{expected_epsg}, "
                f"got {gdf.crs.to_string()}. Reprojection skipped."
            ),
        }
    return {"crs_ok": True, "crs_warning": None}

Merge the result into the top-level context and check crs_ok in the template before rendering any map component.

Variable Scoping in Nested Report Sections

Complex reports nest spatial summaries inside regional overviews, client dashboards, or multi-year comparisons. Variable collisions are common when macros inherit parent context implicitly. To maintain deterministic rendering, pass only the required subset of data into nested macros using keyword arguments, and use Jinja2’s namespace object when a loop must write back to an outer scope. For the full breakdown of scope isolation, see Variable Scoping in Nested Jinja Templates.

Jinja
{# Pass only what the nested macro needs — never the whole context #}
{{ render_spatial_summary(data=spatial_data) }}

Performance in High-Volume Batch Pipelines

Conditional rendering adds negligible overhead when boolean flags are precomputed, but becomes expensive if templates perform geometry calculations at render time. Push all spatial validation and data shaping to the Python preprocessing layer. Use Jinja2’s BytecodeCache for templates that do not change between report runs, and leverage the batch filter when paginating large feature sets rather than slicing Python lists before context assembly.

Python
from jinja2 import FileSystemBytecodeCache

cache = FileSystemBytecodeCache("/tmp/jinja_cache")
env = Environment(
    loader=FileSystemLoader("templates"),
    bytecode_cache=cache,
    undefined=StrictUndefined,
)

Headless and Docker Environments

When rendering in a headless Docker container, geopandas requires GDAL system libraries. Missing GDAL causes fiona or pyogrio import errors that surface as a completely empty spatial context if exceptions are swallowed. Add an explicit health check at container startup:

Python
import importlib, sys

for lib in ("geopandas", "shapely", "jinja2"):
    if importlib.util.find_spec(lib) is None:
        sys.exit(f"Required library '{lib}' not found in container environment.")

Troubleshooting

Symptom Likely Cause Resolution
UndefinedError: 'spatial_data' is undefined Key missing from context dict Add spatial_data to the build_context return value; use StrictUndefined in dev
Map container renders but shows blank extent bbox is None despite has_valid_geometries=True total_bounds returned all-NaN — re-check that make_valid repaired geometries before computing bounds
PDF page height collapses where map was omitted Fallback div has no min-height Add min-height: 280px (or your map height) to .map-placeholder in the report stylesheet
Raw None string appears in output Python None passed directly into a string-interpolated template variable Use `
Attribute table renders with blank columns NaN not converted before to_dict Apply df[num_cols].where(df[num_cols].notna(), other=None) in sanitize_spatial_context
is_valid returns True but map tiles fail Geometry passed shapely check but CRS is missing or wrong Add check_crs step and gate map rendering on crs_ok flag

Detailed Guides in This Section


Conditional rendering transforms unpredictable spatial feeds into deterministic, publication-ready documents. Integrated into a CI-validated rendering pipeline, the boolean-flag pattern ensures every generated report meets editorial and layout standards regardless of upstream data volatility.