Spatial dashboards built on Streamlit suffer a specific class of latency problem: every user interaction that touches a map layer, CRS transformation, or spatial join can trigger a full re-execution of Python code that reads from disk, hits a database, or reprojects tens of thousands of geometries. Without a disciplined caching layer, UI threads block, memory climbs with each new session, and dashboards that perform acceptably on a developer’s laptop collapse under modest concurrent load in production.

@st.cache_data is Streamlit’s function-level memoization primitive. It intercepts calls to decorated functions, serializes the return value to an in-memory or disk-backed store keyed by the function’s inputs, and short-circuits all subsequent calls with identical arguments. For geospatial workloads this decorator is not optional polish — it is the primary mechanism for keeping tile renders, boundary lookups, and spatial aggregations inside acceptable latency budgets. This guide builds a complete, production-ready implementation workflow, from the first decorator to distributed deployment, with specific attention to the hashing failures that catch GIS engineers off guard.


Jump to heading The Problem: Why Spatial Pipelines Break Without Memoization

A typical sequence in a Streamlit mapping dashboard might look like: read an administrative boundary GeoPackage, reproject from EPSG:27700 to EPSG:4326, dissolve by region, then join with a PostGIS query result. Each of those steps is expensive. Without caching, Streamlit re-runs the entire script on every widget interaction — slider move, dropdown change, map click. A 200 MB GeoPackage that takes 1.8 seconds to read and reproject will do so on every re-run. With five concurrent users, that is a sustained 9-second I/O load per widget event.

@st.cache_data breaks this loop. Once the function’s result is stored, re-runs return it in microseconds from the in-process dictionary. The cache key is derived from the function’s source code hash and its argument values, so different inputs still trigger real computation while identical inputs are served from memory. Crucially, the decorator also isolates serialized copies between sessions — unlike @st.cache_resource, which returns a shared mutable reference — which matters enormously for GeoDataFrames that users might filter or modify after retrieval.


Jump to heading Prerequisites

The following environment baseline is required before implementing this pattern. For broader context on where function-level caching fits in your overall architecture, see Caching Strategies & Async Performance Tuning.

  • Python 3.9 or later. Required for stable asyncio.to_thread bridging; Python 3.11+ is recommended for TaskGroup support if you pair caching with Async Data Loading Patterns.
  • streamlit>=1.18.0 — the release that introduced the stable @st.cache_data API and formally deprecated @st.cache. Do not mix the old and new decorators in the same codebase.
  • Geospatial stack: geopandas>=0.12, shapely>=2.0, pyarrow. Shapely 2.0’s vectorized geometry engine is meaningfully faster to serialize than 1.x, which reduces cache write overhead. pyarrow is required if you use Streamlit’s Arrow-backed caching path.
  • Optional: rasterio and xarray for raster pipelines; psycopg2 or asyncpg for PostGIS connectivity.
  • Operational: set STREAMLIT_SERVER_FILE_WATCHER_TYPE=none in production to prevent the file-watcher from invalidating caches on every hot-reload cycle. Point any persist="disk" cache to a persistent, high-throughput volume — not the container’s ephemeral /tmp.

A working knowledge of Session State Patterns is assumed. Understanding how Streamlit’s reactive execution model re-runs scripts top-to-bottom on each interaction is the conceptual prerequisite for reasoning about what to cache and what to exclude.


Jump to heading Data-Flow Architecture

The diagram below shows how @st.cache_data sits between the raw I/O layer and Streamlit’s rendering pipeline, intercepting repeated requests and routing them to the memory or disk store rather than back to the source.

@st.cache_data data-flow for spatial dashboardsA widget interaction triggers a full Streamlit re-run, which calls the decorated function. The function builds a cache key and probes the cache store. On a hit, the stored result returns straight to the rendering layer. On a miss, the function reads from the I/O source, writes the serialized result into the cache store, and then returns it to the rendering layer.Widget Re-runslider / map / dropdown@st.cache_databuild key, probe storeCache Storememory / disk · pickleprobeI/O Sourcedisk / PostGIS / OGCMISSwriteRendermap / deck.glHITresult

Jump to heading Core Implementation Workflow

Jump to heading Step 1 — Identify Cache Boundaries and Function Signatures

