Dynamic Spatial Filtering in Streamlit & Panel Dashboards
Dynamic spatial filtering is the process of reducing large geospatial datasets in real time based on user-driven geographic constraints — viewport bounds, drawn polygons, or proximity radii. Done well, it turns a static map into a responsive analytical workbench: an analyst pans the map, and the table, charts, and downstream layers all re-scope to exactly what is on screen. Done poorly, the same interaction triggers a full national-scale reload on every pixel of a pan gesture, the worker’s memory climbs until it crashes, and the dashboard that passed local smoke tests becomes unusable on real data.
This page is the workflow to master that interaction end to end in Python web frameworks. It covers schema normalization, coordinate-system alignment, R-tree indexing, reactive UI binding, and cached query execution with a graceful fallback path. The patterns apply to both Streamlit and Panel and sit inside the broader Spatial Component Integration & Interactive Maps architecture this site is built around — read that overview first for component lifecycle and rendering context, then return here for the filtering layer.
Jump to heading Prerequisites
- Python 3.9+ in an isolated virtual environment
geopandas >= 0.14,shapely >= 2.0, andpyproj >= 3.4for vector operations and CRS handlingstreamlit >= 1.30(withstreamlit-folium >= 0.17) orpanel >= 1.3pyarrowfor GeoParquet I/O andrtree/libspatialindexfor the spatial index- Working knowledge of the reactive execution model in your framework and the data flow architectures that stage ingestion, transformation, and rendering as a pipeline rather than a linear script
Install the stack:
pip install "geopandas>=0.14" "shapely>=2.0" pyproj streamlit streamlit-folium panel pyarrow rtree
Verify that Shapely is built against GEOS so geometry predicates run in compiled code rather than falling back to pure Python:
import shapely
import geopandas as gpd
print(f"Shapely {shapely.__version__}, GeoPandas {gpd.__version__}")
# Shapely 2.0+ links GEOS directly; PyGEOS is merged in and no longer a separate dependency
Jump to heading Core Implementation Workflow
The workflow is five ordered stages: normalize the data, align the CRS, build the index, bind the filter to the map, then execute and cache. Each stage produces a clean object the next stage can trust.
Jump to heading Step 1 — Ingest and normalize the dataset
Load the dataset and standardize it before anything touches a filter. Type the geometry column explicitly as a GeoSeries, drop null geometries, reset the index so positional lookups stay aligned, and repair invalid topology. Skipping this step is the root cause of most silent filtering failures downstream.
import geopandas as gpd
from pathlib import Path
def load_and_normalize(file_path: Path) -> gpd.GeoDataFrame:
"""Load a spatial file and return a clean, index-aligned GeoDataFrame."""
if file_path.suffix == ".parquet":
gdf = gpd.read_parquet(file_path)
else:
gdf = gpd.read_file(file_path, engine="pyogrio")
gdf = gdf[gdf.geometry.notna()]
gdf = gdf.reset_index(drop=True)
# buffer(0) repairs self-intersections without changing spatial extent
gdf["geometry"] = gdf.geometry.buffer(0)
return gdf
The buffer(0) call is a standard geometric repair that resolves invalid polygon topology — overlapping rings, self-intersections — without altering the footprint. It is essential when working with user-generated or legacy GIS exports, where a single malformed polygon will otherwise raise a TopologicalError mid-query.
Jump to heading Step 2 — Align coordinate reference systems
Dynamic spatial filtering fails silently when the dataset and the filter geometry use mismatched projections. CRS normalization is therefore non-negotiable: web map components emit bounds in EPSG:4326 (WGS84) for display, but any distance-based or area-based predicate needs a projected CRS such as a local UTM zone or EPSG:3857. Transform both the dataset and the filter geometry into a common CRS before running a single spatial operation.
import geopandas as gpd
def align_crs(gdf: gpd.GeoDataFrame, target_crs: str = "EPSG:4326") -> gpd.GeoDataFrame:
if gdf.crs is None:
raise ValueError("Source data lacks a CRS definition. Assign one before filtering.")
target_epsg = int(target_crs.split(":")[1])
if gdf.crs.to_epsg() != target_epsg:
return gdf.to_crs(target_crs)
return gdf
For viewport (bounding-box) filters, keep everything in EPSG:4326. For a “within 5 km of this point” radius filter, reproject to a metric CRS like EPSG:32633 (UTM zone 33N) for the distance calculation, then reproject results back to EPSG:4326 before handing them to the renderer.
| Filter type | Recommended working CRS | Why |
|---|---|---|
| Viewport bounding box | EPSG:4326 | Matches what the map component emits; no reprojection round-trip |
| Drawn polygon containment | EPSG:4326 | Predicate is topological, not metric |
| Proximity radius (metres) | local UTM, e.g. EPSG:32633 | Distances must be computed in a metric, equal-distance projection |
| Area threshold (km²) | local equal-area, e.g. EPSG:3035 | Area in degrees is meaningless; use an equal-area CRS |
Jump to heading Step 3 — Build the spatial index
An R-tree index on the geometry column turns intersection queries from an O(n) linear scan into an O(log n) tree traversal. In GeoPandas the index is built lazily the first time .sindex is accessed and cached on the GeoDataFrame object. Pre-build it explicitly so the first user interaction does not eat the construction latency.
import geopandas as gpd
from shapely.geometry import box
def prepare_spatial_index(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
"""Force R-tree construction up front; the result is cached on gdf."""
_ = gdf.sindex
return gdf
def filter_by_bounds(gdf: gpd.GeoDataFrame, minx: float, miny: float,
maxx: float, maxy: float) -> gpd.GeoDataFrame:
"""Return features intersecting the bbox, using the pre-built sindex."""
bbox = box(minx, miny, maxx, maxy)
# sindex.query returns integer positions (iloc-compatible) and applies
# the predicate in an exact second pass, so no manual false-positive check is needed.
candidate_positions = gdf.sindex.query(bbox, predicate="intersects")
return gdf.iloc[candidate_positions].reset_index(drop=True)
sindex.query(geometry, predicate=...) is the current GeoPandas API (0.12+). The older sindex.intersection(bbox.bounds) returns candidate positions from an envelope-only first pass and forces you to follow up with an exact candidates.intersects(bbox) filter to drop false positives. The predicate= form folds that exact pass into the call.
Jump to heading Step 4 — Bind the filter to map interactions
Attach a callback to the map that extracts viewport bounds or a drawn geometry and routes them into the filter. In Streamlit, streamlit-folium returns interaction state that you read out of st.session_state; in Panel, param plus pn.bind builds a reactive pipeline that fires the query whenever map state changes. Either way, decouple the UI event from the heavy computation so a fast pan does not queue a backlog of queries.
For Leaflet-based maps, configure the event listeners and debounce as described in Folium & Leafmap Integration. If the dashboard needs WebGL rendering for millions of points, pair this server-side filter with the GPU aggregation patterns in Deck.gl Advanced Layers, and route map clicks into the filter using Tooltip & Click Event Handling.
Streamlit
import streamlit as st
import folium
from streamlit_folium import st_folium
m = folium.Map(location=[40.7128, -74.0060], zoom_start=12)
map_data = st_folium(m, width=800, height=500, returned_objects=["bounds"])
if map_data and map_data.get("bounds"):
b = map_data["bounds"]
filtered_gdf = filter_by_bounds(
prepared_gdf,
b["_southWest"]["lng"], b["_southWest"]["lat"],
b["_northEast"]["lng"], b["_northEast"]["lat"],
)
st.dataframe(filtered_gdf.drop(columns=["geometry"]).head())
Panel
import panel as pn
import param
import geopandas as gpd
class SpatialFilterApp(param.Parameterized):
bounds = param.Dict(default={})
def __init__(self, gdf, **params):
super().__init__(**params)
self.gdf = prepare_spatial_index(gdf)
self.filtered_table = pn.pane.DataFrame(gpd.GeoDataFrame())
@param.depends("bounds", watch=True)
def update_filter(self):
if not self.bounds:
return
b = self.bounds
filtered = filter_by_bounds(self.gdf, b["minx"], b["miny"], b["maxx"], b["maxy"])
self.filtered_table.object = filtered.drop(columns=["geometry"])
def view(self):
return pn.Column(self.filtered_table)
Panel’s @param.depends(..., watch=True) gives fine-grained dependency tracking, so the filter fires only when bounds changes and not when an unrelated widget updates. Keeping that isolation is part of widget lifecycle management — without it, a sidebar slider can trigger a redundant spatial query on every move.
Jump to heading Step 5 — Execute, cache, and degrade gracefully
The query itself must never block the main thread. Wrap it in framework-native caching keyed by the bounds and a dataset version so identical viewports reuse a result, and design a fallback path for when the client-side map fails to initialize.
import streamlit as st
import geopandas as gpd
@st.cache_data(ttl=300, max_entries=100, show_spinner=False)
def cached_spatial_query(minx: float, miny: float, maxx: float, maxy: float,
dataset_version: int) -> gpd.GeoDataFrame:
"""Cache spatial filter results keyed by bounds and dataset version."""
return filter_by_bounds(prepared_gdf, minx, miny, maxx, maxy)
Including dataset_version in the cache key forces a clean miss when the underlying data changes, so you never have to flush the whole cache on an upstream update. The TTL and key-design trade-offs are covered in depth under query result caching.
When client-side rendering fails — browser WebGL restrictions, hardware-acceleration limits, or a network timeout — server-side filtering should still return something useful. Implement fallback static maps when WebGL fails so users get a pre-rendered PNG or a simplified GeoJSON summary instead of a blank canvas.
Jump to heading Advanced Patterns
Jump to heading Quantized cache keys for smooth panning
Raw float bounds change on every pan event, so a cache keyed on exact bounds almost never hits. Quantize the bounds to a fixed precision — round to 3–4 decimal places, roughly 10 m at the equator — before forming the key. Slight viewport drift then maps to the same key and reuses the cached subset, cutting redundant queries during a continuous pan.
def quantize_bounds(minx, miny, maxx, maxy, precision: int = 3) -> tuple:
return (round(minx, precision), round(miny, precision),
round(maxx, precision), round(maxy, precision))
# key = quantize_bounds(*raw_bounds) + (dataset_version,)
Jump to heading Lazy loading from PostGIS per viewport
For datasets that exceed available RAM, do not load the whole layer. Stream only the current viewport extent from PostGIS with geopandas.read_postgis() and a WHERE ST_Intersects(geom, ST_MakeEnvelope(:minx, :miny, :maxx, :maxy, 4326)) clause, then cache each chunk under its quantized bbox. The database performs the spatial filter using its own GiST index, and the dashboard holds only what is on screen.
import geopandas as gpd
from sqlalchemy import create_engine, text
engine = create_engine("postgresql://reader@db/spatial")
def load_viewport_chunk(minx, miny, maxx, maxy) -> gpd.GeoDataFrame:
sql = text("""
SELECT id, category, geom
FROM features
WHERE ST_Intersects(geom, ST_MakeEnvelope(:minx, :miny, :maxx, :maxy, 4326))
""")
return gpd.read_postgis(
sql, engine, geom_col="geom",
params={"minx": minx, "miny": miny, "maxx": maxx, "maxy": maxy},
)
Parallelizing several such fetches across endpoints is covered under async data loading patterns.
Jump to heading Compound spatial + attribute filtering
Real dashboards combine a spatial constraint with attribute predicates — a category dropdown, a date slider. Keep the two passes separate: run the spatial sindex query first to shrink the candidate set, then apply attribute predicates on the much smaller subset. This lets you cache the spatial result independently of transient attribute choices, so toggling a category does not re-run the bounding-box query.
def filter_compound(gdf, bbox_tuple, category=None, after=None):
subset = filter_by_bounds(gdf, *bbox_tuple) # spatial pass first
if category:
subset = subset[subset["category"] == category] # cheap attribute pass
if after:
subset = subset[subset["timestamp"] >= after]
return subset.reset_index(drop=True)
Jump to heading Verification and Testing
Confirm each stage behaves before wiring it into a live dashboard. Build a tiny GeoDataFrame with known coordinates so the expected result is obvious.
import geopandas as gpd
from shapely.geometry import Point
def _make_test_gdf() -> gpd.GeoDataFrame:
"""Four points across lower Manhattan, EPSG:4326."""
return gpd.GeoDataFrame(
{"name": ["A", "B", "C", "D"], "category": ["park", "road", "park", "road"]},
geometry=[Point(-74.01, 40.70), Point(-74.00, 40.71),
Point(-73.99, 40.72), Point(-73.98, 40.73)],
crs="EPSG:4326",
)
def test_bounds_filter_returns_subset():
gdf = prepare_spatial_index(_make_test_gdf())
# bbox covering A, B, C but not D
result = filter_by_bounds(gdf, -74.02, 40.69, -73.985, 40.725)
assert len(result) == 3, f"expected 3 features, got {len(result)}"
assert set(result["name"]) == {"A", "B", "C"}
def test_sindex_is_prebuilt():
gdf = prepare_spatial_index(_make_test_gdf())
assert gdf.sindex is not None
def test_crs_alignment_reprojects():
gdf = _make_test_gdf().to_crs("EPSG:3857")
aligned = align_crs(gdf, "EPSG:4326")
assert aligned.crs.to_epsg() == 4326
For memory behaviour, run tracemalloc (or memory_profiler) around the query with a production-scale layer of 100k+ features. If resident memory grows more than ~200 MB beyond baseline after filtering, the cached object is still holding attribute columns that should have been stripped at ingestion. Worker sizing for concurrent cached layers is covered under memory limit management.
Jump to heading Troubleshooting
Viewport queries return zero results despite visible features on the map
A CRS mismatch is the usual cause: the bbox arriving from the map is in EPSG:4326 (longitude, latitude) while the GeoDataFrame is in a projected CRS like EPSG:3857. Confirm gdf.crs.to_epsg() == 4326 before calling sindex.query, or reproject the bbox into the GeoDataFrame’s CRS with pyproj.Transformer. A second cause is swapped axis order — passing (lat, lon) where box() expects (minx, miny, maxx, maxy), i.e. (lon, lat).
TopologicalError: the operation 'GEOSIntersects_r' could not be performed
A geometry that failed the validity check slipped past ingestion. Repair the layer with gdf["geometry"] = gdf.geometry.buffer(0) in Step 1, and inspect offenders with shapely.validation.explain_validity(g) on the rows where ~gdf.geometry.is_valid.
AttributeError: 'GeoDataFrame' object has no attribute 'sindex' or rebuilt on every rerun
The first form means an older GeoPandas; upgrade to >= 0.12. The “rebuilt every rerun” symptom means the indexed GeoDataFrame is being recreated on each Streamlit run — wrap load_and_normalize and prepare_spatial_index in @st.cache_resource so the object, and its cached sindex, persist across reruns.
The map fires dozens of queries during a single pan or zoom
Viewport change events are not debounced. Attach a 300–500 ms debounce so only the final bounds trigger a query, and quantize the bounds in the cache key so near-identical viewports reuse a cached result instead of recomputing.
Pure-Python geometry operations are unexpectedly slow in a container
The image is missing the GEOS / libspatialindex system libraries, so Shapely silently falls back to slower code paths. Alpine-based images strip these by default — install libgeos-dev and libspatialindex-dev (or use a Debian-slim base) and confirm shapely.geos_version is populated.
Jump to heading Performance Considerations
Filtering performance is governed by three levers: how big the returned payload is, how well the cache key is designed, and whether the query runs sync or async.
| Returned feature count | Recommended approach |
|---|---|
| < 10k | Filter in-process; return the full subset to the client |
| 10k – 50k | Cap client-side rendering; strip attribute columns before transmission |
| 50k – 500k | Server-side clustering or hexbin aggregation before sending |
| > 500k | Serve vector tiles (MVT) from a tile pyramid; never push raw GeoJSON |
- Payload thresholds. Cap client-rendered result sets at 10k–50k features. Beyond that, aggregate server-side — a hexbin or cluster summary keeps the map responsive while preserving the spatial story.
- Cache key design. Key on quantized bounds plus a dataset version (or ETag). Quantization raises the hit rate during panning; the version segment auto-invalidates on upstream data changes without a manual flush.
- Sync vs async. A single bounding-box query against an in-memory
sindexis fast enough to run synchronously. Once the filter triggers network I/O — a PostGIS round-trip or a remote tile fetch — move it off the event loop with the async data loading patterns, and debounce so only the settled viewport hits the database. - Format. Prefer GeoParquet over GeoJSON for the source: it preserves columnar compression and reads 60–80% faster than text formats, which shortens cold-start time for the first filter.
Back to Spatial Component Integration & Interactive Maps
Related