How to Build a Streamlit App with an Interactive Map

On this page

Problem statement

A Streamlit map app is twenty lines and then a series of surprises:

  • every widget change re-reads the file, because the whole script runs again
  • the map resets its zoom on every interaction, so the user cannot pan and filter
  • the app is fine with 5,000 features and unusable with 50,000
  • the drawing the user made disappears the moment they touch a slider
  • a cached function that takes a GeoDataFrame stops the app, because Streamlit cannot hash one

None of these are bugs in Streamlit. They follow from its execution model β€” the script runs top to bottom on every interaction β€” and each has a specific, small fix.

Measured headlessly on this page's app β€” Europe's 277 NUTS 2 regions from a 6.7 MB GeoPackage β€” caching took a rerun from 1.06 s to 0.81 s for a new slider value, and to 0.37 s for a value already seen. The cache fixes the reading; what is left is the layer, 13.12 MB of GeoJSON rebuilt and sent for every new value.

Quick answer

%%writefile quick_app.py
import geopandas as gpd
import pydeck as pdk
import streamlit as st

st.set_page_config(page_title="Unemployment by region", layout="wide")


@st.cache_data(show_spinner="Loading regions…")
def load_districts(path: str) -> gpd.GeoDataFrame:
    gdf = gpd.read_file(path).to_crs(4326)
    gdf["geometry"] = gdf.geometry.simplify(0.01, preserve_topology=True)
    return gdf


districts = load_districts("districts.gpkg")
if "view" not in st.session_state:
    st.session_state.view = pdk.ViewState(latitude=52.0, longitude=10.0, zoom=3)

with st.sidebar:
    country = st.selectbox("Country", ["All", *sorted(districts.country.unique())], key="country")
    threshold = st.slider("Highlight above (%)", 0.0, 30.0, 8.0, 0.5, key="threshold")

subset = districts if country == "All" else districts[districts.country == country]
subset = subset.assign(above=subset["rate"] > threshold)

left, right = st.columns([3, 1])
with left:
    layer = pdk.Layer("GeoJsonLayer", subset.__geo_interface__, pickable=True,
                      get_fill_color="properties.above ? [239, 68, 68, 160] : [14, 165, 233, 120]")
    st.pydeck_chart(pdk.Deck(layers=[layer], initial_view_state=st.session_state.view),
                    width="stretch")
with right:
    st.metric("Regions", len(subset))
    st.metric("Above threshold", int(subset["above"].sum()))
    st.download_button("Download CSV", subset.drop(columns="geometry").to_csv(index=False),
                       "selection.csv", "text/csv")

Two decisions carry most of the quality: @st.cache_data on the loader, and a view state that does not reset.

The structure of a Streamlit map app that behaves well. six decisions, in order1cache the loader@st.cache_data2keep the view in session_stateor the map resets3choose the component by feature countpydeck, folium, tiles4filter server-sidesend only the result5layout="wide", sidebar for controlsmaps want the width6give every widget a keystate survives layout changesThe first two carry most of the quality spatialworkflow.io
Everything else is styling.

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

277 NUTS 2 regions in 33 countries, unemployment 1.7–30.0% (2023); districts.gpkg is 6.7 MB

Cache the load, hold the view, pick the component, filter

1. Cache the loading, and cache it correctly

@st.cache_data                 # data: hashed by arguments, returns a copy
def load(path): ...

@st.cache_resource             # connections, models: one shared instance
def connect(dsn): ...

cache_data is for values β€” DataFrames, GeoDataFrames, arrays. It serialises the return value and hands back a copy, so mutating the result does not corrupt the cache.

cache_resource is for things that must not be copied: a database connection, a loaded model, a DuckDB handle. Use it for exactly those and nothing else.

2. Keep the map's view state out of the rerun

The commonest complaint about Streamlit maps is that the view resets. It resets because a new deck object is built on every rerun with a fresh initial view state.

if "view" not in st.session_state:
    st.session_state.view = pdk.ViewState(latitude=52.0, longitude=10.0, zoom=3)

deck = pdk.Deck(layers=layers, initial_view_state=st.session_state.view)

Storing the view in session_state keeps it across reruns. It is the difference between an app that can be used and one that fights the user.

3. Choose the map component from the feature count

folium markers      up to a few thousand      481 bytes of HTML per point
folium GeoJson      tens of thousands         50k points = 6.23 MB page
pydeck              hundreds of thousands     50k points = 3.84 MB page
tiles               anything larger           fixed cost per tile

