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.

if-else Layer Rendering Flow Diagram showing the Python pipeline producing a context dict with has_features and features, which the Jinja2 template consumes. A conditional branch produces either the full map/legend/table block when has_features is true, or the empty-state panel when false. Python Pipeline load → filter → validate → build context dict Context Dict has_features: bool features: list[dict] feature_count: int Jinja2 Template {% if has_features %} {% else %} / {% endif %} True → Active Layer map frame + legend + attribute table False → Empty State heading + message + "0 records" badge true false

Prerequisites

  • Python 3.10+ with geopandas>=0.14 and jinja2>=3.1.0 installed (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 autoescape setting
  • 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.

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

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

HTML
<!-- 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.

HTML
<!-- 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.

HTML
{# 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:

HTML
{# 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:

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

Python
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 None instead of [] for an empty layer. {% for f in features %} raises TypeError when features is None; always initialise to [] in Python. A | default([]) filter in the template is a safety net, not a substitute for correct pipeline output.

  • Testing features truthiness in the template instead of has_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 own has_features guard, 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: avoid in 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. Apply page-break-inside: avoid to .empty-state in your print stylesheet.