Generate a deterministic hash from your geometry and CRS before the framework ever sees the arguments — floating-point drift, vertex reordering, and CRS aliases are the root cause of spatial cache misses, and a pre-hashing normalization layer eliminates all three.

Jump to heading Why This Matters

Query result caching is the single biggest latency lever in a spatial dashboard: executed correctly, it transforms a 2-second PostGIS bounding-box join into a sub-50 ms dictionary lookup. The problem is that the default hashing mechanisms built into @st.cache_data and @pn.cache were designed for ordinary Python objects, not geospatial data structures. Bounding boxes from browser map widgets carry floating-point noise. GeoJSON FeatureCollection objects reorder vertices between requests. "EPSG:4326", "WGS84", and 4326 are semantically identical CRS references but produce three distinct string hashes. The result is cache thrashing: the framework generates a fresh cache entry for every logically identical query, memory fills with near-duplicate GeoDataFrame result sets, and database connection pools saturate under redundant load. Fixing this requires a pre-hashing normalization layer that enforces logical equivalence before the framework computes its key.

This page covers the exact implementation. For broader context on memory limit management and async data loading patterns that sit alongside caching in a production dashboard, follow those links.

Jump to heading Prerequisites

  • Python 3.9+, streamlit>=1.18 or panel>=1.0
  • geopandas>=0.12, shapely>=2.0
  • Familiarity with query result caching concepts — specifically TTL-based eviction and per-function cache scoping

Jump to heading Why Framework-Level Hashing Breaks on Geospatial Data

Spatial cache miss failure modesDiagram showing four input types — floating-point coords, unordered geometries, CRS aliases, and transient UI state — each flowing into a framework hasher that produces distinct keys for logically identical inputs, leading to cache misses.Spatial inputsFloating-point coords−73.935242 vs −73.935242000001Unordered geometriesMultiPolygon vertex order variesCRS aliasesEPSG:4326 / WGS84 / 4326Transient UI statezoom level, slider metadataFrameworkhasherbyte-level hashkey: a3f8c1… — MISSkey: 9d22e7… — MISSkey: b14f30… — MISSLogically identical queries → distinct keys → cache thrashing

Framework caching decorators compute keys by recursively hashing function arguments. Spatial queries routinely trigger four structural failure modes:

  1. Floating-point drift — client-side bounding boxes and radius buffers differ by roughly 1e-12 across browser sessions. Frameworks treat these as distinct inputs, generating unique hashes for logically identical queries.
  2. Unordered geometriesMultiPolygon, LineString, and GeoJSON FeatureCollection objects frequently reorder vertices or features between requests. Hash functions are order-sensitive, so vertex-order variance causes unnecessary misses.
  3. CRS ambiguity"EPSG:4326", "WGS84", and 4326 resolve to the same coordinate system but produce different string hashes. A dashboard that uses session state patterns to persist the selected CRS often stores it in whichever format the user last chose, making key stability impossible without normalization.
  4. Transient UI state — sliders, brush selections, and map zoom levels append ephemeral metadata to query parameters. When baked into the cache key, they invalidate results that should remain stable across zoom changes.

Jump to heading Step-by-Step Solution

Jump to heading Step 1 — Build a deterministic key normalizer

Decouple key generation from the cached function entirely. The function below normalizes geometry, canonicalizes the CRS, and returns a stable SHA-256 hex digest that is safe to use as a leading argument for any caching decorator.

python
import hashlib
from shapely import wkt, wkb
from shapely.geometry.base import BaseGeometry
from typing import Union

def normalize_spatial_key(
    geometry: Union[BaseGeometry, str],
    crs: Union[str, int],
    precision: int = 6,
) -> str:
    """
    Return a deterministic SHA-256 cache key for a spatial query.

    precision=6 caps coordinate noise at ~11 cm — sufficient for
    dashboard bounding boxes while preserving analytical accuracy.
    """
    # Accept WKT strings or live Shapely geometries
    geom = wkt.loads(geometry) if isinstance(geometry, str) else geometry

    # Round coordinates to eliminate floating-point drift
    rounded_geom = wkt.loads(wkt.dumps(geom, rounding_precision=precision))

    # Canonicalize CRS: "WGS84", 4326, "EPSG:4326" → "EPSG:4326"
    crs_str = str(crs).strip()
    if crs_str.isdigit():
        canonical_crs = f"EPSG:{int(crs_str)}"
    elif crs_str.upper().startswith("EPSG:"):
        canonical_crs = crs_str.upper()
    else:
        # Handle "WGS84", "WGS 84", etc. via pyproj if needed;
        # for common aliases a simple mapping is sufficient
        _aliases = {"WGS84": "EPSG:4326", "WGS 84": "EPSG:4326"}
        canonical_crs = _aliases.get(crs_str.upper(), crs_str.upper().replace(" ", ""))

    # WKB serialization strips vertex-order ambiguity
    wkb_hex = wkb.dumps(rounded_geom, hex=True)

    payload = f"{canonical_crs}|{wkb_hex}"
    return hashlib.sha256(payload.encode()).hexdigest()