Isolate functions that perform expensive, deterministic operations: reading GeoJSON or GeoPackage files, executing geopandas.sjoin(), projecting coordinate reference systems via to_crs(), computing spatial indices via .sindex, or running PostGIS queries. These functions are cache candidates. Exclude from caching anything that returns mutable UI components, reads st.session_state, or produces randomized outputs.

The cache key is derived from the function’s source code hash and its argument values. Any non-deterministic input — a live database connection object, a current timestamp, a floating-point result of a prior step — will produce cache misses even when the underlying data has not changed. Design function signatures so every argument is either a primitive, a file path string, or a hashable value you control.

Jump to heading Step 2 — Apply the Decorator with GIS-Safe Parameter Types

Wrap the target function with @st.cache_data. When working with GeoDataFrames, pass the file path or a versioned URI rather than the DataFrame object itself. Streamlit’s serialization engine relies on pickle, which struggles with C-level geometry buffers. By caching at the I/O boundary you guarantee that identical file paths produce identical cache keys.

python
import geopandas as gpd
import streamlit as st

@st.cache_data(ttl=3600, max_entries=50, persist="disk")
def load_admin_boundaries(file_path: str, target_crs: str = "EPSG:4326") -> gpd.GeoDataFrame:
    """Load and project administrative boundaries with deterministic caching."""
    gdf = gpd.read_file(file_path)
    return gdf.to_crs(target_crs)


# Called on every Streamlit re-run — result served from cache after first load
boundaries = load_admin_boundaries(
    "data/lad_boundaries_2023.gpkg",
    target_crs="EPSG:4326"
)

The ttl=3600 argument invalidates the entry after one hour. max_entries=50 caps the in-memory dictionary to prevent unbounded growth. persist="disk" writes a serialized copy to disk so the cache survives app restarts — important when container memory is recycled but a shared volume is mounted.

Jump to heading Step 3 — Configure Lifecycle and Memory Parameters

Tune ttl and max_entries to match your data refresh cadence and infrastructure limits.

Data typeRecommended ttlNotes
Static admin boundariesNone or 86400Invalidate on dataset version bump
Nightly aggregation layers3600Aligns with typical ETL schedules
Near-real-time sensor feeds60300Pair with explicit invalidation hook
PostGIS query results900See Query Result Caching

Always set max_entries explicitly. Leaving it unbounded means a dashboard that serves hundreds of unique bounding-box filter combinations will grow its cache dictionary without limit until the worker OOM-kills. A reasonable starting value is 100 entries for large GeoDataFrames, adjusted down if RSS monitoring shows excess memory pressure.

Jump to heading Step 4 — Validate Cache Behaviour and Hit Ratios

During development, call st.cache_data.clear() to force invalidation and verify that your pipeline correctly rebuilds the cache from scratch. Use Streamlit’s built-in cache inspector in the sidebar to observe hit and miss events interactively.

python
import streamlit as st
import time

@st.cache_data(ttl=1800, max_entries=30)
def expensive_spatial_join(left_path: str, right_path: str) -> "gpd.GeoDataFrame":
    import geopandas as gpd
    t0 = time.perf_counter()
    left = gpd.read_file(left_path)
    right = gpd.read_file(right_path)
    result = gpd.sjoin(left, right, how="inner", predicate="intersects")
    elapsed = time.perf_counter() - t0
    # Log to app console so you can verify cache hits eliminate this output
    print(f"[cache miss] spatial join took {elapsed:.2f}s, {len(result)} rows")
    return result

A healthy spatial pipeline should achieve a cache hit rate above 85 % under normal concurrency. If you observe frequent misses despite identical inputs, inspect your function signatures for hidden mutable state — default arguments that reference a module-level object, dynamic CRS strings assembled at call time, or timestamp-derived paths.

Jump to heading Step 5 — Integrate with Async and Query Layers

When spatial queries originate from external databases or cloud storage, pair @st.cache_data with Async Data Loading Patterns to prevent blocking the main thread during the cold-cache path. For database-backed workloads, implement Query Result Caching to intercept SQL and GeoServer responses before they reach the Python layer.

python
import asyncio
import geopandas as gpd
import streamlit as st

