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

Flow from widgets through a selection object into a pure filter function.
It also makes the filter order visible, which matters when two filters interact.

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.

Grid of value-count ranges against the widget that suits each.
Cascading options stop users choosing combinations that match nothing.

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.

Two panels contrasting live widgets with widgets inside a form.
Below about 100 ms per rerun, live widgets feel better.

Edge cases or notes

  • Give every widget a key=, or its state is lost when the layout changes.
  • st.form batches 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_params makes a selection shareable and bug reports reproducible.
  • Apply filters in a documented order; it changes which one gets the blame for an empty result.

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.