Building a Streamlit Map Dashboard with Folium
A Streamlit map dashboard is thirty lines of code that works immediately and then degrades the moment a second widget appears — the data reloads on every click and the map snaps back to its starting viewport whenever anyone touches a filter. This guide builds the version that does not do that, for analysts turning a notebook map into an internal tool. It sits under Geospatial Dashboards & App Deployment in Web Mapping & Interactive Visualization, and assumes you can already draw the layer itself with Interactive Maps with Folium.
Why This Approach / What Goes Wrong
Three mechanisms interact here, and every symptom people report is one of them behaving exactly as designed.
The script is the callback. Streamlit has no event handlers. Moving a slider re-executes app.py from its first line in the same process, with widget values restored from st.session_state. A gpd.read_postgis call sitting at module scope therefore runs again on every checkbox toggle — not because anything is broken, but because there is no other place for it to be skipped. Caching is what supplies the missing skip.
The component's return value is an input to the next run. st_folium is a bidirectional Streamlit component: it renders the map and hands back a dictionary of what the user did to it. Streamlit reruns the script whenever that dictionary changes, so by default a pan changes bounds, center and zoom, which changes the return value, which triggers a rerun. Restricting the dictionary with returned_objects=[...] is not a micro-optimisation; it is how you decide which map gestures count as interactions at all.
A new folium.Map object is a new component. The frontend keys the rendered map on the HTML that Python sends it. Construct a fresh folium.Map(location=..., zoom_start=11) on each rerun with different children baked in, and the component receives different markup, remounts Leaflet, and initialises it at location/zoom_start — discarding wherever the user had panned to. That is the whole of the "my map keeps resetting" problem.
returned_objects removes the whole right column.Prerequisites
streamlit>=1.37— the first release wherest.fragmentis stable rather than experimentalstreamlit-folium>=0.22— suppliesfeature_group_to_add,layer_controlanduse_container_widthonst_foliumfolium>=0.17— the Leaflet wrapper producing the map markupgeopandas>=1.0—read_postgisandestimate_utm_crsfor the metric side of the click handlersqlalchemy>=2.0withpsycopg[binary]>=3.1— the connection pool held as a cached resource
pip install "streamlit>=1.37,<2" "streamlit-folium>=0.22" "folium>=0.17" \
"geopandas>=1.0" "sqlalchemy>=2.0" "psycopg[binary]>=3.1"
If the layer lives in a file rather than a database, swap gpd.read_postgis for gpd.read_file and drop the last two pins — nothing else in this guide changes.
Step-by-Step Implementation
1. Separate the connection from the data.
The engine is a long-lived handle that every session should share; the query result is a value each session may filter independently. That is exactly the st.cache_resource / st.cache_data split. Calling get_engine() inside the loader rather than passing it as an argument also sidesteps UnhashableParamError, because a SQLAlchemy engine has no stable hash and would poison the cache key.
# app.py — streamlit run app.py
from __future__ import annotations
import geopandas as gpd
import streamlit as st
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
st.set_page_config(page_title="Flood sensors", layout="wide")
@st.cache_resource
def get_engine() -> Engine:
"""One pool per container, shared by every browser session."""
return create_engine(
"postgresql+psycopg://gis@db:5432/hydrology",
pool_size=5,
pool_pre_ping=True,
)
@st.cache_data(ttl=900, max_entries=8, show_spinner="Querying sensors…")
def load_sensors(basin: str) -> gpd.GeoDataFrame:
"""Cached per basin. Returns a COPY, so the caller may filter it freely."""
sql = text(
"SELECT sensor_id, basin, reading_m, installed_on, geom "
"FROM flood_sensors WHERE (:basin = 'all' OR basin = :basin)"
)
gdf = gpd.read_postgis(sql, get_engine(), geom_col="geom",
params={"basin": basin})
return gdf.to_crs("EPSG:4326") # web maps consume WGS84 only
2. Choose which filter goes in the query and which goes in pandas.
Every distinct argument creates its own cache entry, so a filter's cardinality decides where it belongs. A basin name has a handful of values — push it into SQL and let the cache hold one frame per basin. A continuous slider has hundreds of positions; pushing that into the query would evict the cache on every drag. Filter continuous ranges on the returned copy instead, which costs a vectorized boolean mask and nothing else.
basin = st.sidebar.selectbox("Basin", ["all", "Willamette", "Columbia", "Tualatin"])
sensors = load_sensors(basin) # low cardinality → cache key
lo, hi = st.sidebar.slider("Reading (m)", 0.0, 8.0, (0.5, 6.0), step=0.1)
sensors = sensors[sensors["reading_m"].between(lo, hi)] # high cardinality → pandas
st.sidebar.metric("Sensors shown", len(sensors))
3. Build the base map once and never vary it.
This is the step that fixes the viewport reset. Define the base map as a module-level constant with fixed arguments, and put everything that changes into a folium.FeatureGroup handed to st_folium through feature_group_to_add. The component then swaps the layer client-side instead of remounting Leaflet. Folium's Python arguments are latitude-then-longitude while the GeoJSON it renders is longitude-then-latitude — the axis-order trap explained in Coordinate Systems with PyProj — so location=[45.52, -122.68] is Portland, and [-122.68, 45.52] is the Southern Ocean.
import folium
BASE_MAP = folium.Map(
location=[45.52, -122.68], # [lat, lon] — Folium's order, not GeoJSON's
zoom_start=11,
tiles="CartoDB positron",
prefer_canvas=True, # canvas rendering scales past ~1k markers
)
def sensor_layer(gdf: gpd.GeoDataFrame) -> folium.FeatureGroup:
fg = folium.FeatureGroup(name="Flood sensors")
for row in gdf.itertuples():
fg.add_child(
folium.CircleMarker(
location=[row.geometry.y, row.geometry.x], # y=lat, x=lon
radius=5,
color="#0d6f87",
fill=True,
fill_opacity=0.85,
tooltip=f"{row.sensor_id} · {row.reading_m:.2f} m",
)
)
return fg
folium.Map constant and vary only the feature group — the difference between a dashboard that fights the user and one that does not.4. Render the map and declare which gestures are interactions.
returned_objects filters the dictionary the component sends back, and Streamlit only reruns when that dictionary changes. Listing just the click keys means panning and zooming never touch Python. Because feature_group_to_add bypasses the map's own child list, a folium.LayerControl() added to BASE_MAP would not see the group — pass it through the layer_control argument instead.
from streamlit_folium import st_folium
state = st_folium(
BASE_MAP,
feature_group_to_add=sensor_layer(sensors),
layer_control=folium.LayerControl(collapsed=False),
key="sensor_map", # stable key = stable component instance
height=560,
use_container_width=True,
returned_objects=["last_object_clicked", "last_object_clicked_tooltip"],
)
5. Persist the click in session state, then work in metres.
The returned dictionary is emptied on reruns the click did not cause, so copy anything you need into st.session_state the moment it appears. The click arrives as {"lat": …, "lng": …} in WGS84; any distance question about it has to move to a projected CRS first — estimate_utm_crs() picks the right zone (10N for Portland), and Web Mercator would inflate the numbers by roughly 1/cos(latitude).
from shapely.geometry import Point
if state and state.get("last_object_clicked"):
st.session_state["focus"] = state["last_object_clicked"]
focus = st.session_state.get("focus")
if focus:
utm = sensors.estimate_utm_crs() # EPSG:32610 here
metric = sensors.to_crs(utm)
click = gpd.GeoSeries(
[Point(focus["lng"], focus["lat"])], crs="EPSG:4326" # x=lon, y=lat
).to_crs(utm)
metric["distance_m"] = metric.distance(click.iloc[0])
st.subheader("Nearest gauges")
st.dataframe(
metric.nsmallest(5, "distance_m")[
["sensor_id", "basin", "reading_m", "distance_m"]
],
hide_index=True,
)
st.session_state immediately.6. Contain the blast radius with a fragment.
A click that should refresh a detail table has no business re-running the sidebar, the header, or a second chart. Wrapping the map and its panel in @st.fragment limits a rerun triggered from inside it to that function, leaving the rest of the page's output untouched.
@st.fragment
def map_panel(gdf: gpd.GeoDataFrame) -> None:
"""Clicks inside here rerun only this function, not the whole script."""
state = st_folium(
BASE_MAP,
feature_group_to_add=sensor_layer(gdf),
key="sensor_map",
height=560,
use_container_width=True,
returned_objects=["last_object_clicked"],
)
if state and state.get("last_object_clicked"):
st.session_state["focus"] = state["last_object_clicked"]
st.caption(f"{len(gdf)} sensors drawn · click a gauge for its neighbours")
map_panel(sensors)
7. Read the viewport only when you actually want it.
Adding "bounds" to returned_objects deliberately re-enables the pan-triggers-rerun behaviour, which is the right trade when the point is to load only what is visible. GeoPandas' .cx indexer takes the box in x then y order, the opposite of the lat/lng keys the component returns.
view = st_folium(BASE_MAP, key="bbox_map", height=400,
returned_objects=["bounds"])
bounds = (view or {}).get("bounds") or {}
sw, ne = bounds.get("_southWest"), bounds.get("_northEast")
if sw and ne:
# .cx slices x (lon) first, then y (lat)
visible = sensors.cx[sw["lng"]:ne["lng"], sw["lat"]:ne["lat"]]
st.write(f"{len(visible)} of {len(sensors)} sensors in view")
Verification
The two things worth asserting are the ones that fail silently: coordinate order in the marker layer, and the size of the markup the component ships to the browser on every render. Both check out without a Streamlit runtime.
# checks.py — python checks.py
import folium
import geopandas as gpd
from shapely.geometry import Point
sensors = gpd.GeoDataFrame(
{"sensor_id": ["PDX-014", "PDX-015"],
"basin": ["Willamette", "Columbia"],
"reading_m": [1.82, 3.44]},
geometry=[Point(-122.68, 45.52), Point(-122.75, 45.60)], # lon, lat
crs="EPSG:4326",
)
markers = [
folium.CircleMarker(location=[r.geometry.y, r.geometry.x], radius=5,
tooltip=r.sensor_id)
for r in sensors.itertuples()
]
# 1. Folium stores [lat, lon] — reversed relative to Shapely and GeoJSON
assert markers[0].location == [45.52, -122.68], markers[0].location
# 2. Payload the component re-sends on every render
fg = folium.FeatureGroup(name="Flood sensors")
for marker in markers:
fg.add_child(marker)
probe = folium.Map(location=[45.52, -122.68], zoom_start=11,
tiles="CartoDB positron")
fg.add_to(probe)
kib = len(probe.get_root().render().encode()) / 1024
assert sensors.crs.to_epsg() == 4326, "st_folium expects WGS84"
print(f"{len(sensors)} sensors -> {kib:.0f} KiB of map HTML")
# 2 sensors -> 13 KiB of map HTML
Re-run that with a realistic row count before deploying. Past roughly 1.5 MiB of rendered markup the component becomes the slowest thing on the page, and the fix is fewer or simpler features — drop unused attributes, simplify in a metric CRS, or switch to tiles as described in the dashboards and deployment overview.
Edge Cases & Debugging
- The map snaps back to its starting view on every widget change. A new
folium.Mapis being constructed each rerun. Hoist it to a module-level constant, move the varying data intofeature_group_to_add, and givest_foliuma stablekey. UnhashableParamErrorfromst.cache_data. An engine, connection orTransformerwas passed as an argument. Fetch it from anst.cache_resourcesingleton inside the function, or rename the parameter to_engineso it is excluded from the key.- Memory climbs until the container is killed. A continuous widget is part of a cache key, so every drag stores another frame. Cap it with
max_entriesandttl, and filter continuous ranges in pandas rather than in the query. last_object_clickedis alwaysNone. Clicks on the tiles setlast_clicked; only markers and vector layers setlast_object_clicked. AMarkerClusteradded viafeature_group_to_addalso swallows clicks until the group is expanded — see clustering map markers with Folium.- The layer control shows no layers.
folium.LayerControl()added to the map is rendered before the feature group arrives. Pass the control throughst_folium(..., layer_control=...)instead. missing ScriptRunContextwarnings and dead widgets. The file was started withpython app.py. Streamlit components only exist understreamlit run app.py.
Frequently Asked Questions
Why does the map still rerun when I only wanted click events?
st_folium returns bounds, center and zoom unless told otherwise, and every pan changes them, which changes the component's value, which reruns the script. Pass returned_objects=["last_object_clicked"] — or [] if you want a purely display map — and viewport gestures stop reaching Python entirely. Keep in mind this is a trade: with bounds excluded you cannot implement load-only-what-is-visible, so decide per map rather than globally.
Should st.cache_data hold the whole layer or the filtered layer?
Hold the widest slice you can afford, and filter the returned copy. st.cache_data hands each caller its own copy, so mutating the result is safe, and one entry serving many filter combinations beats one entry per combination. Push a filter into the cache key only when it is low-cardinality and genuinely reduces the query cost — a basin name yes, a floating-point slider no.
Can I move the map programmatically from Python?
Yes, through st_folium's center=(lat, lon) and zoom= arguments, which reposition the existing component without remounting it. Set them from st.session_state and write to that state only when you intend a move — for example when a search result is selected. Feeding the returned center straight back in on every rerun creates a loop where the component and the script argue about the viewport.
Is Streamlit the right framework for this dashboard at all? For an internal tool with one map and a handful of filters, yes — the rerun model is the reason the code above reads top to bottom with no callback graph. Once a page holds a map plus several charts and a table that must update independently, the explicit output declarations of a callback framework start winning; the comparison is worked through in Streamlit vs Dash for geospatial dashboards.