@st.cache_data(ttl=900, max_entries=20)
def load_postgis_layer(dsn: str, table: str, bbox: tuple[float, float, float, float]) -> gpd.GeoDataFrame:
    """Cache PostGIS spatial query results keyed by DSN, table, and bounding box."""
    import sqlalchemy
    engine = sqlalchemy.create_engine(dsn)
    west, south, east, north = bbox
    sql = f"""
        SELECT geom, name, region_code
        FROM {table}
        WHERE geom && ST_MakeEnvelope({west}, {south}, {east}, {north}, 4326)
    """
    return gpd.read_postgis(sql, engine, geom_col="geom", crs="EPSG:4326")

This layered approach decouples I/O latency from rendering performance, ensuring that heavy geospatial payloads served from a warm cache never block the UI thread.


Jump to heading Advanced Patterns

Every advanced pattern below is, at heart, a decision about how Streamlit derives a stable cache key from a non-trivial argument. The flow chart maps each GIS argument type to the technique that keeps it deterministic — and shows which path raises UnhashableParamError if you skip it.

Choosing a cache-safe key strategy for spatial argumentsA decision flow starting from an argument passed to a cached function. Primitives, file paths and bounding-box tuples hash natively. A live GeoDataFrame, pyproj CRS or Shapely geometry is unhashable by default and raises UnhashableParamError unless routed through a strategy: pass the file path instead, supply a hash_funcs mapping to a stable digest, or add an explicit version-hash argument. All cache-safe paths converge on a deterministic key.Function argumentpassed to cached fnHashable by default?yesPrimitive · str path · bbox tuple"data/lad.gpkg" · (w,s,e,n)noGeoDataFrame · CRS · geometry→ UnhashableParamErrorPass file pathread inside fnhash_funcs→ WKB / EPSGdigestversion argfile MD5Deterministic cache key

Jump to heading Pattern 1 — Custom hash_funcs for Complex GIS Objects

Geospatial workflows frequently need to pass pyproj.CRS objects, Shapely geometry instances, or other C-extension types as function arguments. These are unhashable by default and will raise UnhashableParamError. Supply a hash_funcs mapping that extracts only the deterministic component from each type.

python
import geopandas as gpd
import streamlit as st
from pyproj import CRS
from shapely.geometry.base import BaseGeometry

@st.cache_data(
    ttl=1800,
    max_entries=25,
    hash_funcs={
        gpd.GeoDataFrame: lambda gdf: hash(gdf.to_wkb().tobytes()),
        CRS: lambda crs: crs.to_epsg(),
        BaseGeometry: lambda geom: hash(geom.wkb),
    }
)
def clip_to_aoi(
    gdf: gpd.GeoDataFrame,
    aoi: BaseGeometry,
    target_crs: CRS
) -> gpd.GeoDataFrame:
    """Clip a GeoDataFrame to an area of interest, projecting to target CRS."""
    clipped = gdf.clip(aoi)
    return clipped.to_crs(target_crs)

hash_funcs keys must be the actual type object (e.g., gpd.GeoDataFrame), not a string such as "geopandas.GeoDataFrame". String-based keys are silently ignored by @st.cache_data.

Jump to heading Pattern 2 — Versioned Cache Keys for Dataset Refresh Cycles

Static boundary files rarely change, but when they do — a new ONS release, an updated census boundary — you need explicit invalidation without restarting the application. Encode a version string or a dataset checksum as a function argument so that a new dataset version automatically creates a distinct cache entry.

python
import hashlib
import pathlib
import geopandas as gpd
import streamlit as st

def file_md5(path: str) -> str:
    """Return the MD5 digest of a file — stable cache key for versioned datasets."""
    h = hashlib.md5()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()

@st.cache_data(ttl=None, max_entries=10)
def load_boundaries_versioned(file_path: str, version_hash: str) -> gpd.GeoDataFrame:
    """version_hash is derived from the file's MD5 so stale entries self-expire on dataset update."""
    gdf = gpd.read_file(file_path)
    return gdf.to_crs("EPSG:4326")

# At call site — recompute hash on each run (cheap) so cache key tracks dataset changes
boundaries = load_boundaries_versioned(
    "data/lad_2024.gpkg",
    version_hash=file_md5("data/lad_2024.gpkg")
)

Jump to heading Pattern 3 — Redis-Backed Warm Cache for Distributed Workers

