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, andjinja2>=3.1.0
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.
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.
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.
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.
{# 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.
{% 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 %}
/* 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
"""
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.
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.
{# 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.
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:
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
- Using Jinja2 if-else Blocks to Hide Empty GIS Layers — layer-specific toggling with legend synchronization when individual GIS layers go missing mid-report.
Related
- Fallback Content Strategies for Empty Map Layers — placeholder design patterns and copy standards for the fallback blocks this workflow produces.
- Loop Mapping for Dynamic Attribute Tables — coordinate conditional presence checks with row and column iterators so table headers only render when data exists.
- Variable Scoping in Nested Jinja Templates —
namespaceobjects,withblocks, and context freezing strategies for nested regional report sections. - Dynamic Legend Injection for Variable Datasets — adapt legend entries and colour ramps when the underlying feature set is incomplete or empty.
- Jinja2 Templating & Theme Logic ↑ parent 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.