st.cache_data vs pn.cache for GeoDataFrame Caching
Use @st.cache_data when you need per-session mutation isolation and a serialized, evictable cache, and use @pn.cache when you want zero-copy in-process memoization of a read-only reference layer shared across every session in the same process.
Jump to heading Why this matters
A geopandas.read_file call that parses a multi-megabyte GeoPackage or shapefile can dominate a dashboard’s first paint. Both Streamlit and Panel ship a decorator that promises to run that read once and reuse the result, but they implement caching so differently that the “right” choice changes the memory profile, the thread-safety guarantees, and whether one user’s edit silently corrupts another user’s map. This decision sits inside the broader work of @st.cache_data Implementation and the wider discipline of Caching Strategies & Async Performance Tuning. Picking the wrong tool for a GeoDataFrame shows up as either an UnhashableParamError at startup or, worse, a heisenbug where a filtered layer leaks between sessions.
Jump to heading Prerequisites
- Python 3.9+,
geopandas>=0.14,shapely>=2.0(for vectorized WKB access). streamlit>=1.30for@st.cache_data, and/orpanel>=1.3for@pn.cache.- A source layer to cache — the examples read an EPSG:4326 GeoPackage of administrative polygons.
- Familiarity with cache-key design from Query Result Caching.
pip install "geopandas>=0.14" "streamlit>=1.30" "panel>=1.3"
Jump to heading The comparison at a glance
| Dimension | @st.cache_data (Streamlit) | @pn.cache (Panel) |
|---|---|---|
| Storage model | Hashes inputs, serializes output to bytes | Memoizes the live object in process |
| Return on hit | Fresh deserialized copy each time | Shared reference to one object |
Unhashable GeoDataFrame arg | Raises UnhashableParamError; needs hash_funcs or a scalar key | Uses the object directly; no error |
| Mutation isolation | Isolated per hit (copy) | Not isolated — mutation leaks to all |
| TTL support | ttl= (seconds or timedelta) | ttl= (seconds) |
| Max size | max_entries= | max_items= |
| Eviction policy | LRU (oldest entries dropped) | policy="lru", "lfu", or "fifo" |
| Serialization cost | Pickle on write + unpickle on every hit | None on hit (zero-copy) |
| Cross-session sharing | Yes, but each session gets a copy | Yes, one shared object per process |
| Thread safety | Safe: copies avoid shared mutable state | Reads safe; concurrent in-place writes are not |
| Best fit | Session-scoped, mutated, or evictable layers | Large read-only reference layers |
Jump to heading Step-by-step solution
Jump to heading Step 1 — Build one deterministic cache key for both frameworks
A GeoDataFrame is unhashable, and floating-point bounds are fragile hash inputs. Derive a stable scalar key from the layer’s bounding box (rounded), its CRS, and a SHA-256 digest of the geometry WKB. The same key works for either decorator, so you can switch frameworks without re-keying.
import hashlib
import geopandas as gpd
def layer_key(path: str, bbox: tuple[float, float, float, float], crs_epsg: int) -> str:
"""Deterministic cache key: rounded bbox + CRS + SHA-256 of geometry WKB."""
gdf = gpd.read_file(path, bbox=bbox)
wkb_blob = b"".join(gdf.geometry.to_wkb()) # shapely 2.0 vectorized WKB
digest = hashlib.sha256(wkb_blob).hexdigest()[:16]
rounded = tuple(round(v, 6) for v in bbox) # avoid float jitter in the key
return f"{crs_epsg}:{rounded}:{digest}"
# Example: a slice of German admin polygons in WGS 84
BBOX = (9.5, 47.2, 13.9, 50.6) # lon/lat, EPSG:4326
print(layer_key("admin.gpkg", BBOX, 4326))
Rounding the bounds to six decimals (~0.1 m at the equator) stops sub-pixel pan/zoom jitter from producing a fresh key on every rerun. The WKB digest guarantees that if the underlying file changes, the key changes too.
Jump to heading Step 2 — Cache the read with @st.cache_data
Streamlit hashes the function’s arguments to build its key, then pickles the returned GeoDataFrame into an internal store. Pass only hashable scalars; do the actual read_file inside the function so Streamlit never has to hash the frame itself.
import streamlit as st
import geopandas as gpd
@st.cache_data(ttl=3600, max_entries=8, show_spinner="Reading layer…")
def load_layer_streamlit(path: str, bbox: tuple, crs_epsg: int) -> gpd.GeoDataFrame:
"""Streamlit serializes the returned GeoDataFrame; args must be hashable."""
gdf = gpd.read_file(path, bbox=bbox)
return gdf.to_crs(epsg=crs_epsg)
gdf = load_layer_streamlit("admin.gpkg", (9.5, 47.2, 13.9, 50.6), 3857)
st.write(f"{len(gdf)} polygons, CRS {gdf.crs.to_epsg()}")
If you truly must pass an existing GeoDataFrame as an argument, register a hash_funcs entry so Streamlit hashes its WKB instead of erroring:
@st.cache_data(hash_funcs={gpd.GeoDataFrame: lambda g: hashlib.sha256(
b"".join(g.geometry.to_wkb())).hexdigest()})
def summarize(gdf: gpd.GeoDataFrame) -> dict:
return {"count": len(gdf), "area": float(gdf.to_crs(6933).area.sum())}
max_entries=8 caps the LRU store at eight cached slices; the ninth distinct key evicts the least-recently-used entry.
Jump to heading Step 3 — Cache the same read with @pn.cache
Panel’s pn.cache memoizes the object in a process-global dictionary. Nothing is serialized, so a hit returns the identical in-memory frame. Choose an eviction policy and a max_items bound explicitly, because an unbounded cache of large geometries is a slow memory leak.
import panel as pn
import geopandas as gpd
pn.extension()
@pn.cache(ttl=3600, max_items=8, policy="lru")
def load_layer_panel(path: str, bbox: tuple, crs_epsg: int) -> gpd.GeoDataFrame:
"""pn.cache stores the live GeoDataFrame; no serialization on hit."""
gdf = gpd.read_file(path, bbox=bbox)
return gdf.to_crs(epsg=crs_epsg)
gdf = load_layer_panel("admin.gpkg", (9.5, 47.2, 13.9, 50.6), 3857)
# A second call with identical args returns the SAME object (id() matches).
gdf_again = load_layer_panel("admin.gpkg", (9.5, 47.2, 13.9, 50.6), 3857)
assert gdf is gdf_again # shared reference — true for pn.cache
Set policy="lfu" when a few reference layers are hit far more often than the rest (least-frequently-used keeps the hot layers resident); use "lru" when access follows recency, such as a user panning across regions.
Jump to heading Step 4 — Guard the shared reference against mutation
The assert gdf is gdf_again above is the crux of the difference. Because pn.cache returns a shared object, any in-place edit — gdf["score"] = ..., gdf.to_crs(..., inplace=True), gdf.rename(columns=..., inplace=True) — mutates the one cached copy that every session holds. Streamlit hands back a fresh copy per hit, so the same edit is invisible to other sessions. When you cache with pn.cache, treat the result as read-only and copy before mutating:
# Safe pattern with pn.cache: never mutate the shared object in place
base = load_layer_panel("admin.gpkg", (9.5, 47.2, 13.9, 50.6), 3857)
view = base.copy() # detach before per-session edits
view["highlight"] = view["name"].isin(selected_regions)
Jump to heading Verification
Prove the storage-model difference directly: identity holds for pn.cache, and an independent-copy invariant holds for st.cache_data.
# pn.cache returns the same object; mutation leaks without an explicit copy
a = load_layer_panel("admin.gpkg", (9.5, 47.2, 13.9, 50.6), 3857)
b = load_layer_panel("admin.gpkg", (9.5, 47.2, 13.9, 50.6), 3857)
assert a is b, "pn.cache should return a shared reference"
# st.cache_data returns an equal but independent copy each hit
import pandas.testing as pdt
x = load_layer_streamlit("admin.gpkg", (9.5, 47.2, 13.9, 50.6), 3857)
y = load_layer_streamlit("admin.gpkg", (9.5, 47.2, 13.9, 50.6), 3857)
pdt.assert_frame_equal(x, y) # equal values
# x is y is False — Streamlit deserializes a new copy per call
print("Verified: pn.cache shares, st.cache_data copies")
A passing run confirms you understand which knob controls mutation safety. To time the serialization gap, wrap each loader in time.perf_counter() across repeated hits: pn.cache hits stay flat while st.cache_data hits carry a steady unpickle cost proportional to geometry size.
Jump to heading Edge cases and gotchas
- Streamlit copies on every hit. For a 60 MB WKB layer, the per-hit unpickle can cost tens of milliseconds and briefly doubles resident memory while the copy materializes. That is the price of isolation. If the layer is read-only and hit constantly,
pn.cacheavoids it — but see Memory Limit Management before caching very large frames in-process. - pn.cache returns a shared mutable reference. The single most common bug is an
inplace=Trueoperation on apn.cacheresult corrupting the layer for all sessions. Copy first, or wrap the cached read so it returnsgdf.copy()if any caller might mutate. - Hashing floats and geometry. Never key a cache directly on raw
bboxfloats or onhash(gdf). Round the bounds and hash the WKB (Step 1). Two geometrically identical layers with different float noise otherwise produce distinct keys and silently defeat the cache.
Jump to heading FAQ
Can I pass a GeoDataFrame directly as an argument to a cached function?
With @st.cache_data you should not: a GeoDataFrame is unhashable, so Streamlit raises UnhashableParamError unless you register a hash_funcs entry or pass scalar arguments and read inside the function. @pn.cache accepts the object because it memoizes on the argument value, but relying on a mutable frame as a key is fragile. Prefer the deterministic scalar key built from bbox, CRS, and a WKB hash in both frameworks.
Does pn.cache share cached GeoDataFrames across browser sessions?
Yes. pn.cache stores results in a process-global dictionary, so every session served by that Python process shares one cached object. That is efficient for read-only reference layers but dangerous if a callback mutates the frame in place, because the change is visible to all sessions. st.cache_data also shares across sessions but returns a fresh deserialized copy on each hit, so per-session mutations stay isolated.
Which is faster for large geometries?
On a hit, pn.cache is faster because it returns the in-memory object with no serialization, whereas st.cache_data pickles on write and unpickles a copy on every hit. That copy cost buys mutation isolation. If your layer is tens of megabytes of WKB and callbacks never mutate it, pn.cache avoids repeated deserialization; if isolation matters, st.cache_data is the safer default.
Back to @st.cache_data Implementation
Related
- @st.cache_data Implementation — parent guide to serializing and keying cached spatial data in Streamlit
- Query Result Caching — cache-key design and eviction policies for spatial query results
- Memory Limit Management — bounding the footprint of in-process caches holding large GeoDataFrames
- Caching Strategies & Async Performance Tuning — parent section covering the full performance optimization stack