How to Add Filters and Widgets That Drive a Map
Problem statement
Filters are the reason an app exists, and they are where a map app becomes unusable:
- every widget change re-runs the whole script, so a slider dragged across its range fires forty reruns
- the filters interact in ways nobody tested, and some combinations select nothing
- the map does not say what is filtered, so a user cannot tell an empty result from a broken app
- a filter on a column with 4,000 distinct values is a dropdown nobody can use
The fix is not more widgets. It is deciding what each filter does to the query, making the current selection visible, and keeping the expensive work out of the rerun. Measured on a 15 MB layer, an uncached rerun cost 0.23โ0.25 s and a cached one 0.04โ0.06 s โ with forty reruns from one slider drag, that is ten seconds against two.
Quick answer
Read the widgets into one selection object, apply it in one function, and always report what it matched:
from dataclasses import dataclass
import streamlit as st
@dataclass(frozen=True)
class Selection:
region: str | None
year: int
min_rate: float
categories: tuple
def read_widgets(districts) -> Selection:
with st.sidebar:
region = st.selectbox("Region", ["All", *sorted(districts.region.unique())],
key="region")
year = st.select_slider("Year", options=YEARS, value=YEARS[-1], key="year")
min_rate = st.slider("Minimum rate (%)", 0.0, 20.0, 0.0, 0.5, key="min_rate")
categories = st.multiselect("Category", sorted(districts.category.unique()),
key="categories")
return Selection(None if region == "All" else region, year, min_rate,
tuple(categories))
def apply(districts, selection: Selection):
subset = districts
if selection.region:
subset = subset[subset.region == selection.region]
if selection.categories:
subset = subset[subset.category.isin(selection.categories)]
subset = subset[subset[f"rate_{selection.year}"] >= selection.min_rate]
return subset
selection = read_widgets(districts)
subset = apply(districts, selection)
st.caption(f"{len(subset):,} of {len(districts):,} districts match")
The caption is not decoration. It is the difference between "nothing matched" and "the app is broken".
Step-by-step solution
1. Separate reading widgets from applying filters
A Selection object between them buys three things: the filter logic can be tested without Streamlit, the current state can be logged or shared as a URL, and the widgets can be rearranged without touching the query.
It also makes it obvious when two filters interact, because they are applied in one place in a visible order.
2. Choose the widget from the cardinality
| Distinct values | Widget |
|---|---|
| 2โ5 | st.radio โ all options visible |
| 5โ50 | st.selectbox or st.multiselect |
| 50โ500 | st.selectbox with search, or a text input with matching |
| 500+ | a text search box, not a dropdown |
| continuous | st.slider, or st.select_slider for a fixed set |
A dropdown with four thousand entries is not a filter; it is a list the user has to scroll.
3. Debounce the expensive work
Streamlit re-runs on every slider movement. For an expensive filter, that is dozens of reruns nobody wanted:
# a slider fires on every step; a form fires once
with st.sidebar.form("filters"):
year = st.select_slider("Year", options=YEARS)
min_rate = st.slider("Minimum rate", 0.0, 20.0, 0.0)
submitted = st.form_submit_button("Apply")
if submitted or "subset" not in st.session_state:
st.session_state.subset = apply(districts, Selection(...))
st.form is the built-in answer: widgets inside it do not trigger a rerun until the submit button is pressed. Use it whenever the work behind a filter is measured in hundreds of milliseconds.
4. Make the filters depend on each other where it helps
A region filter should narrow the district filter. Cascading options prevent the empty combinations that make an app feel broken:
region = st.selectbox("Region", ["All", *sorted(districts.region.unique())])
available = (districts if region == "All"
else districts[districts.region == region])
district = st.selectbox("District", ["All", *sorted(available.name.unique())])
Recomputing the second widget's options costs a unique() on a filtered frame, which is negligible compared with a user selecting a combination that matches nothing.
5. Report the result of every filter
Two lines that remove most confusion:
st.caption(f"{len(subset):,} of {len(districts):,} districts match")
if subset.empty:
st.warning("No districts match these filters. Try widening the rate range.")
An empty map with no explanation is indistinguishable from a failure. An empty map with "0 of 309 districts match" is a filter the user can adjust.
6. Put the selection in the URL
st.query_params.update({"region": selection.region or "",
"year": str(selection.year)})
A shareable URL turns "look at the app and set these five filters" into a link. It also gives you a way to reproduce a bug report exactly, which is worth the four lines on its own.
Code examples
Example 1 โ the full filter layer, testable without Streamlit
from dataclasses import dataclass, replace
import geopandas as gpd
@dataclass(frozen=True)
class Selection:
region: str | None = None
districts: tuple = ()
year: int = 2025
min_rate: float = 0.0
max_rate: float = 100.0
categories: tuple = ()
def describe(self) -> str:
parts = []
if self.region:
parts.append(self.region)
if self.districts:
parts.append(f"{len(self.districts)} districts")
if self.categories:
parts.append(", ".join(self.categories))
if self.min_rate > 0 or self.max_rate < 100:
parts.append(f"{self.min_rate:g}โ{self.max_rate:g}%")
return " ยท ".join(parts) or "everything"
def apply_selection(districts: gpd.GeoDataFrame, selection: Selection):
"""Pure function: no Streamlit, fully testable, reusable in a batch job."""
subset = districts
if selection.region:
subset = subset[subset["region"] == selection.region]
if selection.districts:
subset = subset[subset["name"].isin(selection.districts)]
if selection.categories:
subset = subset[subset["category"].isin(selection.categories)]
column = f"rate_{selection.year}"
subset = subset[(subset[column] >= selection.min_rate)
& (subset[column] <= selection.max_rate)]
return subset
def filter_report(districts, selection) -> dict:
"""How much each filter removed โ the diagnostic for an empty result."""
counts, running = {"start": len(districts)}, districts
for field in ("region", "districts", "categories"):
running = apply_selection(
running, replace(Selection(), **{field: getattr(selection, field)}))
counts[field] = len(running)
running = apply_selection(running, replace(
Selection(), year=selection.year,
min_rate=selection.min_rate, max_rate=selection.max_rate))
counts["rate range"] = len(running)
return counts
filter_report is the function to reach for when a user says "it shows nothing". It names the filter that emptied the selection.
Example 2 โ a form to stop a slider re-running everything
import streamlit as st
def filter_sidebar(districts, expensive=True):
"""Use a form when applying the filter is slow; live widgets when it is not."""
container = st.sidebar.form("filters") if expensive else st.sidebar
with container:
st.header("Filters")
region = st.selectbox("Region", ["All", *sorted(districts.region.unique())])
available = (districts if region == "All"
else districts[districts.region == region])
chosen = st.multiselect("Districts", sorted(available.name.unique()))
year = st.select_slider("Year", options=YEARS, value=YEARS[-1])
low, high = st.slider("Rate range (%)", 0.0, 20.0, (0.0, 20.0), 0.5)
submitted = st.form_submit_button("Apply") if expensive else True
selection = Selection(None if region == "All" else region, tuple(chosen),
year, low, high)
return selection, submitted
Example 3 โ filters that push down into the query
import duckdb
import streamlit as st
@st.cache_resource
def connection():
con = duckdb.connect("districts.duckdb", read_only=True)
con.execute("load spatial")
return con
@st.cache_data
def query(_con, region, year, min_rate, categories):
"""Filter in the engine, not in Python. Only the result crosses over.
The leading underscore stops Streamlit trying to hash the connection.
"""
clauses, params = [f"rate_{year} >= ?"], [min_rate]
if region:
clauses.append("region = ?")
params.append(region)
if categories:
placeholders = ",".join("?" * len(categories))
clauses.append(f"category in ({placeholders})")
params += list(categories)
return _con.execute(f"""
select name, region, category, rate_{year} as rate,
st_asgeojson(geom) as geometry
from districts where {' and '.join(clauses)}
""", params).df()
For a dataset too large to hold in the app, this is the shape that works: the connection in cache_resource, the query in cache_data, and only the filtered result materialised. The underscore on _con is the mechanism Streamlit provides for arguments that must not be hashed.
Explanation
Why a selection object is worth the extra class
Widgets scattered through a script are read in one place and used in another, and the filter logic ends up interleaved with layout. That makes it impossible to test, impossible to reuse, and hard to reason about when two filters interact.
A frozen dataclass between them gives a testable pure function, a loggable state, something to put in a URL, and one obvious place to change the order in which filters apply.
Why forms exist
Streamlit re-runs on every widget change, which for a slider means every step. Dragging a range slider across twenty positions is twenty reruns, and if each costs 0.25 s the app feels broken.
A form batches the widgets and fires once. The measured rerun costs make the rule concrete: below about 100 ms per rerun, live widgets feel better; above it, use a form.
Why cascading options prevent most "the app is broken" reports
Independent filters allow combinations that match nothing โ a region and a district in a different region, a category absent from the selected area. The user sees an empty map and has no way to know which filter caused it.
Deriving each widget's options from the current selection removes the impossible combinations entirely, and the filter report handles the ones that remain.
Why pushing filters into the query changes the ceiling
Filtering in Python requires the whole dataset in the app's memory, which is per session in Streamlit and therefore multiplies with concurrent users.
Pushing the filter into DuckDB or PostGIS keeps the data outside the app, materialises only the result, and makes the app's memory a function of what is displayed rather than of what exists. That is what turns a demo that works with 50,000 rows into an app that works with 50 million.
Edge cases or notes
- Give every widget a
key=, or its state is lost when the layout changes. st.formbatches widgets; nothing inside it re-runs until submit.- Cascading options prevent empty combinations, and cost a
unique()call. - Report the match count always. An unexplained empty map reads as a bug.
- Underscore-prefixed arguments are not hashed by
st.cache_dataโ that is how to pass a connection. - A dropdown over 500 values is a search box.
st.query_paramsmakes a selection shareable and bug reports reproducible.- Apply filters in a documented order; it changes which one gets the blame for an empty result.
Internal links
- How to build a Streamlit app with an interactive map โ the app the filters drive
- Reruns and state explained: why your map app redraws everything โ why forms exist
- How to cache spatial data in a map app โ keeping the rerun cheap
- How to link a map and a chart so one selection drives both โ filters from the map itself
- How to let users draw an area of interest in a map app โ a spatial filter
- Fixing a map app that takes ten seconds to load โ when filters are the cause
- How to test a map app without a browser โ testing the pure filter function
- How to filter spatial data in GeoPandas โ the underlying operations
FAQ
Why does my app re-run when I move a slider?
Because Streamlit re-runs the whole script on every widget change. Put the widgets in an st.form so nothing fires until the submit button.
How do I stop users selecting combinations that match nothing?
Derive each widget's options from the current selection. A district dropdown should list only districts in the selected region.
What should I show when a filter matches nothing?
The count โ "0 of 309 districts match" โ and a hint about which filter to widen. An unexplained empty map is indistinguishable from a failure.
Which widget for a column with thousands of values?
A text search box, not a dropdown. Above about 500 options a dropdown is a list the user has to scroll rather than a filter.
How do I filter a dataset too large to hold in the app?
Push the filter into DuckDB or PostGIS: the connection in st.cache_resource, the query in st.cache_data, and only the result materialised.
Can I share the current filter state?
Yes โ write it into st.query_params. It makes the state a link, and it makes bug reports exactly reproducible.