Using Jinja2 if-else blocks to hide empty GIS layers

Using Jinja2 if-else blocks to hide empty GIS layers requires evaluating feature counts and geometry validity in your Python pipeline before the template renders. By passing a pre-computed boolean (has_features) and a clean feature list into the Jinja2 context, you can wrap map containers, legends, and attribute tables in {% if %}...{% else %}...{% endif %} logic. This prevents broken map frames, orphaned legend symbols, and pagination shifts in automated PDF/HTML reports.

The pattern shifts heavy spatial evaluation out of the templating engine and into your data pipeline, ensuring templates remain lightweight, deterministic, and easy to maintain across reporting workflows.

Core Architecture: Evaluate in Python, Render in Jinja2

Jinja2 is a string templating engine, not a spatial query processor. Attempting to count features, validate geometries, or filter GeoDataFrames inside a template introduces latency, breaks autoescaping, and complicates debugging. Instead, adopt a strict separation of concerns:

  • Python loads, filters, validates, and serializes spatial data.
  • Jinja2 receives explicit context variables and handles presentation logic.

When integrated into a broader Jinja2 Templating & Theme Logic architecture, this separation allows you to standardize report generation across municipal dashboards, environmental assessments, and consulting deliverables without manual document patching.

Step 1: Prepare the Context in Python

The most reliable approach evaluates layer emptiness during data preparation. Your Python script should return a dictionary containing a boolean flag, a sanitized feature list, and metadata for the template.

Python
# report_pipeline.py
import geopandas as gpd
from jinja2 import Environment, FileSystemLoader, select_autoescape

def build_spatial_context(shapefile_path: str, zone_filter: str) -> dict:
    # 1. Load spatial data
    gdf = gpd.read_file(shapefile_path)
    
    # 2. Apply attribute filter
    filtered = gdf[gdf["zone"] == zone_filter].copy()
    
    # 3. Remove null/invalid geometries to prevent renderer crashes
    # See: https://geopandas.org/en/stable/docs/user_guide/geometric_manipulations.html
    filtered = filtered[filtered.geometry.notna()]
    
    # 4. Build explicit, template-ready context
    return {
        "layer_name": f"{zone_filter.replace('_', ' ').title()} Parcels",
        "features": filtered[["parcel_id", "risk_score", "last_updated"]].to_dict("records"),
        "has_features": len(filtered) > 0,
        "feature_count": len(filtered),
        "layer_slug": zone_filter.lower().replace(" ", "-")
    }

# Example usage
context = build_spatial_context("data/parcels.shp", "flood_risk")

env = Environment(
    loader=FileSystemLoader("templates"),
    autoescape=select_autoescape()
)
template = env.get_template("spatial_report.html")
html_output = template.render(**context)

Why this works:

  • has_features provides a fast, unambiguous boolean for the template.
  • features contains only the columns needed for the attribute table, reducing payload size.
  • Geometry validation happens upstream, avoiding silent rendering failures in map libraries.

Step 2: Apply Conditional Logic in the Template

With a clean context, your Jinja2 template can safely branch between full spatial output and a graceful fallback. The {% if %} statement should wrap the entire component block, including headers, map containers, and tables.

HTML
<!-- templates/spatial_report.html -->
<section class="spatial-layer" id="layer-{{ layer_slug }}">
  {% if has_features %}
    <h3>{{ layer_name }} — {{ feature_count }} features</h3>
    
    <div class="map-frame" data-layer-id="{{ layer_slug }}" data-feature-count="{{ feature_count }}">
      <!-- Map library (Leaflet, MapLibre, etc.) initializes here -->
    </div>
    
    <table class="attribute-table">
      <thead>
        <tr>
          <th>Parcel ID</th>
          <th>Risk Score</th>
          <th>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>

Key implementation notes:

  • The else block is mandatory for production reports. It preserves document flow, prevents collapsed sections, and provides clear user messaging.
  • Data attributes (data-layer-id, data-feature-count) allow JavaScript map libraries to skip initialization when feature_count == 0.
  • For official syntax reference, consult the Jinja2 control structures documentation.

Reusable Patterns for Production Reports

Hardcoding {% if has_features %} across dozens of layers creates maintenance debt. Extract the logic into a Jinja2 macro to standardize fallback behavior and styling.

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 }} features</h3>
      <div class="map-frame" data-layer="{{ layer_slug }}"></div>
      {{ caller() }}
    {% else %}
      <div class="empty-state">
        <h3>{{ layer_name }}</h3>
        <p>No spatial data available for this layer.</p>
      </div>
    {% endif %}
  </section>
{% endmacro %}

Call the macro with {% call %} to inject layer-specific table markup while keeping the conditional wrapper DRY. This pattern aligns directly with Conditional Rendering for Missing Spatial Data workflows, where datasets routinely arrive with zero features due to spatial filters, temporal cutoffs, or incomplete field surveys.

Performance & Edge-Case Guidelines

Issue Solution
None vs [] in context Always pass an explicit empty list [] and a boolean. Jinja2 treats None and [] differently in truthiness checks.
Large feature lists Paginate or sample in Python before passing to the template. Jinja2 loops scale linearly; 10k+ rows will bottleneck HTML generation.
Legend synchronization Generate legend items from the same features list. If has_features is false, skip legend rendering entirely to avoid phantom symbols.
PDF pagination breaks Wrap each {% if %} block in a CSS page-break-inside: avoid; rule to prevent empty layers from splitting across pages.
Autoescape conflicts Use `

Summary

Using Jinja2 if-else blocks to hide empty GIS layers is a presentation-layer safeguard, but its reliability depends entirely on upstream data validation. Evaluate feature counts and geometry validity in Python, pass explicit booleans and serialized lists to the template, and wrap all spatial components in strict {% if %}...{% else %}...{% endif %} blocks. This guarantees clean report output, prevents renderer crashes, and scales effortlessly across multi-layer GIS dashboards and automated publishing pipelines.