Session State Patterns for Spatial Dashboards
Geospatial dashboards push reactive Python frameworks into territory they were not originally designed for. Unlike tabular data apps, a spatial dashboard simultaneously manages map viewport coordinates, zoom levels, active layer lists, temporal sliders, and feature selection sets — all of which must survive reruns, tab switches, and worker restarts without losing context. Because each rerun re-executes the whole script, session state is the only place this map context can live between interactions; it sits one layer below widget lifecycle management and feeds directly into your data flow architecture. When these interdependent variables are handled carelessly, users see jarring map resets mid-analysis, lost filter contexts, or cascading query errors triggered by stale projection data.
This page is the practical implementation guide for session state in Streamlit and Panel spatial dashboards, covering schema design, initialization guards, CRS-aware viewport binding, advanced Redis-backed persistence, and production-scale verification. It is a companion to Core Dashboard Architecture & State Management, which covers the broader architectural layers this state system sits within.
Jump to heading Prerequisites
Before implementing advanced state patterns, confirm your environment meets these requirements:
- Python 3.9+ with
streamlit>=1.28.0orpanel>=1.3.0 - Spatial libraries:
geopandas>=0.14,pyproj>=3.6,shapely>=2.0, and eitherstreamlit-folium>=0.15oripyleafletfor Panel - Reactive execution model: Both Streamlit and Panel re-execute on every interaction. Review Widget Lifecycle Management before building bidirectional bindings — unguarded assignments inside widget callbacks will silently overwrite user-modified state on each rerun.
- CRS fundamentals: Map libraries report viewport coordinates in EPSG:4326 (WGS84 latitude/longitude), but spatial databases and analytical backends often expect EPSG:3857 (Web Mercator) or a local projected CRS. Know your pipeline’s expected CRS before designing the state schema.
- Version-controlled environment: State schemas evolve across dashboard iterations. Track schema changes in version control and include a
schema_versionfield in your state dict from day one.
Jump to heading Core Implementation Workflow
Follow this sequence when architecting session state for a spatial dashboard. Each step builds on the previous one, and skipping ahead causes subtle bugs that surface only under concurrent use or after worker restarts.
Jump to heading Step 1 — Define a Spatial State Schema
Write down every variable that must survive a rerun before writing a single line of initialization code. For a typical spatial dashboard the schema includes:
| Key | Type | Notes |
|---|---|---|
map_center | [float, float] | [lat, lon] in EPSG:4326 |
map_zoom | int | Tile zoom level (0–22) |
active_layers | list[str] | Layer identifiers, not layer objects |
selected_ids | list[str | int] | Feature primary keys only |
temporal_range | tuple[str, str] | ISO 8601 start/end |
filter_criteria | dict | Attribute filters as plain Python types |
bbox_3857 | tuple[float,float,float,float] | Normalized bounding box for backend queries |
schema_version | str | e.g. "1.2" for migration handling |
Critically, do not store raw GeoDataFrame objects, full GeoJSON FeatureCollection blobs, or raster arrays in session state. Persist lightweight identifiers or normalized bounding boxes, and reconstruct geometries on demand from your spatial database or a query result cache.
Use a TypedDict to make the schema inspectable and enforce it in tests:
from typing import TypedDict, Optional
class MapState(TypedDict):
map_center: list[float]
map_zoom: int
active_layers: list[str]
selected_ids: list[int]
temporal_range: tuple[str, str]
filter_criteria: dict
bbox_3857: Optional[tuple[float, float, float, float]]
schema_version: str
SPATIAL_STATE_DEFAULTS: MapState = {
"map_center": [34.0522, -118.2437], # Los Angeles
"map_zoom": 10,
"active_layers": ["parcels", "transit_stops"],
"selected_ids": [],
"temporal_range": ("2024-01-01", "2024-12-31"),
"filter_criteria": {},
"bbox_3857": None,
"schema_version": "1.0",
}
Jump to heading Step 2 — Initialize with Guard Clauses
Use conditional initialization so defaults are applied exactly once per session. Never assign raw defaults unconditionally — doing so overwrites whatever the user has changed every time the script reruns.
import streamlit as st
from copy import deepcopy
def init_spatial_state() -> None:
"""Idempotent initializer — safe to call at the top of every script run."""
if "map_state" not in st.session_state:
st.session_state.map_state = deepcopy(SPATIAL_STATE_DEFAULTS)
elif st.session_state.map_state.get("schema_version") != "1.0":
# Migrate state from a previous schema version without resetting user data
_migrate_state(st.session_state.map_state)
def _migrate_state(state: dict) -> None:
state.setdefault("bbox_3857", None)
state.setdefault("schema_version", "1.0")
init_spatial_state()
For Panel, the equivalent is a param.Parameterized class with watch=True bindings. State should live on a class instance stored in pn.state.cache, not in global module variables, to avoid cross-session contamination in multi-worker deployments.
Jump to heading Step 3 — Bind the Viewport Bidirectionally
Connect map interaction events to the session state dict and read state back when constructing the map. streamlit-folium’s st_folium() returns viewport and click data on each render cycle. Merge this into state with shallow-copy discipline to avoid reference mutation bugs, and always validate incoming coordinates against your application’s geographic bounding box before persisting them.
import folium
from streamlit_folium import st_folium
state = st.session_state.map_state
m = folium.Map(
location=state["map_center"],
zoom_start=state["map_zoom"],
tiles="CartoDB positron",
)
# Add layers according to state
if "parcels" in state["active_layers"]:
folium.GeoJson("/path/to/parcels.geojson", name="parcels").add_to(m)
map_return = st_folium(m, width="100%", height=500, returned_objects=["center", "zoom"])
# Merge viewport changes back into state
if map_return and map_return.get("center"):
new_center = [
map_return["center"]["lat"],
map_return["center"]["lng"],
]
# Guard: only update if within California (example domain bounds)
if 32.0 <= new_center[0] <= 42.0 and -124.5 <= new_center[1] <= -114.0:
state["map_center"] = new_center
state["map_zoom"] = map_return.get("zoom", state["map_zoom"])
Jump to heading Step 4 — Normalize CRS at the State Boundary
A map component reports viewport extents in EPSG:4326, but your spatial database or async data loading layer likely expects EPSG:3857 or a local CRS. Normalize at the state boundary — before data leaves the presentation layer — so every downstream consumer works with a consistent projection.
from pyproj import Transformer
_transformer_4326_to_3857 = Transformer.from_crs(
"EPSG:4326", "EPSG:3857", always_xy=True
)
def bbox_4326_to_3857(
west: float, south: float, east: float, north: float
) -> tuple[float, float, float, float]:
"""Convert a WGS84 bounding box to Web Mercator for backend queries."""
x_min, y_min = _transformer_4326_to_3857.transform(west, south)
x_max, y_max = _transformer_4326_to_3857.transform(east, north)
return (x_min, y_min, x_max, y_max)
# Persist the normalized bbox for backend use
if map_return and map_return.get("bounds"):
bounds = map_return["bounds"]
state["bbox_3857"] = bbox_4326_to_3857(
west=bounds["_southWest"]["lng"],
south=bounds["_southWest"]["lat"],
east=bounds["_northEast"]["lng"],
north=bounds["_northEast"]["lat"],
)
The Transformer object is module-level; constructing it once avoids repeated PROJ metadata lookups, which are measurably slow in tight render loops.
Jump to heading Step 5 — Throttle Backend Triggers
Do not issue a spatial query every time the map moves. Gate the processing layer behind a threshold check: only trigger a new bounding-box query when the viewport has shifted meaningfully. A 5% change in bounding-box area works well in practice for tile-level analytics.
def bbox_area(bbox: tuple) -> float:
x_min, y_min, x_max, y_max = bbox
return (x_max - x_min) * (y_max - y_min)
def should_refresh_query(old_bbox: tuple, new_bbox: tuple, threshold: float = 0.05) -> bool:
if old_bbox is None:
return True
old_area = bbox_area(old_bbox)
if old_area == 0:
return True
change = abs(bbox_area(new_bbox) - old_area) / old_area
return change >= threshold
new_bbox = state["bbox_3857"]
prev_bbox = st.session_state.get("_prev_bbox_3857")
if new_bbox and should_refresh_query(prev_bbox, new_bbox):
features = run_spatial_query(new_bbox) # calls your DB or cache layer
st.session_state["_prev_bbox_3857"] = new_bbox
This pairs naturally with query result caching — the throttle reduces cache misses as well as raw compute.
Jump to heading Advanced Patterns
Jump to heading Redis-Backed State for Production Worker Pools
Default in-memory session dicts vanish when the hosting process recycles. In Dockerized or Kubernetes deployments with multiple Streamlit workers, a user may land on a different worker on reconnect and lose all context. Externalize state to Redis to eliminate this failure mode.
import json
import redis
import streamlit as st
_redis = redis.Redis(host="redis", port=6379, decode_responses=True)
SESSION_TTL_SECONDS = 3600 # 1 hour
def load_state_from_redis(session_id: str) -> dict | None:
raw = _redis.get(f"spatial_session:{session_id}")
return json.loads(raw) if raw else None
def persist_state_to_redis(session_id: str, state: dict) -> None:
# Serialize only JSON-safe types; exclude any accidental GeoDataFrame refs
safe_state = {k: v for k, v in state.items() if isinstance(v, (str, int, float, list, dict, tuple, type(None)))}
_redis.setex(
f"spatial_session:{session_id}",
SESSION_TTL_SECONDS,
json.dumps(safe_state),
)
# At app startup
session_id = st.query_params.get("sid", st.runtime.scriptrunner.get_script_run_ctx().session_id)
if "map_state" not in st.session_state:
restored = load_state_from_redis(session_id)
st.session_state.map_state = restored or deepcopy(SPATIAL_STATE_DEFAULTS)
# At the end of every run
persist_state_to_redis(session_id, st.session_state.map_state)
Set TTL based on expected session duration. Do not store geometries in Redis — keep the payload under 10 KB by persisting only identifiers and normalized bounding boxes.
Jump to heading Cross-Tab Coordination with Namespaced Keys
Each browser tab creates an independent Streamlit session. This isolation is desirable for personal analytical workflows, but enterprise deployments sometimes need shared filter state across tabs for a single analyst. The pattern used in How to manage Streamlit session state across multiple user tabs applies a user_id + workspace_id compound Redis key so that state written in one tab is visible in another when the user explicitly opts in.
def workspace_state_key(user_id: str, workspace_id: str) -> str:
return f"workspace:{user_id}:{workspace_id}"
# Writer (tab that changed the filter)
_redis.setex(
workspace_state_key("u:alice", "ws:parcel-audit"),
SESSION_TTL_SECONDS,
json.dumps(shared_filter_state),
)
# Reader (another tab, same user + workspace)
raw = _redis.get(workspace_state_key("u:alice", "ws:parcel-audit"))
if raw:
st.session_state.map_state["filter_criteria"] = json.loads(raw)["filter_criteria"]
Keep shared state minimal — only filters and layer selections, not viewport coordinates, which are inherently per-tab.
Jump to heading Lazy GeoDataFrame Loading via State-Gated Cache
Never load full GeoDataFrames eagerly at startup. Gate heavy loads behind an explicit user action recorded in session state, and back the loaded result with a function-level cache so reruns within the same session hit memory rather than the database.
import geopandas as gpd
import streamlit as st
@st.cache_data(ttl=600, show_spinner="Loading parcel data…")
def load_parcels(bbox_3857: tuple[float, float, float, float]) -> gpd.GeoDataFrame:
from sqlalchemy import create_engine, text
engine = create_engine("postgresql://user:pass@db:5432/spatial")
query = text("""
SELECT id, apn, zoning, geometry
FROM parcels
WHERE ST_Intersects(
geometry,
ST_MakeEnvelope(:x_min, :y_min, :x_max, :y_max, 3857)
)
""")
return gpd.read_postgis(
query,
engine,
params={"x_min": bbox_3857[0], "y_min": bbox_3857[1],
"x_max": bbox_3857[2], "y_max": bbox_3857[3]},
geom_col="geometry",
crs="EPSG:3857",
)
if st.session_state.map_state.get("bbox_3857") and st.button("Load parcels in view"):
parcels = load_parcels(tuple(st.session_state.map_state["bbox_3857"]))
st.session_state.map_state["selected_ids"] = parcels["id"].tolist()[:500]
The @st.cache_data decorator serializes the GeoDataFrame to Arrow format for storage, avoiding the reference-leakage issues that @st.cache_resource creates with mutable geospatial objects.
Jump to heading Verification & Testing
Jump to heading Assertion-Based State Checks
Add a debug expander in development builds that inspects session state in real time:
if st.secrets.get("DEBUG_MODE"):
with st.expander("Session State Inspector"):
state = st.session_state.map_state
assert isinstance(state["map_center"], list) and len(state["map_center"]) == 2, \
"map_center must be [lat, lon]"
assert 0 <= state["map_zoom"] <= 22, \
f"Zoom {state['map_zoom']} out of range"
assert state["bbox_3857"] is None or len(state["bbox_3857"]) == 4, \
"bbox_3857 must be a 4-tuple or None"
st.json(state)
Jump to heading Memory Profiling Commands
Check session payload size in the terminal using tracemalloc during load testing:
import tracemalloc, json, sys
tracemalloc.start()
snapshot_before = tracemalloc.take_snapshot()
# Simulate a state write
state_payload = json.dumps(st.session_state.map_state)
payload_bytes = sys.getsizeof(state_payload)
print(f"Session state payload: {payload_bytes / 1024:.1f} KB")
# Target: < 50 KB for fast WebSocket serialization
snapshot_after = tracemalloc.take_snapshot()
top_stats = snapshot_after.compare_to(snapshot_before, "lineno")
for stat in top_stats[:5]:
print(stat)
Target payloads under 50 KB. Exceeding 200 KB typically signals that a GeoDataFrame or full geometry collection has leaked into the state dict.
Jump to heading Panel Test Hook
For Panel, parameterized test fixtures check that state updates do not trigger redundant processing callbacks:
import panel as pn
import param
class SpatialDashboard(param.Parameterized):
map_center = param.List(default=[34.0522, -118.2437])
map_zoom = param.Integer(default=10, bounds=(0, 22))
active_layers = param.List(default=["parcels"])
@param.depends("map_center", "map_zoom", watch=True)
def _on_viewport_change(self):
# Only fires when map_center or map_zoom actually changes
self._fetch_tiles()
# Test: changing an unrelated param must not trigger viewport callback
dashboard = SpatialDashboard()
call_count = 0
original = dashboard._on_viewport_change
def counting_callback():
global call_count
call_count += 1
original()
dashboard._on_viewport_change = counting_callback
dashboard.active_layers = ["parcels", "roads"] # should NOT increment call_count
assert call_count == 0, "Viewport callback fired on unrelated param change"
Jump to heading Troubleshooting
Map resets to default center on every interaction
Symptom: st.session_state.map_state["map_center"] always equals the default value even after the user pans the map.
Root cause: The state update code runs after the Folium map object is constructed, so the stale center is passed to folium.Map() on the next run before the new value from st_folium() can be applied.
Fix: Always construct the Folium map object by reading from st.session_state before calling st_folium(). Update state after the call, then let Streamlit rerun naturally on the next interaction.
KeyError: 'map_state' on first page load
Symptom: KeyError: 'map_state' on the first render in a freshly created browser session.
Root cause: The initialization function (init_spatial_state()) is not being called before the first widget that reads state. Streamlit runs top-to-bottom, so any read before the guard clause fires will fail.
Fix: Call init_spatial_state() as the very first statement after imports, before any st. widget or state read.
pyproj CRS transform returns inf or NaN
Symptom: bbox_4326_to_3857() returns (inf, inf, inf, inf) or (nan, nan, nan, nan).
Root cause: The input longitude/latitude values are transposed (lon passed as lat or vice versa), or the bounding box crosses the antimeridian (±180° longitude). The always_xy=True parameter requires (lon, lat) order, not (lat, lon).
Fix: Verify argument order. Pass (west_lon, south_lat, east_lon, north_lat). For antimeridian-crossing viewports, split the bbox at ±180° and issue two separate queries.
State not persisting across worker restarts in production
Symptom: Users lose their map context after a server restart or scale-down event. All state resets to defaults.
Root cause: Default st.session_state is in-process memory. When the worker process exits, state is lost. Sticky session load balancing delays this problem but does not eliminate it.
Fix: Implement the Redis-backed state pattern described in the Advanced Patterns section above. Ensure persist_state_to_redis() is called at the end of every script run, not just on explicit save events.
Race condition: two users' filters merge on a shared cache key
Symptom: User A’s bounding box filter appears in User B’s sidebar without any action from User B.
Root cause: A global @st.cache_data or @st.cache_resource result is being stored under a cache key that does not include the session ID or user ID, causing the backend to return a result set built from a different user’s spatial query.
Fix: Include the bounding box, active layers, and filter criteria as arguments to @st.cache_data-decorated functions so the cache key is unique per query configuration. Never use a session-scoped variable as a cache argument — pass the value explicitly.
Jump to heading Performance Considerations
Payload size: Serialize st.session_state.map_state to JSON periodically and assert it is under 50 KB. Approaching this limit almost always means a geometry or DataFrame reference has leaked in. Payloads above 200 KB will noticeably slow WebSocket round-trips and serialize latency.
Cache key design: Include all variables that affect the output — bbox, CRS code, active layers, filter criteria, and schema version — in the arguments to @st.cache_data. Missing a variable from the key causes stale data to be served after a filter change without any obvious error. Review query result caching strategies for cache key patterns that scale to hundreds of concurrent users.
Async vs sync: @st.cache_data is synchronous. For spatial queries that exceed 500 ms, use asyncio with a background task pattern (see async data loading patterns) to avoid blocking the Streamlit event loop while a PostGIS query runs. State management in async contexts requires explicit locking if multiple coroutines read and write the same keys.
Debounce threshold: The 5% bounding-box area change threshold described in Step 5 works for city-level analytics. For country- or continent-scale maps with sparse data, raise it to 15%. For parcel-level micro-detail, lower it to 2% but pair it with a tile cache to absorb the higher query frequency.
Jump to heading Frequently Asked Questions
Why does my map reset to the default center on every Streamlit rerun?
The map state is not being persisted in st.session_state before the widget renders. Initialize all viewport keys — map_center, map_zoom, active_layers — with guard clauses before calling st_folium(), and always read state from the session dict when constructing the folium.Map() object. Step 2 and Step 3 above show the exact ordering that fixes this.
How do I prevent session state from growing too large with GeoDataFrame data?
Never store raw GeoDataFrame objects or full GeoJSON FeatureCollection blobs in session state. Persist only lightweight identifiers — feature IDs, bounding boxes, CRS codes, and filter predicates — and reconstruct geometries on demand from a query result cache or a spatial database. Profile the JSON payload with tracemalloc and keep it under 50 KB, as shown under Verification & Testing.
Does session state persist when a user opens the same dashboard in two browser tabs?
No. Each browser tab creates an independent session context, so state is isolated per tab by default. If you need coordinated state across tabs, use an external store such as Redis with a namespaced workspace key, but architect it carefully to avoid race conditions on concurrent spatial queries — the full pattern is covered in How to manage Streamlit session state across multiple user tabs.
Back to Core Dashboard Architecture & State Management
Related
- Widget Lifecycle Management — prevent widget re-renders from overwriting session state during mount and teardown
- Data Flow Architectures — decouple view state from query state in reactive spatial pipelines
- Query Result Caching — cache spatial query outputs keyed on bounding box and filter criteria
- How to manage Streamlit session state across multiple user tabs — cross-tab isolation and coordinated workspace state