st.map is fine for a quick scatter of points and has almost no styling. st.pydeck_chart is the workhorse. streamlit-folium is right when you need Leaflet plugins β€” drawing tools, in particular β€” and its markers are the heaviest payload of all: 50,000 of them took 7.0 s just to render to HTML (Example 3).

4. Filter server-side, send the result

The temptation is to send everything and filter in the browser. The measured cost of sending everything: 50,000 real GPS points are 6.23 MB as one folium GeoJson layer and 24.04 MB as folium markers, and the 277 region outlines are 13.12 MB of GeoJSON even simplified at 0.001Β°.

Filtering in Python and sending only the result keeps the payload proportional to what is displayed, which is the whole reason the app has a server.

5. Lay it out with columns and a sidebar

st.set_page_config(layout="wide")           # maps want the width
with st.sidebar:
    ...                                     # controls
left, right = st.columns([3, 1])            # map, then numbers

layout="wide" is the single most effective line in a Streamlit map app: the default centred column wastes most of the screen on a map.

6. Give every widget a stable key

country = st.selectbox("Country", options, key="country")

Keys tie a widget to a slot in session_state. Without them, a widget that moves between layout branches loses its value, and two widgets with the same label collide.

Which Streamlit cache decorator to use for which return value. two decorators, not interchangeabledecoratorbehaviourDataFrame / GeoDataFrameβœ“cache_dataserialised, a copy per callerdatabase connectioncache_resourceone object, shared by all sessionsML modelcache_resourceexpensive, large, read-onlyopen file handlecache_resourcenot copyablecache_data on a connection raises; cache_resource on a DataFrame shares mutations spatialworkflow.io
The copy is what makes cache_data safe across concurrent sessions.

An unemployment map of Europe's regions

The data used on this page

The "districts" are Europe's 277 NUTS 2 regions with a 2023 unemployment rate: boundaries from Eurostat GISCO at 1:1 million, and the rate for ages 15–74 from the Eurostat table lfst_r_lfu3rt, joined on the NUTS code into districts.gpkg. The country column is what the app filters on. Eurostat refuses the default python-requests user agent, hence the header.

import os

import geopandas as gpd
import pandas as pd
import requests

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_2.geojson")
    stat = requests.get("https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/lfst_r_lfu3rt",
                        params={"format": "JSON", "time": "2023", "sex": "T", "age": "Y15-74",
                                "unit": "PC", "isced11": "TOTAL"},
                        headers={"User-Agent": "spatialworkflow.io guide"}, timeout=120).json()
    assert all(n == 1 for d, n in zip(stat["id"], stat["size"]) if d != "geo")
    position = stat["dimension"]["geo"]["category"]["index"]
    rate = pd.Series({code: stat["value"].get(str(i)) for code, i in position.items()},
                     name="rate", dtype=float)
    (nuts.rename(columns={"NUTS_ID": "code", "CNTR_CODE": "country", "NAME_LATN": "name"})
         [["code", "country", "name", "geometry"]]
         .merge(rate, left_on="code", right_index=True)
         .dropna(subset=["rate"])
         .to_file("districts.gpkg"))

districts = gpd.read_file("districts.gpkg")
print(f"{len(districts)} NUTS 2 regions in {districts.country.nunique()} countries, "
      f"unemployment {districts.rate.min():.1f}–{districts.rate.max():.1f}% (2023); "
      f"districts.gpkg is {os.path.getsize('districts.gpkg') / 1e6:.1f} MB")
277 NUTS 2 regions in 33 countries, unemployment 1.7–30.0% (2023); districts.gpkg is 6.7 MB

Example 1 β€” a complete app with caching, state and a download

%%writefile app.py
import geopandas as gpd
import pydeck as pdk
import streamlit as st

st.set_page_config(page_title="Unemployment by region", layout="wide")


@st.cache_data(show_spinner="Loading regions…")
def load_districts(path: str, simplify_deg: float = 0.001) -> gpd.GeoDataFrame:
    gdf = gpd.read_file(path).to_crs(4326)
    gdf["geometry"] = gdf.geometry.simplify(simplify_deg, preserve_topology=True)
    return gdf


@st.cache_data
def to_geojson(_gdf: gpd.GeoDataFrame, key: tuple) -> dict:
    """Cached on the filter that made it: Streamlit cannot hash a GeoDataFrame."""
    import json
    return json.loads(_gdf.to_json())


