How to Add Filters and Widgets That Drive a Map

On this page

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 setting three filters is three full 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 1,332 distinct values β€” Europe's NUTS 3 region names β€” 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. How much a rerun costs decides the rest: on Europe's 1,333 NUTS 3 regions, three live filter changes took 0.07 s in all, and a form made it one rerun instead of three (Example 2) β€” while on a map app that rebuilds its layer, one rerun measured 0.81 s.

Quick answer

Read the widgets into one selection object, apply it in one function, and always report what it matched:

%%writefile quick_app.py
from dataclasses import dataclass

import geopandas as gpd
import streamlit as st

YEARS = [2019, 2020, 2021, 2022, 2023]


@dataclass(frozen=True)
class Selection:
    country: str | None
    year: int
    min_rate: float
    categories: tuple


@st.cache_data
def load(path):
    return gpd.read_file(path)


def read_widgets(districts) -> Selection:
    with st.sidebar:
        country = st.selectbox("Country", ["All", *sorted(districts.country.unique())],
                               key="country")
        year = st.select_slider("Year", options=YEARS, value=YEARS[-1], key="year")
        min_rate = st.slider("Minimum share aged 65+ (%)", 0.0, 40.0, 0.0, 0.5, key="min_rate")
        categories = st.multiselect("Category", sorted(districts.category.unique()),
                                    key="categories")
    return Selection(None if country == "All" else country, year, min_rate,
                     tuple(categories))


def apply(districts, selection: Selection):
    subset = districts
    if selection.country:
        subset = subset[subset.country == selection.country]
    if selection.categories:
        subset = subset[subset.category.isin(selection.categories)]
    subset = subset[subset[f"rate_{selection.year}"] >= selection.min_rate]
    return subset


districts = load("districts.gpkg")
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".

Reading widgets into one selection object and applying it in one place. widgets in, selection through, result outwidgetssidebar controlsSelectiona frozen dataclassapply_selection()pure, testablemap, chart, exportall from one resultThe pure function is testable without Streamlit and reusable in a batch job spatialworkflow.io
It also makes the filter order visible, which matters when two filters interact.

What you get β€” from The data used on this page

1,333 NUTS 3 regions in 36 countries; share aged 65+ in 2023: 2.7–36.0%
{'intermediate': 619, 'rural': 438, 'urban': 276} 1,332 distinct names

Read widgets once, choose by cardinality, debounce

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 widget change β€” once per slider release, measured in a browser on the reruns page, and once for every other filter touched after it. For an expensive filter, that is several reruns nobody wanted:

