Using Jinja2 if-else Blocks to Hide Empty GIS Layers
Wrapping GIS layer components in Jinja2 conditional blocks is the primary presentation-layer defence against broken map frames, orphaned legend symbols, and pagination shifts in automated reports. This technique sits inside the Conditional Rendering for Missing Spatial Data workflow: after Python has evaluated feature counts and geometry validity, the Jinja2 template receives a pre-computed has_features boolean and conditionally renders either the full spatial component or a compact empty-state panel using if/else/endif block pairs. When applied consistently, the pattern eliminates silent rendering failures across multi-layer municipal dashboards, environmental assessments, and consulting deliverables.
Prerequisites
- Python 3.10+ with
geopandas>=0.14andjinja2>=3.1.0installed (pip install geopandas jinja2) - A spatial data source (Shapefile, GeoPackage, or GeoJSON) accessible to the reporting script
- Familiarity with Jinja2 Templating & Theme Logic macro scoping and the
autoescapesetting - Understanding of geometry validity — specifically that
notna()checks on a GeoDataFrame geometry column suppress null-geometry rows before they reach the template
Step 1: Validate and Filter Spatial Data in Python
Jinja2 is a string templating engine, not a spatial query processor. Geometry validity checks, feature counts, and attribute filtering belong entirely in Python. The template should receive only serialised, presentation-ready values.
# report_pipeline.py
from __future__ import annotations
import geopandas as gpd
from jinja2 import Environment, FileSystemLoader, select_autoescape
def build_layer_context(shapefile_path: str, zone_filter: str) -> dict:
"""Load, filter, validate, and serialise a spatial layer for Jinja2 rendering."""
gdf: gpd.GeoDataFrame = gpd.read_file(shapefile_path)
# Attribute filter — only rows matching the requested zone
filtered: gpd.GeoDataFrame = gdf[gdf["zone"] == zone_filter].copy()
# Drop null or invalid geometries before serialisation
filtered = filtered[filtered.geometry.notna() & filtered.geometry.is_valid]
features: list[dict] = (
filtered[["parcel_id", "risk_score", "last_updated"]]
.to_dict("records")
)
return {
"layer_name": f"{zone_filter.replace('_', ' ').title()} Parcels",
"layer_slug": zone_filter.lower().replace(" ", "-"),
"features": features, # always a list, never None
"has_features": len(features) > 0, # explicit boolean — no truthiness ambiguity
"feature_count": len(features),
}
Pass only the columns the template actually uses. Serialising the full GeoDataFrame into the context bloats the render payload and exposes geometry objects that Jinja2 cannot display safely.
Step 2: Render the Template with the Prepared Context
Instantiate the Jinja2 environment with select_autoescape enabled so HTML from attribute values is escaped automatically, then pass the context dict as keyword arguments.
# report_pipeline.py (continued)
def render_layer_report(shapefile_path: str, zone_filter: str) -> str:
context = build_layer_context(shapefile_path, zone_filter)
env = Environment(
loader=FileSystemLoader("templates"),
autoescape=select_autoescape(["html", "xml"]),
)
template = env.get_template("spatial_report.html")
return template.render(**context)
if __name__ == "__main__":
html = render_layer_report("data/parcels.shp", "flood_risk")
with open("output/flood_risk_report.html", "w", encoding="utf-8") as f:
f.write(html)
select_autoescape protects against XSS when parcel_id or risk_score values contain user-controlled strings — a real concern in automated reports ingesting third-party attribute data.
Step 3: Wrap the Map Container in an if-else Block
The if block gate should enclose the entire spatial component — heading, map frame, and attribute table — so that when the layer is empty, none of its DOM nodes appear in the output.
<!-- templates/spatial_report.html -->
<section class="spatial-layer" id="layer-{{ layer_slug }}">
{% if has_features %}
<h3>{{ layer_name }} — {{ feature_count }} feature{{ "s" if feature_count != 1 }}</h3>
<div class="map-frame"
data-layer-id="{{ layer_slug }}"
data-feature-count="{{ feature_count }}">
{# Map library (Leaflet, MapLibre) initialises here via data attributes #}
</div>
<table class="attribute-table">
<thead>
<tr>
<th scope="col">Parcel ID</th>
<th scope="col">Risk Score</th>
<th scope="col">Last Updated</th>
</tr>
</thead>
<tbody>
{% for f in features %}
<tr>
<td>{{ f.parcel_id }}</td>
<td>{{ f.risk_score }}</td>
<td>{{ f.last_updated }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<h3>{{ layer_name }}</h3>
<p>No features matched the current spatial or temporal filters.</p>
<span class="badge">0 records</span>
</div>
{% endif %}
</section>
The data-feature-count attribute lets JavaScript map libraries skip tile fetching and marker initialisation when the count is zero, reducing network overhead in browser-rendered reports.
Step 4: Synchronise the Legend to the Same Boolean
Legend sections that render independently of the map container are a common source of phantom symbols — a legend entry for a layer that produced zero features. Gate the legend block on has_features and build its entries from the same features list so they can never diverge.
<!-- Legend block in the same template or a shared partial -->
{% if has_features %}
<aside class="legend" aria-label="Layer legend for {{ layer_name }}">
<h4>Legend</h4>
<ul>
{% for f in features[:5] %} {# sample up to 5 representative entries #}
<li>
<span class="swatch" style="background:var(--color-risk-{{ f.risk_score | int }})"></span>
{{ f.parcel_id }}
</li>
{% endfor %}
{% if feature_count > 5 %}
<li class="legend-overflow">… and {{ feature_count - 5 }} more</li>
{% endif %}
</ul>
</aside>
{% endif %}
For Dynamic Legend Injection for Variable Datasets, where legend complexity scales with layer cardinality, the same pre-computed features list drives both the symbol count and the conditional gate.
Step 5: Extract the Pattern into a Reusable Macro
Repeating {% if has_features %} across dozens of layer sections creates maintenance debt. Define a Jinja2 macro that owns the conditional wrapper, and use the caller() mechanism to inject layer-specific table markup.
{# macros/spatial.html #}
{% macro render_spatial_layer(layer_name, layer_slug, features, feature_count) %}
{% set has_features = feature_count > 0 %}
<section class="spatial-layer" id="layer-{{ layer_slug }}">
{% if has_features %}
<h3>{{ layer_name }} — {{ feature_count }} feature{{ "s" if feature_count != 1 }}</h3>
<div class="map-frame" data-layer="{{ layer_slug }}"
data-feature-count="{{ feature_count }}"></div>
{{ caller() }}
{% else %}
<div class="empty-state">
<h3>{{ layer_name }}</h3>
<p>No spatial data available for this layer.</p>
<span class="badge">0 records</span>
</div>
{% endif %}
</section>
{% endmacro %}
Call it with {% call %} to pass the attribute table as the caller body:
{# Report template that imports the macro #}
{% from "macros/spatial.html" import render_spatial_layer %}
{% call render_spatial_layer(layer_name, layer_slug, features, feature_count) %}
<table class="attribute-table">
<thead>
<tr><th scope="col">Parcel ID</th><th scope="col">Risk Score</th></tr>
</thead>
<tbody>
{% for f in features %}
<tr><td>{{ f.parcel_id }}</td><td>{{ f.risk_score }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endcall %}
This pattern is especially valuable in Loop Mapping for Dynamic Attribute Tables, where the same macro is called inside a {% for %} loop over a variable set of layers.
Step 6: Verify the Output
Add an assertion in the Python pipeline to catch misconfigured context dicts before they reach the template:
# In your test suite or pipeline preflight
def assert_layer_context(ctx: dict) -> None:
assert isinstance(ctx["has_features"], bool), \
"has_features must be a bool, not a truthy object"
assert isinstance(ctx["features"], list), \
"features must be a list — never None or a GeoDataFrame"
assert ctx["feature_count"] == len(ctx["features"]), \
"feature_count must equal len(features)"
if ctx["has_features"]:
assert ctx["feature_count"] > 0
else:
assert ctx["feature_count"] == 0 and ctx["features"] == []
For rendered HTML, inspect the output with a simple string check:
html = render_layer_report("data/parcels.shp", "nonexistent_zone")
assert "empty-state" in html
assert "map-frame" not in html # map container must be absent for empty layers
For PDF outputs generated via WeasyPrint, visually verify that sections with zero features collapse to a single empty-state panel with no white gaps or orphaned headings between layers.
Key Parameters / Configuration Reference
| Parameter | Type | Default | Effect |
|---|---|---|---|
has_features |
bool |
— | Primary gate for all spatial component blocks; must be passed explicitly |
features |
list[dict] |
[] |
Serialised attribute rows; empty list collapses table and legend |
feature_count |
int |
0 |
Used in headings and data-feature-count; must equal len(features) |
layer_slug |
str |
— | URL-safe identifier for id attributes and data-layer-id hooks |
layer_name |
str |
— | Human-readable display name rendered in headings and empty-state panels |
Common Pitfalls
-
Passing
Noneinstead of[]for an empty layer.{% for f in features %}raisesTypeErrorwhenfeaturesisNone; always initialise to[]in Python. A| default([])filter in the template is a safety net, not a substitute for correct pipeline output. -
Testing
featurestruthiness in the template instead ofhas_features. An empty list is falsy in Python but Jinja2’s truthiness rules for Undefined differ — if the variable is ever missing from the context, the template silently skips the block without error. An explicit boolean avoids this ambiguity entirely. -
Letting the legend render independently of the map gate. When legend markup lives in a separate
{% include %}partial without its ownhas_featuresguard, a refactor can accidentally break the synchronisation. Keep both blocks behind the same flag, or pass both into the same macro. -
Forgetting
page-break-inside: avoidin PDF output. Empty-state panels are short. Without the CSS rule, a WeasyPrint or ReportLab layout can insert a page break between an empty-state heading and its message paragraph, producing a confusing half-page gap. Applypage-break-inside: avoidto.empty-statein your print stylesheet.
Related
- Conditional Rendering for Missing Spatial Data — parent workflow covering the full pipeline from null-geometry detection to template branching
- Fallback Content Strategies for Empty Map Layers — choosing between empty-state panels, placeholder tiles, and summary-only modes
- Loop Mapping for Dynamic Attribute Tables — iterating over variable-length feature sets and combining loops with conditional guards
- Dynamic Legend Injection for Variable Datasets — synchronising legend entries to the live feature set so symbols never outlive their data