Single-node persist="disk" caches become fragmented when multiple Streamlit workers or containerized replicas each maintain their own serialized store. For cross-worker cache warming, use Redis as a shared key-value store and call it from within the @st.cache_data function body. The outer decorator still short-circuits at the process level; the inner Redis call handles cross-process sharing.

python
import geopandas as gpd
import io
import redis
import streamlit as st

_redis = redis.Redis(host="redis", port=6379, db=0)

@st.cache_data(ttl=600, max_entries=5)
def load_layer_distributed(layer_key: str, file_path: str) -> gpd.GeoDataFrame:
    """
    Process-local cache via @st.cache_data.
    Cross-worker warm cache via Redis using Parquet serialization.
    """
    raw = _redis.get(layer_key)
    if raw is not None:
        return gpd.read_parquet(io.BytesIO(raw))

    gdf = gpd.read_file(file_path).to_crs("EPSG:4326")
    buf = io.BytesIO()
    gdf.to_parquet(buf, index=False, compression="snappy")
    _redis.setex(layer_key, 600, buf.getvalue())
    return gdf

Parquet with Snappy compression is substantially faster to serialize and deserialize than pickle for large GeoDataFrames, and it preserves columnar structure that Arrow-backed rendering tools can consume without an intermediate copy.


Jump to heading Verification and Testing

Confirm that caching is active and performing as expected before deploying to production.

python
import time
import geopandas as gpd
import streamlit as st

@st.cache_data(ttl=3600, max_entries=10)
def load_test_layer(path: str) -> gpd.GeoDataFrame:
    return gpd.read_file(path).to_crs("EPSG:4326")

# First call — cold cache
t0 = time.perf_counter()
gdf_a = load_test_layer("data/lad_boundaries_2023.gpkg")
cold_time = time.perf_counter() - t0

# Second call — should be served from cache (orders of magnitude faster)
t1 = time.perf_counter()
gdf_b = load_test_layer("data/lad_boundaries_2023.gpkg")
warm_time = time.perf_counter() - t1

assert warm_time < cold_time * 0.05, (
    f"Cache not active: cold={cold_time:.3f}s warm={warm_time:.3f}s"
)
# Both calls should return structurally equivalent DataFrames
assert list(gdf_a.columns) == list(gdf_b.columns)
assert len(gdf_a) == len(gdf_b)
print(f"Cache working: {cold_time:.2f}s cold → {warm_time:.4f}s warm")

For memory profiling, use tracemalloc or memory_profiler to snapshot RSS before and after warming the cache. Target a growth rate below 50 MB per 100 unique cache entries for typical boundary datasets. If growth is higher, reduce max_entries or switch to Parquet-backed serialization.


Jump to heading Troubleshooting

UnhashableParamError: cannot hash type GeoDataFrame

Root cause: You passed a live GeoDataFrame as a function argument. Streamlit tries to hash it using pickle; C-level geometry buffers are not deterministically picklable.

Fix: Refactor the function to accept a file path string and read the GeoDataFrame internally. If you must pass a DataFrame, supply a hash_funcs mapping: hash_funcs={gpd.GeoDataFrame: lambda gdf: hash(gdf.to_wkb().tobytes())}.

Cache misses on every run despite identical inputs

Root cause: A non-deterministic value is included in the function’s arguments. Common culprits: a datetime.now() call assembled outside the function but passed in, a dict whose iteration order varies, or a pathlib.Path object (use str(path) instead).

Fix: Audit every argument type. Convert Path objects to str. Move any timestamp or version calculation inside the function and encode it as a stable string parameter. Add a print statement inside the function body — if it fires on every run, the cache is not activating.

MemoryError or OOM kill after extended uptime

Root cause: max_entries was not set, so the cache dictionary grew unboundedly as users queried different bounding boxes or filter combinations.

Fix: Always set max_entries. For GeoDataFrames exceeding 100 MB each, set max_entries=5 to max_entries=20. Monitor RSS with psutil.Process().memory_info().rss and alert when it exceeds 80 % of the container memory limit. Use persist="disk" to offload serialized copies off the heap if you need to retain more entries.

Stale geometry after dataset update — old shapes still appearing

Root cause: A long ttl or ttl=None means the cache entry does not self-expire when the underlying file changes.