def build_deck(gdf, view, key):
    layer = pdk.Layer(
        "GeoJsonLayer", to_geojson(gdf, key),
        get_fill_color="properties.above ? [239, 68, 68, 160] : [14, 165, 233, 120]",
        get_line_color=[255, 255, 255], line_width_min_pixels=0.5,
        pickable=True, auto_highlight=True)
    return pdk.Deck(layers=[layer], initial_view_state=view,
                    tooltip={"text": "{name}\n{rate}%"})


districts = load_districts("districts.gpkg")

if "view" not in st.session_state:
    # not the centre of total_bounds: the regions reach RΓ©union and Guadeloupe
    st.session_state.view = pdk.ViewState(latitude=52.0, longitude=10.0, zoom=3)

with st.sidebar:
    st.header("Filters")
    country = st.selectbox("Country", ["All", *sorted(districts.country.unique())],
                           key="country")
    threshold = st.slider("Highlight above (%)", 0.0, 30.0, 8.0, 0.5,
                          key="threshold")

subset = districts if country == "All" else districts[districts.country == country]
subset = subset.assign(above=subset["rate"] > threshold)

left, right = st.columns([3, 1])
with left:
    st.pydeck_chart(build_deck(subset, st.session_state.view, (country, threshold)),
                    width="stretch")
with right:
    st.metric("Regions", f"{len(subset):,}")
    st.metric("Above threshold", int(subset["above"].sum()))
    st.metric("Median rate", f"{subset['rate'].median():.1f}%")
    st.download_button("Download this selection",
                       subset.drop(columns="geometry").to_csv(index=False),
                       file_name="selection.csv", mime="text/csv")
    st.caption(f"Source: Eurostat lfst_r_lfu3rt, 2023 Β· {len(districts):,} regions loaded")

The layer function takes the GeoDataFrame as _gdf and a separate key. An earlier version of this page passed the frame itself, and the app stopped with Cannot hash argument 'gdf' (of type geopandas.geodataframe.GeoDataFrame): Streamlit hashes every argument to build the cache key, and cannot hash a GeoDataFrame. The leading underscore tells it to skip the argument, so the filter values that produced the frame have to stand in for it.

Streamlit's AppTest runs the script headlessly, so the app can be checked without a browser:

from streamlit.testing.v1 import AppTest

for name in ("quick_app.py", "app.py"):
    at = AppTest.from_file(name, default_timeout=120).run()
    print(name, [m.value for m in at.metric], "exception:", list(at.exception))
at.selectbox(key="country").set_value("ES").run()
at.slider(key="threshold").set_value(12.0).run()
print([m.value for m in at.metric], "exception:", list(at.exception))
quick_app.py ['277', '76'] exception: []
app.py ['277', '76', '5.7%'] exception: []
['19', '9', '10.3%'] exception: []

Example 2 β€” folium when you need Leaflet's plugins

%%writefile folium_app.py
import folium
import geopandas as gpd
import streamlit as st
from streamlit_folium import st_folium


def folium_map(gdf, centre, zoom=4):
    m = folium.Map(location=centre, zoom_start=zoom)
    # one GeoJson layer, not one marker per feature (Example 3 measures both)
    folium.GeoJson(
        gdf.to_json(),
        style_function=lambda feature: {
            "fillColor": "#ef4444" if feature["properties"]["above"] else "#0ea5e9",
            "color": "white", "weight": 0.5, "fillOpacity": 0.6},
        tooltip=folium.GeoJsonTooltip(fields=["name", "rate"]),
    ).add_to(m)
    return m


subset = gpd.read_file("districts.gpkg").assign(above=lambda d: d["rate"] > 8)
subset["geometry"] = subset.geometry.simplify(0.01, preserve_topology=True)
state = st_folium(folium_map(subset, [52.0, 10.0]), width=None, height=560,
                  returned_objects=["last_active_drawing", "bounds"])
st.write(state)
at = AppTest.from_file("folium_app.py", default_timeout=120).run()
print("exception:", list(at.exception), "| returned:", at.json[0].value)
exception: [] | returned: {"last_active_drawing": null, "bounds": {"_southWest": {"lat": -21.388846427999965, "lng": -63.15118732999997}, "_northEast": {"lat": 71.18416173700007, "lng": 55.83481818300004}}}

returned_objects matters: by default st_folium returns everything the map knows on every interaction, which re-runs the script constantly. Naming only what you use makes the app usable. The page used to set tiles="cartodbpositron"; folium 0.20 now warns that CartoDB tiles require an API key, so the example keeps the default OpenStreetMap tiles.

Example 3 β€” measuring the rerun, and what the browser receives