Jump to heading Step 2 — Pass the normalized key as the leading argument

Frameworks hash positional arguments left-to-right. Placing the stable hash string first guarantees cache hits even when secondary parameters carry minor variance.

python
import streamlit as st
import geopandas as gpd
import psycopg2
from typing import Any, Dict

# Connection pool is created once outside the cached function
_conn = psycopg2.connect(
    host="db.example.internal",
    dbname="spatial_db",
    user="reader",
    password="...",
)

@st.cache_data(ttl=300, max_entries=500, show_spinner="Fetching spatial data…")
def execute_cached_spatial_query(
    cache_key: str,          # normalized hash — stable across sessions
    cache_version: int,      # explicit invalidation counter
    query_template: str,
    params: Dict[str, Any],
) -> gpd.GeoDataFrame:
    """
    Execute a PostGIS query and return a GeoDataFrame.

    Example query_template:
      SELECT gid, name, geom
      FROM parcels
      WHERE ST_Intersects(geom, ST_MakeEnvelope(%(xmin)s, %(ymin)s, %(xmax)s, %(ymax)s, 4326))

    Example params:
      {"xmin": -74.02, "ymin": 40.70, "xmax": -73.91, "ymax": 40.78}
    """
    return gpd.read_postgis(
        query_template,
        _conn,
        geom_col="geom",
        params=params,
        crs="EPSG:4326",
    )

Jump to heading Step 3 — Wire up a version-stamped invalidation trigger

Automatic byte-level hashing cannot distinguish a stale dataset from a fresh one. Tie invalidation to an explicit data refresh event rather than hoping the framework detects upstream changes.

Version-stamped cache invalidation data flowThe map widget emits a bounding box that normalize_spatial_key turns into a stable hash. The hash and a session cache_version counter enter the cached query function: on a cache hit the stored GeoDataFrame is returned immediately, while a Refresh data event increments the version counter and forces a fresh PostGIS query whose result repopulates the cache.Map widgetemits bounding boxnormalize_spatial_key()round · WKB · canon. CRS→ stable SHA-256 hashcache_versionsession-state counterexecute_cached_spatial_query()key + version → lookupcache_keycache hitreturn cachedGeoDataFramemissPostGIS queryresult repopulates cacheRefresh databutton / webhook+1Stable key → repeat hits · incrementing the version forces one fresh PostGIS call, then caches again
python
import streamlit as st

# Initialise the counter once per session
if "spatial_cache_version" not in st.session_state:
    st.session_state["spatial_cache_version"] = 0

def trigger_data_refresh() -> None:
    """
    Call this from any 'Refresh data' button callback or
    scheduled data-pipeline completion webhook.
    Incrementing the version forces fresh cache entries for all
    spatial queries without touching unrelated cached data.
    """
    st.session_state["spatial_cache_version"] += 1
    # Only call .clear() under severe memory pressure — prefer
    # letting LRU eviction handle old entries naturally.
    # st.cache_data.clear()

# --- In the main dashboard render loop ---
bbox_polygon = get_map_bbox_as_shapely_polygon()  # your map widget helper

key = normalize_spatial_key(bbox_polygon, crs=4326, precision=6)
version = st.session_state["spatial_cache_version"]

gdf = execute_cached_spatial_query(
    cache_key=key,
    cache_version=version,
    query_template="""
        SELECT gid, neighbourhood, geom
        FROM nyc_neighbourhoods
        WHERE ST_Intersects(
            geom,
            ST_MakeEnvelope(%(xmin)s, %(ymin)s, %(xmax)s, %(ymax)s, 4326)
        )
    """,
    params={
        "xmin": -74.02, "ymin": 40.70,
        "xmax": -73.91, "ymax": 40.78,
    },
)

Jump to heading Step 4 — Apply the same pattern in Panel

@pn.cache respects the same leading-argument convention. The normalize_spatial_key() function is framework-agnostic.

python
import panel as pn
import geopandas as gpd
from typing import Any, Dict

@pn.cache(ttl=300, max_items=500)
def execute_cached_spatial_query(
    cache_key: str,
    cache_version: int,
    query_template: str,
    params: Dict[str, Any],
) -> gpd.GeoDataFrame:
    return gpd.read_postgis(query_template, _conn, geom_col="geom", params=params, crs="EPSG:4326")

Jump to heading Verification

