Fallback Content Strategies for Empty Map Layers
Automated spatial reporting pipelines regularly encounter map layers that return zero features. This condition rarely signals a system failure — it stems from overzealous spatial query filters, temporal data gaps, permission restrictions, or upstream ETL interruptions. When a rendering engine tries to draw an empty geometry collection the result is a blank canvas, collapsed layout containers, or silent template exceptions that degrade report credibility and break downstream publishing workflows. Structured fallback handling transforms those gaps into transparent, correctly sized placeholders that preserve document integrity and maintain batch-generation velocity.
Within the broader framework of Jinja2 Templating & Theme Logic, fallback handling operates as a deterministic routing mechanism — not a cosmetic patch. It evaluates spatial context before rendering, substitutes appropriate content, and maintains CSS grid stability across viewport sizes and export formats.
Prerequisites
Verify that your environment meets these baseline requirements before integrating fallback routing:
pip install jinja2>=3.1.0 geopandas>=0.13.0 shapely>=2.0.0
- Python 3.10+ with the packages above installed
- A structured template directory with a base layout, reusable component macros, and a Jinja2
Environmentconfigured withautoescape=Trueandtrim_blocks=True - A spatial validation step that evaluates feature counts, geometry validity, and bounding-box extents before template injection — this is the prerequisite that the Conditional Rendering for Missing Spatial Data patterns build on
- Working knowledge of Jinja2 truthiness evaluation, the
|defaultfilter, andis definedtests - CSS layout rules that enforce minimum container dimensions and use flexbox or CSS grid fallback behaviors to prevent visual collapse
Establishing these prerequisites prevents TypeError and AttributeError exceptions during batch execution.
Pipeline Architecture
The validation-and-routing pipeline for empty map layers has two distinct concerns: detecting emptiness in Python before template execution, and selecting the correct substitution tier inside the template. Keeping them separate makes both stages independently testable.
All three fallback tiers ultimately write into the same layer_has_data boolean and fallback_payload dictionary. The Jinja2 template remains ignorant of which tier was selected; it simply branches on the flag and renders what the payload provides.
Step-by-Step Implementation
1. Pre-Render Spatial Validation
Query the target spatial layer and evaluate three critical metrics: feature count, bounding-box validity, and attribute schema completeness. Serialize results into typed Python primitives — never pass a raw GeoDataFrame into Jinja2, as its iteration behavior inside a template is undefined when the frame is empty.
import geopandas as gpd
import hashlib
def validate_layer(gdf: gpd.GeoDataFrame, query_params: dict) -> dict:
"""Return a typed validation dict safe for Jinja2 context injection."""
if gdf is None or gdf.empty or not gdf.geometry.is_valid.all():
return {
"has_data": False,
"feature_count": 0,
"bounds": None,
"crs": None,
"query_hash": hashlib.md5(str(query_params).encode()).hexdigest()[:8],
}
return {
"has_data": True,
"feature_count": len(gdf),
"bounds": gdf.total_bounds.tolist(), # [minx, miny, maxx, maxy]
"crs": gdf.crs.to_string(),
"query_hash": hashlib.md5(str(query_params).encode()).hexdigest()[:8],
}
The query_hash provides a short audit token that appears in Tier 2 and Tier 3 fallback output so report consumers can trace exactly which query produced an empty result.
2. Define Fallback Tiers
Establish a clear hierarchy of replacement content that scales with data availability:
- Tier 1 — Data Substitution: Swap to an alternative dataset, a prior-period snapshot, or a regional superset that guarantees feature presence. Annotate the substitution with a banner so readers know the displayed data is not primary.
- Tier 2 — Static Placeholder: Render a lightweight inline SVG or optimised PNG with an explanatory caption and the
query_hashtimestamp. This preserves the reserved layout space without implying any spatial content. - Tier 3 — Text-Only Notice: Output a structured alert block containing data provenance, applied filter parameters, the reporting period, and contact information for data stewards.
Tier selection should be driven by business rules in your Python pipeline, not by template complexity. When fallback logic intersects with attribute-heavy components, the patterns in Loop Mapping for Dynamic Attribute Tables show how to handle empty record sets while preserving column alignment and pagination.
def select_fallback_tier(
validation: dict,
alt_dataset: gpd.GeoDataFrame | None,
placeholder_asset: str | None,
) -> dict:
"""Choose and build a fallback payload based on data availability."""
if validation["has_data"]:
return {"tier": None, "content": None}
if alt_dataset is not None and not alt_dataset.empty:
return {
"tier": 1,
"content": "substitute_dataset",
"caption": "Primary layer empty — displaying prior-period snapshot.",
"query_hash": validation["query_hash"],
}
if placeholder_asset:
return {
"tier": 2,
"content": placeholder_asset,
"caption": "No spatial features matched the applied query filters.",
"query_hash": validation["query_hash"],
}
return {
"tier": 3,
"content": "text_notice",
"caption": "Data unavailable for this reporting period.",
"query_hash": validation["query_hash"],
}
3. Context Variable Preparation
Attach a boolean flag (layer_has_data), a fallback payload object, and layout metadata to the Jinja2 context dictionary. Never pass None, empty lists, or uninitialised variables directly to the template engine. Jinja2’s truthiness rules treat empty lists and None as falsy, which can cause silent branching failures when not explicitly tested — a problem the Variable Scoping in Nested Jinja Templates guide addresses in depth.
import json
def build_context(
validation: dict,
fallback: dict,
layer_data: list[dict] | None = None,
) -> dict:
"""Produce a Jinja2-safe context dictionary with explicit sentinel values."""
return {
"layer_has_data": validation["has_data"],
"map_bounds": validation["bounds"] if validation["bounds"] else [],
"map_crs": validation["crs"] or "EPSG:4326",
"feature_count": validation["feature_count"],
"layer_data": layer_data or [],
# Serialise fallback payload as a JSON-safe dict; never pass Python objects
"fallback_payload": json.loads(json.dumps(fallback)),
}
Serialising the fallback payload through json.loads(json.dumps(...)) forces every value to a JSON-native type and surfaces TypeError at context-build time, not inside the template where tracebacks are harder to attribute.
4. Conditional Template Routing
Use Jinja2 control structures to branch rendering based on the validation flag. Prefer is true over bare truthiness checks so that numeric zero and empty string do not accidentally suppress valid single-feature renders.
{% if layer_has_data is true %}
<div class="map-container" data-bounds="{{ map_bounds | join(',') }}" data-crs="{{ map_crs }}">
{{ render_leaflet_layer(layer_data) }}
</div>
{% elif fallback_payload.tier == 1 %}
<div class="map-container map-fallback--substitute" role="note"
aria-label="Substitute dataset — {{ fallback_payload.caption }}">
{{ render_leaflet_layer(substitute_layer_data) }}
<p class="fallback-banner">{{ fallback_payload.caption }}</p>
</div>
{% elif fallback_payload.tier == 2 %}
<div class="map-fallback" role="img"
aria-label="{{ fallback_payload.caption }}">
<img src="{{ fallback_payload.content }}" alt="{{ fallback_payload.caption }}"
loading="lazy" decoding="async"/>
<p class="fallback-caption">{{ fallback_payload.caption }}</p>
<span class="fallback-meta">Query ID: {{ fallback_payload.query_hash }}</span>
</div>
{% else %}
{# Tier 3 — text-only notice #}
<div class="map-fallback map-fallback--text" role="alert"
aria-label="Empty map layer notice">
<p class="fallback-caption">{{ fallback_payload.caption }}</p>
<p class="fallback-meta">
Query ID: {{ fallback_payload.query_hash }} ·
Contact your data steward to investigate this gap.
</p>
</div>
{% endif %}
For the more granular logic of hiding individual layer components — legends, scale bars, attribute tables — using {% if %}...{% else %} at the component level, see Using Jinja2 if-else blocks to hide empty GIS layers.
5. Layout Stabilisation with CSS
Empty map containers frequently trigger cumulative layout shift (CLS), especially in responsive grid systems used for both web and PDF export. Assign minimum dimensions to both the live-map and fallback containers so that swapping one for the other produces zero reflow:
.map-container,
.map-fallback {
min-height: 320px;
display: grid;
place-items: center;
background: var(--bg-tertiary);
border: 1px dashed var(--border-default);
border-radius: 6px;
}
.map-fallback {
text-align: center;
padding: 2rem;
}
.map-fallback--text {
border-style: solid;
background: var(--bg-warning-subtle);
}
.fallback-banner {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-warning-subtle);
color: var(--text-warning);
font-size: 0.85rem;
padding: 0.25rem 0.75rem;
text-align: center;
}
.fallback-caption {
font-size: 0.95rem;
color: var(--text-secondary);
margin: 0.5rem 0;
}
.fallback-meta {
font-size: 0.8rem;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
Enforcing min-height and CSS grid centering eliminates CLS penalties in both web and WeasyPrint PDF exports. The .map-fallback--text variant uses a distinct background to signal data absence without using colour alone, supporting WCAG 1.4.1 (use of colour) compliance.
Production-Ready Script
This complete, copy-pasteable script handles the full fallback workflow with logging, exception handling, and configurable tier selection:
"""
fallback_pipeline.py — Production fallback handler for empty spatial layers.
Requires: jinja2>=3.1.0, geopandas>=0.13.0, shapely>=2.0.0
"""
from __future__ import annotations
import hashlib
import json
import logging
from pathlib import Path
import geopandas as gpd
from jinja2 import Environment, FileSystemLoader, StrictUndefined
log = logging.getLogger(__name__)
def validate_layer(gdf: gpd.GeoDataFrame, query_params: dict) -> dict:
query_hash = hashlib.md5(str(query_params).encode()).hexdigest()[:8]
if gdf is None or gdf.empty:
log.warning("Layer empty for query %s", query_hash)
return {"has_data": False, "feature_count": 0,
"bounds": None, "crs": None, "query_hash": query_hash}
try:
if not gdf.geometry.is_valid.all():
gdf = gdf[gdf.geometry.is_valid]
if gdf.empty:
return {"has_data": False, "feature_count": 0,
"bounds": None, "crs": None, "query_hash": query_hash}
return {
"has_data": True,
"feature_count": len(gdf),
"bounds": gdf.total_bounds.tolist(),
"crs": gdf.crs.to_string() if gdf.crs else "EPSG:4326",
"query_hash": query_hash,
}
except Exception as exc: # shapely.errors.GEOSException etc.
log.error("Geometry validation failed for query %s: %s", query_hash, exc)
return {"has_data": False, "feature_count": 0,
"bounds": None, "crs": None, "query_hash": query_hash}
def select_fallback_tier(
validation: dict,
alt_dataset: gpd.GeoDataFrame | None = None,
placeholder_asset: str | None = None,
) -> dict:
if validation["has_data"]:
return {"tier": None, "content": None, "caption": "", "query_hash": ""}
qh = validation["query_hash"]
if alt_dataset is not None and not alt_dataset.empty:
log.info("Using Tier 1 substitute dataset for query %s", qh)
return {"tier": 1, "content": "substitute_dataset",
"caption": "Primary layer empty — displaying prior-period snapshot.",
"query_hash": qh}
if placeholder_asset and Path(placeholder_asset).exists():
log.info("Using Tier 2 placeholder asset for query %s", qh)
return {"tier": 2, "content": placeholder_asset,
"caption": "No spatial features matched the applied query filters.",
"query_hash": qh}
log.warning("Using Tier 3 text notice for query %s", qh)
return {"tier": 3, "content": "text_notice",
"caption": "Data unavailable for this reporting period.",
"query_hash": qh}
def render_report(
template_dir: str,
template_name: str,
gdf: gpd.GeoDataFrame,
query_params: dict,
alt_dataset: gpd.GeoDataFrame | None = None,
placeholder_asset: str | None = None,
) -> str:
env = Environment(
loader=FileSystemLoader(template_dir),
autoescape=True,
trim_blocks=True,
lstrip_blocks=True,
undefined=StrictUndefined,
)
validation = validate_layer(gdf, query_params)
fallback = select_fallback_tier(validation, alt_dataset, placeholder_asset)
context = {
"layer_has_data": validation["has_data"],
"map_bounds": validation["bounds"] or [],
"map_crs": validation["crs"] or "EPSG:4326",
"feature_count": validation["feature_count"],
"layer_data": gdf.to_dict(orient="records") if validation["has_data"] else [],
"fallback_payload": json.loads(json.dumps(fallback)),
}
tmpl = env.get_template(template_name)
return tmpl.render(**context)
StrictUndefined ensures that any context key referenced in the template but missing from the dictionary raises UndefinedError immediately, rather than silently rendering an empty string. This is critical for production batch pipelines where silent errors across thousands of reports are impossible to audit retrospectively.
Edge Cases and Advanced Configuration
Handling CRS-Invalid Geometries
Some PostGIS layers return geometries whose CRS metadata differs from the project CRS. Validate and reproject before the feature-count check:
def normalise_crs(gdf: gpd.GeoDataFrame, target_crs: str = "EPSG:4326") -> gpd.GeoDataFrame:
if gdf.crs is None:
raise ValueError("Layer has no CRS metadata — cannot normalise.")
if gdf.crs.to_epsg() != int(target_crs.split(":")[1]):
gdf = gdf.to_crs(target_crs)
return gdf
Calling normalise_crs before validate_layer ensures the bounding box coordinates serialised into the context are always in a known reference frame, preventing coordinate-swap errors in tile-based map renderers.
Multi-Format Outputs
When the same pipeline generates both web HTML and WeasyPrint PDFs, the map-fallback CSS class must carry explicit print-media overrides:
@media print {
.map-fallback {
min-height: 280px; /* match print page's map block height */
border: 0.5pt solid #999; /* hairline border visible at 300 dpi */
}
.map-fallback--text {
background: #f8f8f8; /* avoid transparent background in PDF */
}
}
Headless and Containerised Environments
In Docker-based batch pipelines, shapely may be compiled without GEOS support. Verify at container start:
from shapely import geos_version_string
assert geos_version_string, "GEOS unavailable — geometry validation will fail silently."
Failing fast at startup is preferable to discovering validation gaps in the first failed report.
Troubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
| Fallback renders for layers that have features | Implicit truthiness on a dict context variable evaluating to falsy | Replace {% if layer_data %} with {% if layer_has_data is true %} |
UndefinedError: 'fallback_payload' is undefined |
Context built with Environment(undefined=StrictUndefined) but key not injected |
Ensure build_context() always populates fallback_payload, even for successful renders |
| Layout collapses to zero height in PDF export | min-height not carried into print media styles |
Add @media print block with explicit min-height and solid border |
GEOSException halts batch processing |
Uncaught shapely error during is_valid check |
Wrap validate_layer in try/except Exception with Tier 3 fallback injection and continue |
| Tier 1 banner missing after substitute dataset renders | .fallback-banner requires position: relative on parent |
Add position: relative to .map-container |
| CLS score degrades when map swaps on data load | Live and fallback container dimensions differ | Audit that both .map-container and .map-fallback share identical min-height rules |
Validation and Testing Protocols
Promoting fallback changes to production requires tests that cover the full tier cascade, not just the empty-layer path:
- Empty geometry injection: Force an empty
GeoDataFramethroughrender_reportand assert the output HTML containsrole="alert"and the correctquery_hashvalue. - Tier 1 activation: Provide a non-empty
alt_datasetwith an empty primarygdf. Assert the substitute-dataset banner text appears in the output. - Tier 2 placeholder: Set
placeholder_assetto a known file path and pass an empty primarygdfwith noalt_dataset. Assert the<img>src attribute matches. - Temporal gap simulation: Query a date range known to contain zero records. Confirm Tier 2 or Tier 3 output contains an accurate
query_hash. - CLS audit: Render the report in a headless Chromium instance and measure layout shift score. Assert it is below 0.1.
- Template linting: Run Jinja2 templates through
djlintto catch unclosed blocks and undefined variable references before they reach CI.
# tests/test_fallback.py
import pytest
import geopandas as gpd
from shapely.geometry import Point
from fallback_pipeline import validate_layer, select_fallback_tier
QUERY = {"region": "north", "year": 2023}
def make_gdf(n: int) -> gpd.GeoDataFrame:
return gpd.GeoDataFrame(
{"id": range(n)},
geometry=[Point(i, i) for i in range(n)],
crs="EPSG:4326",
)
@pytest.mark.parametrize("n,expected_has_data", [(0, False), (1, True), (50, True)])
def test_validate_layer(n: int, expected_has_data: bool) -> None:
result = validate_layer(make_gdf(n), QUERY)
assert result["has_data"] is expected_has_data
def test_tier_1_selected_when_alt_available() -> None:
validation = validate_layer(make_gdf(0), QUERY)
fallback = select_fallback_tier(validation, alt_dataset=make_gdf(5))
assert fallback["tier"] == 1
def test_tier_3_when_no_assets() -> None:
validation = validate_layer(make_gdf(0), QUERY)
fallback = select_fallback_tier(validation)
assert fallback["tier"] == 3
Detailed Guides in This Section
- Using Jinja2 if-else blocks to hide empty GIS layers — step-by-step technique for wrapping map containers, legends, and attribute tables in feature-count guards at the component level.
Related
- Conditional Rendering for Missing Spatial Data — broader framework for evaluating dataset presence and validating geometry before Jinja2 renders any spatial component
- Loop Mapping for Dynamic Attribute Tables — companion technique for handling empty record sets in attribute tables without breaking column alignment or pagination
- Variable Scoping in Nested Jinja Templates — understanding how context keys propagate through macro calls and child templates, critical when fallback payloads are passed across template layers
- Dynamic Legend Injection for Variable Datasets — how to suppress or replace legend blocks when the associated spatial layer is absent
- Jinja2 Templating & Theme Logic ↑ parent section
Empty map layers become invisible in well-engineered reports — not because they are hidden, but because the fallback content is as carefully constructed as the primary output. The validation-first, tiered substitution approach documented here ensures that every document produced by your pipeline, regardless of spatial data availability, meets the same structural and accessibility standards expected by enterprise stakeholders.