# live widgets rerun on every change; a form reruns once, on submit
with st.sidebar.form("filters"):
    year = st.select_slider("Year", options=YEARS)
    min_rate = st.slider("Minimum share aged 65+", 0.0, 40.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:

country = st.selectbox("Country", ["All", *sorted(districts.country.unique())])
available = (districts if country == "All"
             else districts[districts.country == country])
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 range.")

An empty map with no explanation is indistinguishable from a failure. An empty map with "0 of 1,333 districts match" is a filter the user can adjust.

6. Put the selection in the URL

st.query_params.update({"country": selection.country 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.

Which widget suits which number of distinct values. the widget follows the cardinalitywidget2–5βœ“radio β€” all options visible5–50βœ“selectbox / multiselect50–500selectbox with search500+!a text search box, not a dropdowncontinuousβœ“slider or select_sliderA dropdown with four thousand entries is a list to scroll, not a filter spatialworkflow.io
Cascading options stop users choosing combinations that match nothing.

A testable filter layer over European regions

The data used on this page

The "districts" are Europe's NUTS 3 regions β€” 1,333 of them once joined to data β€” from Eurostat GISCO at 1:1 million, with GISCO's urban–rural typology as the category, and the share of the population aged 65 and over for 2019 to 2023 from the Eurostat table demo_r_pjanind3 as rate_2019 … rate_2023. Eurostat refuses the default python-requests user agent, hence the header.

import math
import os

import geopandas as gpd
import pandas as pd
import requests

YEARS = [2019, 2020, 2021, 2022, 2023]


def eurostat(table, **filters):
    """A Eurostat table as a Series indexed by every dimension."""
    stat = requests.get(f"https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/{table}",
                        params={"format": "JSON", **filters},
                        headers={"User-Agent": "spatialworkflow.io guide"}, timeout=120).json()
    codes = [sorted(stat["dimension"][d]["category"]["index"], key=stat["dimension"][d]["category"]["index"].get)
             for d in stat["id"]]
    values = [stat["value"].get(str(i)) for i in range(math.prod(stat["size"]))]
    return pd.Series(values, index=pd.MultiIndex.from_product(codes, names=stat["id"]), dtype=float)


if not os.path.exists("districts.gpkg"):
    nuts = gpd.read_file("https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/"
                         "NUTS_RG_01M_2021_4326_LEVL_3.geojson")
    older = (eurostat("demo_r_pjanind3", indic_de="PC_Y65_MAX", unit="PC",
                      sinceTimePeriod=YEARS[0], untilTimePeriod=YEARS[-1])
             .droplevel(["freq", "indic_de", "unit"]).unstack("time"))
    older.columns = [f"rate_{year}" for year in older.columns]
    kind = {1: "urban", 2: "intermediate", 3: "rural"}
    (nuts.assign(category=nuts["URBN_TYPE"].map(kind))
         .rename(columns={"NUTS_ID": "code", "CNTR_CODE": "country", "NAME_LATN": "name"})
         [["code", "country", "name", "category", "geometry"]]
         .merge(older, left_on="code", right_index=True)
         .dropna(subset=[f"rate_{YEARS[-1]}"])
         .to_file("districts.gpkg"))

districts = gpd.read_file("districts.gpkg")
print(f"{len(districts):,} NUTS 3 regions in {districts.country.nunique()} countries; "
      f"share aged 65+ in {YEARS[-1]}: {districts[f'rate_{YEARS[-1]}'].min():.1f}–"
      f"{districts[f'rate_{YEARS[-1]}'].max():.1f}%")
print(districts.category.value_counts().to_dict(),
      f"{districts.name.nunique():,} distinct names")
1,333 NUTS 3 regions in 36 countries; share aged 65+ in 2023: 2.7–36.0%
{'intermediate': 619, 'rural': 438, 'urban': 276} 1,332 distinct names

Example 1 β€” the full filter layer, testable without Streamlit

%%writefile filters.py
from dataclasses import dataclass, replace

import geopandas as gpd


@dataclass(frozen=True)
class Selection:
    country: str | None = None
    districts: tuple = ()
    year: int = 2023
    min_rate: float = 0.0
    max_rate: float = 40.0      # the slider's upper end, so a full range is no filter
    categories: tuple = ()

    def describe(self) -> str:
        parts = []
        if self.country:
            parts.append(self.country)
        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 < 40:
            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.country:
        subset = subset[subset["country"] == selection.country]
    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 ("country", "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:

from filters import Selection, apply_selection, filter_report

for chosen in (Selection(country="ES", categories=("urban",), min_rate=20),
               Selection(country="ES", categories=("urban",), min_rate=25)):
    print(f"{chosen.describe():24} {len(apply_selection(districts, chosen)):3} match   "
          f"{filter_report(districts, chosen)}")
ES Β· urban Β· 20–40%        6 match   {'start': 1333, 'country': 59, 'districts': 59, 'categories': 17, 'rate range': 6}
ES Β· urban Β· 25–40%        0 match   {'start': 1333, 'country': 59, 'districts': 59, 'categories': 17, 'rate range': 0}

Spain has 17 urban NUTS 3 regions. Six had 20% or more of their population aged 65 and over in 2023 and none reached 25%, so the second selection is empty β€” and the report shows the rate range, not the country or the category, is what emptied it.

Europe's 1,333 NUTS 3 regions narrowed by country, category and rate: 59 in Spain, 17 of them urban, 6 with a 65+ share of 20% or more, none at 25%. which filter emptied the map?all NUTS 3 regions1,333country filterΓ· 23country = ES59category filterΓ· 3.5category = urban17rate rangeΓ· 2.8aged 65+ β‰₯ 20%6band width is log-scaledRaise the rate floor to 25% and the last stage is 0: the rate range, not the country, emptied it spatialworkflow.io
filter_report on real data: each stage is one filter, so an empty result names its cause.

Example 2 β€” a form to stop the filters re-running everything

%%writefile form_app.py
import geopandas as gpd
import streamlit as st

from filters import Selection, apply_selection

YEARS = [2019, 2020, 2021, 2022, 2023]
EXPENSIVE = True


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")
        country = st.selectbox("Country", ["All", *sorted(districts.country.unique())],
                               key="country")
        available = (districts if country == "All"
                     else districts[districts.country == country])
        chosen = st.multiselect("Districts", sorted(available.name.unique()), key="chosen")
        year = st.select_slider("Year", options=YEARS, value=YEARS[-1], key="year")
        low, high = st.slider("Share aged 65+ (%)", 0.0, 40.0, (0.0, 40.0), 0.5, key="range")
        submitted = st.form_submit_button("Apply") if expensive else True

    selection = Selection(None if country == "All" else country, tuple(chosen),
                          year, low, high)
    return selection, submitted


@st.cache_data
def load(path):
    return gpd.read_file(path)


districts = load("districts.gpkg")
selection, submitted = filter_sidebar(districts, expensive=EXPENSIVE)
subset = apply_selection(districts, selection)
st.caption(f"{len(subset):,} of {len(districts):,} districts match: {selection.describe()}")

Three filter changes in the sidebar, run headlessly with AppTest β€” first as live widgets, then inside the form:

import time

from streamlit.testing.v1 import AppTest

with open("live_app.py", "w") as out:
    out.write(open("form_app.py").read().replace("EXPENSIVE = True", "EXPENSIVE = False"))

for name in ("quick_app.py", "live_app.py", "form_app.py"):
    at = AppTest.from_file(name, default_timeout=120).run()
    print(f"{name:13} first run: {at.caption[0].value}")

for name in ("live_app.py", "form_app.py"):
    at = AppTest.from_file(name, default_timeout=120).run()
    started, runs = time.perf_counter(), 0
    changes = [lambda: at.selectbox(key="country").set_value("PT"),
               lambda: at.select_slider(key="year").set_value(2019),
               lambda: at.slider(key="range").set_value((25.0, 40.0))]
    for change in changes:
        change()
        if name == "live_app.py":          # every live widget change is a rerun
            at.run()
            runs += 1
    if name == "form_app.py":              # the form reruns once, on submit
        at.button[0].click().run()
        runs += 1
    print(f"{name:13} {runs} rerun(s), {time.perf_counter() - started:.2f} s: {at.caption[0].value}")
quick_app.py  first run: 1,333 of 1,333 districts match
live_app.py   first run: 1,333 of 1,333 districts match: everything
form_app.py   first run: 1,333 of 1,333 districts match: everything
live_app.py   3 rerun(s), 0.07 s: 14 of 1,333 districts match: PT Β· 25–40%
form_app.py   1 rerun(s), 0.02 s: 14 of 1,333 districts match: PT Β· 25–40%

Both reach the same 14 regions. The live widgets paid for three reruns and the form for one; at about 20 ms a rerun on this data the difference is invisible, and the form earns its place only when a rerun is expensive.

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, country, 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 country:
        clauses.append("country = ?")
        params.append(country)
    if categories:
        placeholders = ",".join("?" * len(categories))
        clauses.append(f"category in ({placeholders})")
        params += list(categories)

    return _con.execute(f"""
        select name, country, category, rate_{year} as rate,
               st_asgeojson(geom) as geometry
        from districts where {' and '.join(clauses)}
    """, params).df()

The database is the same GeoPackage loaded once into DuckDB. Streamlit's caches work outside a running app too, which is enough to try the query:

if not os.path.exists("districts.duckdb"):
    with duckdb.connect("districts.duckdb") as build:
        build.execute("install spatial; load spatial")
        build.execute("create table districts as select * from st_read('districts.gpkg')")

rows = query(connection(), "PT", 2023, 25.0, ("rural",))
print(f"{len(rows)} rows, {rows.geometry.str.len().sum() / 1e3:.0f} kB of geometry")
print(rows[["name", "category", "rate"]].sort_values("rate", ascending=False).head(3).to_string(index=False))
15 rows, 207 kB of geometry
                    name category  rate
             Alto TΓ’mega    rural  36.0
Terras de TrΓ‘s-os-Montes    rural  34.3
             Beira Baixa    rural  33.5

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.

Selection objects, forms and cascading options

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. Three filters set one after another are three reruns: measured on this page's 1,333 regions, 0.07 s in all, which nobody notices. The same three changes on the Streamlit map app, where a rerun rebuilding the layer measured 0.81 s, cost about two and a half seconds of waiting.

A form batches the widgets and fires once β€” one rerun instead of three in Example 2. As a rule of thumb, 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.

A slider without a form fires a rerun on every step. twenty reruns, or onelive widgetsβœ•a drag across 20 positions = 20 rerunsβœ•at 0.25 s each, five secondsβœ•the app feels brokenβœ•fine only when reruns are cheapinside st.formβœ“nothing fires until submitβœ“one rerun for any number of changesβœ“the user batches their intentβœ“use it above ~100 ms per rerunStreamlit re-runs on every widget change β€” that is the model, not a bug spatialworkflow.io
Below about 100 ms per rerun, live widgets feel better.

Widget keys, forms and match counts

  • 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 1,333 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.