Run this assertion block after wiring up the normalizer to confirm two logically identical bounding boxes produce the same key, and that incrementing the version produces a different effective cache entry (triggering a real database call on the next invocation):

python
from shapely.geometry import box

# Simulate floating-point drift from two browser sessions
bbox_a = box(-74.019999999998, 40.700000000001, -73.910000000002, 40.779999999999)
bbox_b = box(-74.020000000000, 40.700000000000, -73.910000000000, 40.780000000000)

key_a = normalize_spatial_key(bbox_a, crs=4326, precision=6)
key_b = normalize_spatial_key(bbox_b, crs=4326, precision=6)

assert key_a == key_b, "Precision rounding must absorb sub-cm drift"

# Confirm CRS aliases collapse
key_wgs = normalize_spatial_key(bbox_a, crs="WGS84", precision=6)
key_epsg = normalize_spatial_key(bbox_a, crs="EPSG:4326", precision=6)
assert key_wgs == key_epsg, "CRS aliases must normalize to the same canonical form"

print("All key-stability assertions passed.")
# Expected output: All key-stability assertions passed.

Jump to heading Edge Cases and Gotchas

  • Tab isolation in Streamlit — Streamlit’s st.session_state is per-tab, so spatial_cache_version lives in one tab’s session only. If a user has the dashboard open in two tabs and triggers a refresh in tab A, tab B continues serving stale results until it also increments its counter. For cross-tab invalidation, store the version in a shared process-level variable or an external key-value store.
  • CRS mismatch after reprojection — if your data pipeline reprojects GeoDataFrames to EPSG:3857 for tile rendering but your PostGIS query uses EPSG:4326 bounding boxes, ensure normalize_spatial_key receives the CRS that matches the query, not the display CRS. Mixing the two silently produces keys that don’t match any cached entry.
  • TTL expiry during long sessions — with ttl=300, a query cached at the start of a 10-minute user session will expire mid-session. If your data source is stable for hours, raise the TTL to match the actual data refresh cadence rather than arbitrarily limiting it to 5 minutes.

Jump to heading FAQ

Why does st.cache_data produce cache misses even when I pass the same bounding box?

Streamlit’s default hasher performs a byte-level comparison of function arguments. Floating-point bounding-box coordinates that differ by as little as 1e-12 — common when coordinates round-trip through a browser map widget — produce unique hashes. Round coordinates to 6 decimal places and serialize them to WKB using normalize_spatial_key() before passing them to the cached function.

How do I invalidate only the spatial cache without wiping all cached data?

Use a version counter (cache_version) as part of the cache key rather than calling st.cache_data.clear(). Incrementing the counter forces new cache entries for all spatial queries while leaving unrelated cached data — such as metadata lookups or configuration fetches — intact until their own TTLs expire.

Does this deterministic key approach work for Panel dashboards as well?

Yes. The normalize_spatial_key() function is entirely framework-agnostic. Pass its output as the first positional argument to any @pn.cache-decorated function. Panel’s cache respects positional argument ordering identically to Streamlit’s, so the leading-key pattern applies without modification.

Jump to heading Memory and Performance Guardrails

Spatial result sets are heavy: a single GeoDataFrame covering a municipality’s parcel layer can easily exceed 50 MB in process memory. Apply these limits in production:

  • TTL baselinettl=300 (5 minutes) matches typical dashboard session lengths. Align TTL with your data source’s actual refresh cadence if it is longer.
  • Entry capmax_entries=500 triggers LRU eviction before memory pressure becomes critical; tune downward on containers with less than 2 GB RAM.
  • Column pruning — return only the columns downstream components actually consume. Drop geometry entirely if a table widget only needs attribute data; use gdf[["gid", "name", "area_sqm"]] before returning from the cached function.
  • Connection pooling — cache query results, not database connections. Instantiate your psycopg2 or duckdb connection pool outside any cached function so it is shared across cache misses rather than rebuilt on every cold call.

For detailed guidance on setting process-level memory ceilings see memory limit management.

Jump to heading When to Bypass Caching Entirely

Deterministic keys solve hash instability but cannot fix architecturally unsuitable query patterns. Skip the caching layer when:

  • Queries consume real-time streaming data (IoT sensor feeds, live transit GTFS-RT) where any TTL would serve stale readings.
  • Spatial joins span more than ~50,000 rows and require incremental or streaming processing via asyncio-based pipelines — see async data loading patterns for the right approach.
  • Users expect sub-100 ms responses on highly granular, per-pixel bounding boxes where cached entries would never be reused.

In these scenarios, rely on database-side materialized views, GIST or SP-GiST spatial indexes on your PostGIS tables, or push rendering to a client-side tile server so the browser handles spatial filtering rather than the Python process.


Back to Query Result Caching

Related