Fix: Use the versioned cache key pattern (MD5 of the file or a dataset release string passed as an argument). Alternatively, expose an admin button in the dashboard that calls st.cache_data.clear() when a new dataset is published. For caches keyed on user-driven geometry rather than static files, the deterministic key recipe in Fixing Cache Invalidation for Dynamic Spatial Queries shows how to expire entries the moment the underlying bounding box changes.

Serialization overhead dominates response time for large GeoDataFrames

Root cause: Streamlit uses pickle by default. For GeoDataFrames over 200 MB, pickle round-trips are slow and can exceed the latency savings from caching.

Fix: Convert to Parquet inside the cached function and return either a file path or an Arrow Table. Use pyarrow.parquet with compression="snappy" or compression="zstd". Streamlit’s Arrow-backed path skips a full deserialize step when the downstream widget accepts Arrow format natively.


Jump to heading Performance Considerations

Payload size thresholds. Functions returning GeoDataFrames under 50 MB are straightforward candidates for @st.cache_data with default settings. Between 50 MB and 200 MB, switch to persist="disk" and Parquet serialization. Above 200 MB, consider whether caching the full object is the right strategy — it may be cheaper to cache only the result of a spatial filter applied to the full dataset, rather than the full dataset itself.

Cache key design. The cache key includes a hash of the function’s source code. Refactoring a cached function’s internals while keeping its signature identical does not automatically invalidate existing entries — only a change to the source code that inspect.getsource() would detect triggers invalidation. When you fix a bug in a cached function, bump a version: int = 1 argument or call st.cache_data.clear() explicitly during deployment.

Async vs sync trade-offs. @st.cache_data is synchronous — the cold-cache execution path blocks the script thread. For I/O-bound operations (large file reads, network requests to OGC endpoints), pair the decorator with asyncio.to_thread or use a ThreadPoolExecutor to move the blocking call off the main thread during the miss path. Once the result is cached, subsequent calls return synchronously at negligible cost. For detailed guidance see Async Data Loading Patterns.

Thread safety. @st.cache_data is thread-safe by design: concurrent requests for the same key block on the first computation and then all receive the cached result. However, never mutate a cached GeoDataFrame in-place. Always produce a new copy before applying user-driven filters: gdf_filtered = gdf.copy().loc[mask].

Memory Limit Management integration. Cache sizing and memory budgets must be planned together. Set the container’s memory limit first, allocate a fixed fraction (typically 30–40 %) to the cache store, and derive max_entries from that budget divided by your average object size. Adjust after observing actual RSS under load.


Jump to heading Frequently Asked Questions

Why does @st.cache_data raise UnhashableParamError for GeoDataFrames?

GeoDataFrames carry C-level geometry buffers that pickle cannot serialize deterministically, so Streamlit cannot derive a stable cache key from them. Pass file paths or versioned URIs instead of live DataFrames, or supply a hash_funcs mapping that converts the GeoDataFrame to a stable byte representation such as its WKB digest: hash_funcs={gpd.GeoDataFrame: lambda gdf: hash(gdf.to_wkb().tobytes())}.

How do I share a cached result across multiple Streamlit worker processes?

Mount a shared network volume and set persist="disk" so all replicas read and write the same serialized entries. For finer-grained control, call an external key-value store such as Redis or Memcached from inside the cached function body and return the result, bypassing Streamlit’s built-in serialization for large geometry payloads. The Redis-backed warm cache pattern above shows this exact arrangement, using Parquet serialization to keep cross-worker transfers cheap.

What TTL should I use for different spatial data types?

Static administrative boundaries can use ttl=None or ttl=86400. Aggregated analytics layers that update nightly suit ttl=3600. Near-real-time sensor or vehicle feeds should use ttl=60 to ttl=300. Always pair any TTL with max_entries to prevent unbounded memory growth — see the lifecycle table in Step 3 for the full matrix.

Should I use @st.cache_data or @st.cache_resource for spatial layers?

Use @st.cache_data for the GeoDataFrames, query results, and reprojected geometries that flow through your UI — it returns an isolated copy per session, so a user filtering or clipping a layer cannot corrupt another session’s data. Reserve @st.cache_resource for unserializable shared singletons such as a database engine, a pyproj transformer, or a tile-server connection pool, where every session genuinely wants the same live object.


Back to Caching Strategies & Async Performance Tuning

Related