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.

Which script stages re-execute for each kind of interaction A grid of five script stages against four interactions. On first load the engine is created, the PostGIS query runs for 1.9 seconds, the marker layer is built and the map renders. Changing the basin selectbox changes the cache key so the query runs again, while the engine is reused. Moving the reading slider leaves the cache key unchanged, so the load is a 0.01 second cache hit and only the layer build and render repeat. Panning or zooming runs no Python at all because bounds, centre and zoom are excluded from returned_objects, so Leaflet moves client-side only. What actually re-executes on each interaction Script stage First load cold process Basin select cache key changes Reading slider same cache key Pan / zoom no rerun at all get_engine() @st.cache_resource connect · 0.4 s same pool object same pool object not reached load_sensors(basin) @st.cache_data query · 1.9 s miss — new key query · 1.9 s hit · 0.01 s not reached filter + FeatureGroup plain Python, uncached rebuilds · 0.2 s rebuilds · 0.2 s rebuilds · 0.2 s not reached st_folium(BASE, fg) component render mounts · 0.35 s layer swap only viewport kept layer swap only viewport kept client-side only detail panel inside @st.fragment empty, no click yet recomputes recomputes unchanged runs, costs time short-circuited by cache or component script never runs
Only the middle band is genuinely yours to optimise — the caches remove the top rows and returned_objects removes the whole right column.

Prerequisites

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
Why the map resets, and what keeps the viewport Two panels compare the same three moments. On the left, a fresh folium.Map is constructed on every rerun: the user zooms to level 15, moves a slider, and the map comes back at zoom 11 because Leaflet remounted at zoom_start. On the right, a constant base map is passed with feature_group_to_add and a stable key: the same slider move replaces only the marker layer, so the map is still at zoom 15 afterwards. Same three moments, two ways of passing the map Map object rebuilt every rerun st_folium(folium.Map(zoom_start=11)) t1 · user zooms z 15 t2 · slider moves full rerun t3 · after rerun z 11 Leaflet remounts at zoom_start — the pan is lost. Constant base map, layer passed in st_folium(BASE, feature_group_to_add=fg) t1 · user zooms z 15 t2 · slider moves layer swap t3 · after rerun z 15 Only the marker layer changes — the pan survives. The base map is the component's identity: change its markup and Leaflet starts over at location and zoom_start.
Keep the 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,
    )
One lap around the widget, cache, component and session-state loop The upper lane is the Python process running the whole script left to right: a widget value is read from session state, the cached resource and cached data functions are consulted, a feature group is built from the filtered frame, and st_folium is called with the constant base map. The lower lane is the browser: Leaflet pans and zooms without Python, returns a dictionary of click, bounds, zoom and centre keys, and whatever the script copies into st.session_state feeds the next rerun. A closing note states that only keys listed in returned_objects can change the value, and only a changed value starts another rerun. One interaction, one lap around the loop Python process — the whole script runs top to bottom 1 · widget value selectbox + slider restored from state 2 · cached load cache_resource: pool cache_data: a copy 3 · FeatureGroup markers from the filtered frame only 4 · st_folium() constant BASE_MAP + feature group Browser component — and what comes back 5 · Leaflet map pan and zoom run without Python 6 · returned dict last_object_clicked bounds · zoom · center 7 · st.session_state the focus point kept across every rerun rendered once per rerun feeds the next rerun Only keys listed in returned_objects can change the value — and only a changed value starts another rerun.
The component's output is the next run's input — which is why anything worth keeping must be copied into 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

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.