AppTest also times reruns. The loop below moves the slider through four new values, then through the same four again, for the app as written and for a copy with the @st.cache_data lines removed:

import statistics
import time

source = open("app.py").read()
with open("app_uncached.py", "w") as out:
    out.write(source.replace('@st.cache_data(show_spinner="Loading regions…")\n', "")
                    .replace("@st.cache_data\n", ""))

for name in ("app_uncached.py", "app.py"):
    at = AppTest.from_file(name, default_timeout=120).run()
    rounds = []
    for _ in range(2):                    # new slider values, then the same ones again
        times = []
        for value in (5.0, 10.0, 15.0, 20.0):
            started = time.perf_counter()
            at.slider(key="threshold").set_value(value).run()
            times.append(time.perf_counter() - started)
        rounds.append(statistics.median(times))
    print(f"{name:16} new value {rounds[0]:.2f} s, revisited {rounds[1]:.2f} s")
print(f"load average {os.getloadavg()[0]:.1f}")
app_uncached.py  new value 1.06 s, revisited 1.05 s
app.py           new value 0.81 s, revisited 0.37 s
load average 0.8

The rest of a rerun is the layer: turning the frame into GeoJSON and shipping it to the browser. What each component sends is measurable without a browser β€” here for the 277 regions at two simplification tolerances, and for real points, 50,000 GPS fixes from OpenStreetMap's public trackpoints over Brighton and Hove, from a copy saved on 9 October 2026:

import glob
import io
import xml.etree.ElementTree as ET
import zipfile

import folium
import pydeck as pdk

for tol in (0.001, 0.01):
    g = districts.assign(geometry=districts.geometry.simplify(tol, preserve_topology=True))
    print(f"277 regions simplified at {tol}Β°: {len(g.to_json()) / 1e6:5.2f} MB of GeoJSON")

# The OSM trackpoints API for this box, pages 0-11, as saved on 9 October 2026. The API serves the
# newest uploads first, so a live download changes whenever someone uploads a trace here. Live:
#   requests.get("https://api.openstreetmap.org/api/0.6/trackpoints",
#                params={"bbox": "-0.20,50.81,-0.10,50.86", "page": page})
if not os.path.exists("traces/page_11.gpx"):
    reply = requests.get("https://data.spatialworkflow.io/osm-gps-traces-brighton-2026-10-09-v2.zip",
                         timeout=120)
    reply.raise_for_status()
    with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
        archive.extractall("traces")
NS = {"g": "http://www.topografix.com/GPX/1/0"}
fixes = pd.DataFrame([(float(p.get("lon")), float(p.get("lat")))
                      for path in sorted(glob.glob("traces/*.gpx"))
                      for p in ET.parse(path).getroot().iterfind(".//g:trkpt", NS)],
                     columns=["lon", "lat"]).drop_duplicates().head(50_000)

markers = folium.Map(location=[50.83, -0.15], zoom_start=13)
for lon, lat in zip(fixes.lon, fixes.lat):
    folium.CircleMarker([lat, lon], radius=2).add_to(markers)
started = time.perf_counter()
marker_html = markers.get_root().render()
render_s = time.perf_counter() - started

layer = folium.Map(location=[50.83, -0.15], zoom_start=13)
folium.GeoJson(gpd.GeoDataFrame(geometry=gpd.points_from_xy(fixes.lon, fixes.lat),
                                crs=4326).to_json()).add_to(layer)
layer_html = layer.get_root().render()
deck_html = pdk.Deck(layers=[pdk.Layer("ScatterplotLayer", fixes, get_position=["lon", "lat"],
                                       get_radius=3)]).to_html(as_string=True)
empty = len(folium.Map().get_root().render())
print(f"{len(fixes):,} points: folium markers {len(marker_html) / 1e6:.2f} MB "
      f"({(len(marker_html) - empty) / len(fixes):.0f} bytes each, rendered in {render_s:.1f} s), "
      f"one folium GeoJson layer {len(layer_html) / 1e6:.2f} MB, pydeck page {len(deck_html) / 1e6:.2f} MB")
277 regions simplified at 0.001Β°: 13.12 MB of GeoJSON
277 regions simplified at 0.01Β°:  4.06 MB of GeoJSON
50,000 points: folium markers 24.04 MB (481 bytes each, rendered in 7.0 s), one folium GeoJson layer 6.23 MB, pydeck page 3.84 MB
Measured rerun time of a Streamlit map of 277 European regions, uncached, cached with a new slider value, and cached with a value already seen. one slider move, three costsno cache1.06 scached loader, new value0.81 s: layer rebuiltcached, value seen before0.37 s00.6 s1.2 sThe cache removes the reading; a new filter value still rebuilds 13 MB of layer spatialworkflow.io
Timed with AppTest on this page's app (Example 3). Caching the loader is necessary and not sufficient: the layer depends on the widget that changed.

