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.
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.
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
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.
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_datahashes the arguments; unhashable ones need_-prefixed parameter names to be skipped.st_folium'sreturned_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_boundsincludes 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.mapis 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.
Internal links
- Reruns and state explained: why your map app redraws everything β the model behind all of this
- How to cache spatial data in a map app β the two decorators in detail
- How to add filters and widgets that drive a map β the controls
- Fixing a map that resets or disappears on every interaction β the view-state problem
- How to render a million points in a map app with pydeck β beyond folium's ceiling
- What a map app can afford to send to the browser β the payload numbers
- How to add a download button for filtered spatial data β shipping the data too
- How to make interactive maps with folium β the map component itself
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=[...].