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.


Spatial Session State Data-FlowThree horizontal layers — Presentation, State, Processing — connected by arrows. User interactions on the Presentation layer (map pan, layer toggle, filter change) feed into the State layer (session dict with viewport, layers, filters). The State layer triggers selective updates to the Processing layer (spatial query, CRS transform, GeoDataFrame slice) only when thresholds are crossed.PRESENTATIONSTATEPROCESSINGMap canvasLayer togglesTemporal sliderFeature selectionviewport + zoomactive_layerstemporal_rangeselected_idsCRS transform / bboxSpatial query / joinGeoDataFrame sliceimmediate updatethreshold-gated (debounced)

Jump to heading Prerequisites

Before implementing advanced state patterns, confirm your environment meets these requirements:

  • Python 3.9+ with streamlit>=1.28.0 or panel>=1.3.0
  • Spatial libraries: geopandas>=0.14, pyproj>=3.6, shapely>=2.0, and either streamlit-folium>=0.15 or ipyleaflet for 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_version field 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:

KeyTypeNotes
map_center[float, float][lat, lon] in EPSG:4326
map_zoomintTile zoom level (0–22)
active_layerslist[str]Layer identifiers, not layer objects
selected_idslist[str | int]Feature primary keys only
temporal_rangetuple[str, str]ISO 8601 start/end
filter_criteriadictAttribute filters as plain Python types
bbox_3857tuple[float,float,float,float]Normalized bounding box for backend queries
schema_versionstre.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:

python
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.

python
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.

python
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.

python
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.

python
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.

Viewport Throttle State MachineFour states connected by transitions. Idle moves to Panning on each map-move event. When panning ends the machine enters Evaluate threshold, which compares the new bounding-box area against the stored one. If the change is below five percent it returns to Idle and no query runs. If the change is five percent or greater it advances to Querying, which runs the spatial query, writes _prev_bbox_3857, and returns to Idle.IdlePanningEvaluatethresholdQueryingmap movepan endarea change < 5% — skip querychange ≥ 5%run query · write _prev_bbox_3857

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.

python
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.

python
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.

python
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:

python
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:

python
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:

python
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