The script is the app

Why the script re-runs, and why that is mostly fine

Streamlit's model is that the script is the app: there is no callback graph, no state wiring, and no component lifecycle. That removes an enormous amount of code, and the price is that the framework cannot know what changed.

With the expensive work behind @st.cache_data, the rerun gets cheaper β€” measured, 0.81 s against 1.06 s uncached, and 0.37 s when the slider returns to a value whose layer is cached. The model becomes a problem when the expensive work depends on the widget that just changed: here every new threshold recolours all 277 regions, so the layer is rebuilt and the cache cannot help. Sending less β€” simplifying at 0.01Β° instead of 0.001Β° takes the GeoJSON from 13.12 MB to 4.06 MB β€” is the remaining lever.

Why cache_data and cache_resource are not interchangeable

cache_data serialises the return value and hands back a copy, so a mutation in one session cannot corrupt another's view. That is exactly right for DataFrames and exactly wrong for a database connection, which cannot be serialised and must be shared.

cache_resource returns the same object to every session. Use it for connections, models and file handles β€” and remember that anything mutable stored there is shared across users.

Why the view state resets, and why session_state fixes it

Each rerun constructs a new pdk.Deck with a fresh initial_view_state, which the front end applies. The map has not "reset" so much as been rebuilt from the same starting parameters.

Storing the view in session_state and passing it back means the rebuilt deck starts where the user left it. The same pattern applies to any component whose state lives on the client.

Why the payload is the ceiling

The measured browser costs β€” 481 bytes of HTML per folium marker, 6.23 MB for 50,000 points as one folium GeoJson layer, 3.84 MB for the same points in pydeck β€” are properties of the components, not of Streamlit.

That is why filtering server-side matters: the app's job is to send a small result, and a fast server pushing 30 MB into a browser is still a slow app.

Storing the map view in session state so it survives a rerun. why the map resets, and the fixview in the componentrerun β†’ Deck(initial_view_state=default)user’s zoom is gonerebuilt from the same codeview in session_statererun β†’ new Deck(state.view)map starts where it waspassed back inNothing "reset" β€” a new component was built from the same starting parameters spatialworkflow.io
The same pattern applies to drawings and selections.

Wide layouts, widget keys and returned objects

  • layout="wide" β€” the default centred column wastes most of the screen on a map.
  • Give widgets stable key= values, or their state is lost when the layout changes.
  • st.cache_data hashes the arguments; unhashable ones need _-prefixed parameter names to be skipped.
  • st_folium's returned_objects β€” without it, every map movement re-runs the script.
  • One GeoJson layer beats many markers: 6.23 MB against 24.04 MB for 50,000 points.
  • total_bounds includes every outlier. The NUTS regions reach RΓ©union and Guadeloupe, so centring on the bounds put the map over the Atlantic; this app sets its view explicitly.
  • st.map is for a quick scatter, not for styled output.
  • Sessions are per browser tab; memory scales with concurrent users.
  • Simplify geometry at load time, inside the cached function.

FAQ

Why does my Streamlit map reset when I move a slider?

Because each rerun builds a new deck with a fresh initial_view_state. Store the view in st.session_state and pass it back.

Why is my app slow?

First an uncached loader, then the layer. On the 277-region app here, @st.cache_data took reruns from 1.06 s to 0.81 s, and the rest was rebuilding 13.12 MB of GeoJSON β€” which simplifying at 0.01Β° cuts to 4.06 MB.

cache_data or cache_resource?

cache_data for values such as DataFrames β€” it returns a copy. cache_resource for connections, models and anything that must be shared and cannot be serialised.

Which map component should I use?

st.pydeck_chart for most things, streamlit-folium when you need Leaflet plugins such as drawing tools, and st.map only for a quick scatter of points.

How many features can I show?

Measured with 50,000 real GPS points: a single folium GeoJson layer is 6.23 MB, the same points as markers are 24.04 MB and take 7.0 s to render, and pydeck holds them in a 3.84 MB page.

Why does the app re-run when I pan the map?

st_folium returns map state by default, and any returned value re-runs the script. Restrict it with returned_objects=[...].