Reruns and State Explained: Why Your Map App Redraws Everything
Problem statement
The behaviour that confuses everyone new to Streamlit: change one filter, and the whole app rebuilds. The file is re-read, the projection is redone, the map is reconstructed, and any state the user had โ a zoom, a drawing, a selection โ is gone.
That is not a bug. Streamlit's model is that the script is the app: every interaction re-executes it from the first line. There is no callback graph and no component lifecycle, which is why an app takes an afternoon to write.
The consequences are measurable. On a 15 MB shapefile, an uncached app spent 0.23โ0.25 s on every widget change re-reading the file it had already read; with @st.cache_data on the loader the same interaction cost 0.04โ0.06 s.
Understanding the model turns three separate mysteries โ the slow app, the resetting map, the vanishing drawing โ into one explanation with three fixes.
Quick answer
Three tools, and each one solves a different part of the model:
import streamlit as st
@st.cache_data # values: skipped on rerun, returns a copy
def load(path):
import geopandas as gpd
return gpd.read_file(path)
@st.cache_resource # connections and models: one shared instance
def connect(dsn):
import duckdb
return duckdb.connect(dsn, read_only=True)
if "view" not in st.session_state: # state: survives reruns within a session
st.session_state.view = default_view()
what happens on a rerun
script body re-executed, top to bottom
@st.cache_data results skipped, if the arguments match
@st.cache_resource objects skipped, the same object returned
st.session_state preserved
widget values preserved, keyed by their key=
anything else in memory gone
Step-by-step solution
1. Know exactly what triggers a rerun
- Any widget's value changing.
st.rerun().- A file being edited in development, if auto-rerun is on.
- Some components returning a value โ
st_foliumreturns map state on every pan by default, which is why a folium map can re-run the script continuously.
Nothing else. In particular, another user's interaction does not re-run your session, because sessions are independent.
2. Put expensive, reusable work behind @st.cache_data
@st.cache_data(show_spinner="Loadingโฆ", ttl=3600, max_entries=8)
def load_districts(path: str, simplify_deg: float) -> "gpd.GeoDataFrame":
...
The cache key is the function plus its arguments. Same arguments, no execution โ which is why the measured rerun fell from 0.23โ0.25 s to 0.04โ0.06 s.
cache_data serialises the return value and hands back a copy, so a mutation in the app cannot corrupt the cached value. That is the property that makes it safe by default.
3. Use @st.cache_resource for things that must not be copied
A database connection cannot be serialised, and a loaded model should not be duplicated per session:
@st.cache_resource
def connection():
con = duckdb.connect("data.duckdb", read_only=True)
con.execute("load spatial")
return con
cache_resource returns the same object to every session. That is what you want for a connection pool and dangerous for anything mutable โ a dictionary stored there is shared across all users.
4. Keep client-side state in session_state
The map's view, a drawing, a multi-step wizard's position: all of these live on the client and are lost when the component is rebuilt.
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)
session_state is a per-session dictionary that survives reruns. It is the only thing that does, besides caches and widget values.
5. Give widgets keys, and understand what that does
region = st.selectbox("Region", options, key="region")
A key binds the widget to st.session_state["region"]. Two effects follow: the value survives layout changes, and you can set it programmatically โ which is how a "reset filters" button works.
Without a key, Streamlit derives an internal one from the widget's position and parameters, so moving a widget between branches loses its value.
6. Batch interactions with a form when the work is expensive
Within a form, widgets do not trigger a rerun until the submit button is pressed:
with st.form("filters"):
year = st.select_slider("Year", options=YEARS)
low, high = st.slider("Rate", 0.0, 20.0, (0.0, 20.0))
submitted = st.form_submit_button("Apply")
A slider dragged across twenty positions is twenty reruns without a form and one with it. At 0.25 s each, that is the difference between five seconds and a quarter of one.
Code examples
Example 1 โ a diagnostic that shows the model at work
import time
import streamlit as st
st.session_state.setdefault("runs", 0)
st.session_state["runs"] += 1
started = time.perf_counter()
@st.cache_data
def load(path):
time.sleep(1.0) # stand-in for a real read
return f"data from {path}"
data = load("districts.gpkg") # slow once, free thereafter
uncached = time.sleep(0.05) or "recomputed every time"
st.write(f"rerun #{st.session_state['runs']}")
st.write(f"this rerun took {(time.perf_counter() - started) * 1000:.0f} ms")
st.selectbox("Change me to trigger a rerun", ["a", "b", "c"], key="trigger")
The first run takes over a second; every subsequent one takes about 50 ms. The counter demonstrates that the script really is re-executing, and the timing demonstrates what the cache removed.
Example 2 โ the three storage kinds side by side
import streamlit as st
# 1. cached value: recomputed only when the arguments change
@st.cache_data(ttl=3600)
def districts(path: str, simplify_deg: float = 0.001):
import geopandas as gpd
gdf = gpd.read_file(path).to_crs(4326)
gdf["geometry"] = gdf.geometry.simplify(simplify_deg, preserve_topology=True)
return gdf
# 2. cached resource: one instance for the whole server
@st.cache_resource
def db():
import duckdb
con = duckdb.connect("data.duckdb", read_only=True)
con.execute("load spatial")
return con
# 3. session state: per user, survives reruns
def remember(key, default):
if key not in st.session_state:
st.session_state[key] = default
return st.session_state[key]
view = remember("view", {"latitude": 54.0, "longitude": -2.0, "zoom": 5})
history = remember("history", [])
choice = st.selectbox("Region", ["North", "South"], key="region")
if not history or history[-1] != choice:
history.append(choice) # mutating session_state persists
st.caption(f"you have looked at: {' โ '.join(history)}")
Example 3 โ resetting state deliberately
import streamlit as st
DEFAULTS = {"region": "All", "year": 2025, "min_rate": 0.0}
def reset_filters():
"""A callback runs before the rerun, so widget state can be set here."""
for key, value in DEFAULTS.items():
st.session_state[key] = value
st.sidebar.button("Reset filters", on_click=reset_filters)
def clear_caches():
st.cache_data.clear()
st.cache_resource.clear()
st.sidebar.button("Reload data", on_click=clear_caches)
The on_click callback matters: it runs before the next rerun, so setting a widget's session_state value there is applied. Setting it in the script body after the widget has been created raises an error.
Explanation
Why rerunning everything is a reasonable design
The alternative โ a callback graph โ requires declaring which outputs depend on which inputs, which is most of what makes Dash and Panel apps longer. Streamlit removes that entirely: there is one code path, executed in order, and reading it tells you what the app does.
The bet is that with caching, re-running is cheap enough. The measurements support it at ordinary sizes: 0.04โ0.06 s per interaction on a 15 MB layer. It stops holding when the expensive work depends on the widget that changed, because then the rerun is doing genuine work rather than repeating itself.
Why cache_data returns a copy and cache_resource does not
cache_data is for values. Returning the same object to every caller would let one session's mutation change another's data, so it serialises and returns a copy. That costs a little and removes a whole class of cross-session bugs.
cache_resource is for things that cannot be copied โ a database connection, a machine-learning model, a file handle. It returns the same object to everybody, which is correct for a connection and a hazard for a mutable dictionary.
Choosing the wrong one produces either an unpicklable-object error or a shared-state bug, and both are confusing until the distinction is clear.
Why the map's view resets
The deck or the folium map is rebuilt on every rerun from its initial parameters, so the front end receives a fresh component and applies its starting view.
Nothing has "reset"; the component is new. Storing the view in session_state and passing it back means the new component starts where the old one was, which is what the user expects.
Why a component that returns state can loop
st_folium returns the map's state โ bounds, last click, drawings โ and any component returning a value triggers a rerun. Panning the map therefore re-runs the script, which rebuilds the map, which returns state, which re-runs the script.
returned_objects=[...] restricts what it reports, and restricting it to what you actually use breaks the loop. This is the single most common cause of a Streamlit map app that feels like it is constantly reloading.
Edge cases or notes
- Sessions are independent. One user's rerun does not affect another's.
st.session_stateis per session;cache_resourceis per server. Do not confuse them.- Setting a widget's
session_stateafter it is created raises โ do it in anon_clickcallback. cache_datahashes arguments; prefix unhashable ones with_to skip them.ttlandmax_entriesbound a cache that would otherwise grow.- Mutating a
cache_dataresult is safe โ it is a copy. Mutating acache_resourceobject is shared. st.rerun()restarts the script immediately; use it sparingly, and never unconditionally.- Components that return values trigger reruns โ that is the folium loop.
Internal links
- How to cache spatial data in a map app โ the two decorators in depth
- How to build a Streamlit app with an interactive map โ the model in practice
- Fixing a map that resets or disappears on every interaction โ the view-state symptom
- How to add filters and widgets that drive a map โ forms and debouncing
- Fixing a map app that takes ten seconds to load โ when the rerun is the problem
- Streamlit, Dash or Panel: choosing a framework for a map app โ the other execution models
- Fixing an app whose memory grows with every user โ session state and caches
- How to test a map app without a browser โ driving reruns headlessly
FAQ
Why does my whole Streamlit app re-run when I change one widget?
Because that is the model: the script is the app and it re-executes top to bottom on every interaction. Caching is what makes that cheap.
What survives a rerun?
st.session_state, widget values keyed by key=, and anything behind @st.cache_data or @st.cache_resource. Everything else is rebuilt.
What is the difference between cache_data and cache_resource?
cache_data is for values and returns a copy per caller. cache_resource is for connections and models and returns the same object to every session.
Why does my map lose its zoom?
Because the map component is rebuilt from its initial view on every rerun. Store the view in st.session_state and pass it back.
How do I stop a slider re-running everything?
Put it in an st.form. Widgets inside a form do not trigger a rerun until the submit button is pressed โ one rerun instead of twenty.
Why does my folium map re-run the app when I pan it?
st_folium returns map state by default, and a component returning a value triggers a rerun. Restrict it with returned_objects=[...].