How to Build a Streamlit App with an Interactive Map

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

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.

The measured effect of the most important one: on a 15 MB shapefile, an uncached app reran in 0.23โ€“0.25 s and the same app with @st.cache_data reran in 0.04โ€“0.06 s.

Quick answer

import geopandas as gpd
import pydeck as pdk
import streamlit as st

st.set_page_config(page_title="Districts", layout="wide")


@st.cache_data(show_spinner="Loading districtsโ€ฆ")
def load_districts(path: str) -> gpd.GeoDataFrame:
    return gpd.read_file(path).to_crs(4326)


districts = load_districts("districts.gpkg")

with st.sidebar:
    region = st.selectbox("Region", ["All", *sorted(districts.region.unique())])
    threshold = st.slider("Highlight above (%)", 0.0, 15.0, 8.0, 0.5)

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

left, right = st.columns([3, 1])
with left:
    st.pydeck_chart(build_deck(subset), use_container_width=True)
with right:
    st.metric("Districts", 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.

Six ordered steps for structuring a Streamlit map app.
Everything else is styling.

Step-by-step solution

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=54.0, longitude=-2.0, zoom=5)

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      โ‰ˆ478 bytes of HTML per point
folium GeoJson      tens of thousands         50k features = 7.34 MB, 0.92 s
pydeck              hundreds of thousands     100k points = 7.39 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 it is the slowest of the three.

4. Filter server-side, send the result

The temptation is to send everything and filter in the browser. The measured cost of sending everything: 193,780 points is 28.11 MB of GeoJSON, and as folium markers about 92 MB of HTML.

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

region = st.selectbox("Region", options, key="region")

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.

Grid of four return types and the Streamlit cache decorator each needs.
The copy is what makes cache_data safe across concurrent sessions.

Code examples

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

import geopandas as gpd
import pydeck as pdk
import streamlit as st

st.set_page_config(page_title="Districts", layout="wide")


@st.cache_data(show_spinner="Loading districtsโ€ฆ")
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) -> dict:
    """Cached separately: the filter changes far more often than the layer."""
    import json
    return json.loads(gdf.to_json())


def build_deck(gdf, view):
    layer = pdk.Layer(
        "GeoJsonLayer", to_geojson(gdf),
        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:
    minx, miny, maxx, maxy = districts.total_bounds
    st.session_state.view = pdk.ViewState(
        latitude=(miny + maxy) / 2, longitude=(minx + maxx) / 2, zoom=5)

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

subset = districts if region == "All" else districts[districts.region == region]
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),
                    use_container_width=True)
with right:
    st.metric("Districts", 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: โ€ฆ ยท {len(districts):,} districts loaded")

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

import folium
import streamlit as st
from streamlit_folium import st_folium


def folium_map(gdf, centre, zoom=6):
    m = folium.Map(location=centre, zoom_start=zoom, tiles="cartodbpositron")
    # one GeoJson layer, not one marker per feature: measured 7.34 MB for 50k
    # features as a layer against 23.88 MB as markers
    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


state = st_folium(folium_map(subset, [54.0, -2.0]), width=None, height=560,
                  returned_objects=["last_active_drawing", "bounds"])

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.

Example 3 โ€” measuring the app's own rerun cost

import time
import streamlit as st


def timed_section(label):
    """Show where a rerun's time actually goes, in the app itself."""
    class _Timer:
        def __enter__(self):
            self.started = time.perf_counter()
            return self

        def __exit__(self, *args):
            elapsed = time.perf_counter() - self.started
            st.session_state.setdefault("timings", {})[label] = elapsed
    return _Timer()


with timed_section("load"):
    districts = load_districts("districts.gpkg")
with timed_section("filter"):
    subset = districts[districts.region == region]
with timed_section("render"):
    deck = build_deck(subset, st.session_state.view)

with st.sidebar.expander("Timing"):
    for label, seconds in st.session_state.get("timings", {}).items():
        st.text(f"{label:8} {seconds * 1000:6.0f} ms")

Putting the timings in an expander in the app itself is more useful than profiling offline, because it shows the numbers under real interaction. An uncached load stands out immediately.

Explanation

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 is cheap โ€” measured, 0.04โ€“0.06 s on a 15 MB layer against 0.23โ€“0.25 s uncached. The model becomes a problem only when the expensive work depends on the widget that just changed, at which point it is doing genuine work rather than repeating itself.

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 โ€” 28.11 MB of GeoJSON for 193,780 points, about 478 bytes of HTML per folium marker, 7.39 MB for 100,000 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.

Two panels showing a map view lost on rerun and one preserved in session state.
The same pattern applies to drawings and selections.

Edge cases or notes

  • 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: 7.34 MB against 23.88 MB for 50,000 features.
  • 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?

Almost always an uncached loader. Measured on a 15 MB shapefile, @st.cache_data took reruns from 0.23โ€“0.25 s to 0.04โ€“0.06 s.

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: 50,000 features as a single folium GeoJson layer is 7.34 MB, the same as markers is 23.88 MB, and pydeck holds 100,000 points in a 7.39 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=[...].