Reruns and State Explained: Why Your Map App Redraws Everything
On this page
Problem statement
In Streamlit the script is the app: every interaction β a slider moved, a box ticked β re-executes the file from its first line. There is no callback graph and no component lifecycle, which is why an app takes an afternoon to write, and why its speed and its memory are decided by two things the script controls: which work a cache lets it skip, and which values it keeps in st.session_state between one run and the next.
The consequences are measurable. Reading Europe's 1,333 NUTS 3 regions from a GeoJSON, an uncached app spent 0.34 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.02 s (Example 1).
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
What triggers a rerun, and where state survives it
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 after every pan by default, and measured in a browser each pan re-ran the script once (Example 4).
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.34 s to 0.02 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 the widget's identity from its label and parameters, so changing any of them β options that depend on another filter, say β makes a new widget and drops the 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 is not the problem: it reruns once, on release β dragged across twenty positions in a real browser, it caused one rerun (Example 4). What a form saves is the reruns between widgets: three filters set one after another are three reruns live and one in a form.
Watching the rerun model at work
The data used on this page
The "districts" are Europe's NUTS 3 regions β 1,333 of them once joined to data β from Eurostat GISCO at 1:1 million, with GISCO's urbanβrural typology as the category, and the share of the population aged 65 and over for 2019 to 2023 from the Eurostat table demo_r_pjanind3 as rate_2019 β¦ rate_2023. Eurostat refuses the default python-requests user agent, hence the header.
import math
import os
import geopandas as gpd
import pandas as pd
import requests
YEARS = [2019, 2020, 2021, 2022, 2023]
def eurostat(table, **filters):
"""A Eurostat table as a Series indexed by every dimension."""
stat = requests.get(f"https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/{table}",
params={"format": "JSON", **filters},
headers={"User-Agent": "spatialworkflow.io guide"}, timeout=120).json()
codes = [sorted(stat["dimension"][d]["category"]["index"], key=stat["dimension"][d]["category"]["index"].get)
for d in stat["id"]]
values = [stat["value"].get(str(i)) for i in range(math.prod(stat["size"]))]
return pd.Series(values, index=pd.MultiIndex.from_product(codes, names=stat["id"]), dtype=float)
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_3.geojson")
older = (eurostat("demo_r_pjanind3", indic_de="PC_Y65_MAX", unit="PC",
sinceTimePeriod=YEARS[0], untilTimePeriod=YEARS[-1])
.droplevel(["freq", "indic_de", "unit"]).unstack("time"))
older.columns = [f"rate_{year}" for year in older.columns]
kind = {1: "urban", 2: "intermediate", 3: "rural"}
(nuts.assign(category=nuts["URBN_TYPE"].map(kind))
.rename(columns={"NUTS_ID": "code", "CNTR_CODE": "country", "NAME_LATN": "name"})
[["code", "country", "name", "category", "geometry"]]
.merge(older, left_on="code", right_index=True)
.dropna(subset=[f"rate_{YEARS[-1]}"])
.to_file("districts.gpkg"))
districts = gpd.read_file("districts.gpkg")
print(f"{len(districts):,} NUTS 3 regions in {districts.country.nunique()} countries; "
f"share aged 65+ in {YEARS[-1]}: {districts[f'rate_{YEARS[-1]}'].min():.1f}β"
f"{districts[f'rate_{YEARS[-1]}'].max():.1f}%")
print(districts.category.value_counts().to_dict(),
f"{districts.name.nunique():,} distinct names")
1,333 NUTS 3 regions in 36 countries; share aged 65+ in 2023: 2.7β36.0%
{'intermediate': 619, 'rural': 438, 'urban': 276} 1,332 distinct names
Example 1 β a diagnostic that shows the model at work
%%writefile diagnostic.py
import time
import geopandas as gpd
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):
return gpd.read_file(path)
data = load("districts.geojson") # slow once, free thereafter
st.write(f"run #{st.session_state['runs']} took {(time.perf_counter() - started) * 1000:.0f} ms")
st.selectbox("Change me to trigger a rerun", ["a", "b", "c"], key="trigger")
AppTest runs it headlessly β first as written, then with the @st.cache_data line removed. It reads the regions as GeoJSON, which takes long enough to see:
from streamlit.testing.v1 import AppTest
if not os.path.exists("districts.geojson"): # the same regions as GeoJSON, a slow read
districts.to_file("districts.geojson")
with open("diagnostic_uncached.py", "w") as out:
out.write(open("diagnostic.py").read().replace("@st.cache_data\n", ""))
for name in ("diagnostic.py", "diagnostic_uncached.py"):
at = AppTest.from_file(name, default_timeout=120).run()
lines = [at.markdown[0].value]
for choice in ("b", "c", "a"):
at.selectbox(key="trigger").set_value(choice).run()
lines.append(at.markdown[0].value)
print(f"{name:24} " + " Β· ".join(lines))
print(f"load average {os.getloadavg()[0]:.1f}")
diagnostic.py run #1 took 358 ms Β· run #2 took 19 ms Β· run #3 took 19 ms Β· run #4 took 17 ms
diagnostic_uncached.py run #1 took 320 ms Β· run #2 took 337 ms Β· run #3 took 344 ms Β· run #4 took 341 ms
load average 0.5
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
%%writefile storage.py
import duckdb
import geopandas as gpd
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):
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():
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": 52.0, "longitude": 10.0, "zoom": 3})
history = remember("history", [])
choice = st.selectbox("Country", ["PT", "ES", "FR"], key="country")
if not history or history[-1] != choice:
history.append(choice) # mutating session_state persists
st.caption(f"you have looked at: {' β '.join(history)}")
Two AppTest instances are two sessions. The first looks at three countries; the second starts clean:
import duckdb
duckdb.connect("data.duckdb").close() # the file db() opens read-only
first = AppTest.from_file("storage.py", default_timeout=120).run()
for country in ("ES", "FR"):
first.selectbox(key="country").set_value(country).run()
second = AppTest.from_file("storage.py", default_timeout=120).run()
print("session 1:", first.caption[0].value)
print("session 2:", second.caption[0].value)
session 1: you have looked at: PT β ES β FR
session 2: you have looked at: PT
The copy and the shared object can be seen directly β Streamlit's caches also work outside a running app:
import streamlit as st
@st.cache_data
def load(path):
return gpd.read_file(path)
@st.cache_resource
def connection():
return duckdb.connect()
mine = load("districts.gpkg")
mine.loc[0, "name"] = "changed by one session"
again = load("districts.gpkg")
print(f"cache_data: same object {mine is again}, cached name still {again.loc[0, 'name']!r}")
print(f"cache_resource: same object {connection() is connection()}")
cache_data: same object False, cached name still 'Krapinsko-zagorska ΕΎupanija'
cache_resource: same object True
Example 3 β resetting state deliberately
%%writefile reset_app.py
import streamlit as st
DEFAULTS = {"country": "All", "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
for key, value in DEFAULTS.items(): # defaults live in session_state,
st.session_state.setdefault(key, value) # so the widgets take no value=
st.sidebar.button("Reset filters", on_click=reset_filters, key="reset")
st.selectbox("Country", ["All", "PT", "ES"], key="country")
st.slider("Share aged 65+ at least (%)", 0.0, 40.0, step=0.5, key="min_rate")
if st.sidebar.button("Reset without a callback", key="wrong"):
st.session_state["country"] = "All" # the widget already exists
at = AppTest.from_file("reset_app.py", default_timeout=60).run()
at.selectbox(key="country").set_value("PT").run()
at.slider(key="min_rate").set_value(25.0).run()
print("before:", at.selectbox(key="country").value, at.slider(key="min_rate").value)
at.button(key="reset").click().run()
print("after the callback:", at.selectbox(key="country").value, at.slider(key="min_rate").value)
import contextlib
import io
at.selectbox(key="country").set_value("ES").run()
with contextlib.redirect_stdout(io.StringIO()): # Streamlit prints the traceback too
at.button(key="wrong").click().run()
print("in the script body:", at.exception[0].message.splitlines()[0] if at.exception else "no error")
before: PT 25.0
after the callback: All 0.0
in the script body: `st.session_state.country` cannot be modified after the widget with key `country` is instantiated.
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 the error above. The defaults are set in session_state rather than passed as value=: a widget given both logs a warning that it "was created with a default value but also had its value set via the Session State API" β which the first version of this example did.
Example 4 β what actually triggers a rerun, counted in a browser
AppTest sets widget values directly, so it cannot say how often a real slider or a real map fires. This app appends a line to runs.log every time the script runs; Playwright drives it in headless Chromium β a slider dragged through twenty positions, then five pans of a folium map β once with st_folium's default returns and once with returned_objects=[]:
%%writefile counted_app.py
import os
import folium
import streamlit as st
from streamlit_folium import st_folium
RETURNED = os.environ.get("RETURNED", "default")
with open("runs.log", "a") as log: # one line per script run
log.write("run\n")
st.slider("Share aged 65+ at least (%)", 0.0, 40.0, 0.0, 0.5, key="floor")
m = folium.Map(location=[52.0, 10.0], zoom_start=4)
if RETURNED == "default":
st_folium(m, height=400, width=700, key="map")
else:
st_folium(m, height=400, width=700, key="map", returned_objects=[])
import socket
import subprocess
import sys
import time
import urllib.request
from playwright.sync_api import sync_playwright
def runs():
return sum(1 for _ in open("runs.log")) if os.path.exists("runs.log") else 0
def serve(returned):
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
server = subprocess.Popen(
[sys.executable, "-m", "streamlit", "run", "counted_app.py", "--server.headless", "true",
"--server.port", str(port), "--browser.gatherUsageStats", "false"],
env=dict(os.environ, RETURNED=returned),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
url = f"http://127.0.0.1:{port}"
for _ in range(120):
try:
urllib.request.urlopen(url + "/_stcore/health", timeout=1)
break
except OSError:
time.sleep(0.5)
return server, url
for returned in ("default", "none"):
if os.path.exists("runs.log"):
os.remove("runs.log")
server, url = serve(returned)
try:
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
page = browser.new_page(viewport={"width": 1200, "height": 900})
page.goto(url)
page.wait_for_selector("iframe", timeout=60_000)
time.sleep(4)
start = runs()
thumb = page.get_by_role("slider").bounding_box()
track = page.locator('[data-testid="stSlider"]').bounding_box()
y = thumb["y"] + thumb["height"] / 2
page.mouse.move(thumb["x"] + thumb["width"] / 2, y)
page.mouse.down()
for step in range(1, 21): # twenty positions
page.mouse.move(track["x"] + track["width"] * step / 21, y, steps=3)
time.sleep(0.15)
page.mouse.up()
time.sleep(3)
after_slider = runs()
value = page.locator('[data-testid="stSliderThumbValue"]').inner_text()
frame = page.locator("iframe").first.bounding_box()
cx, cy = frame["x"] + frame["width"] / 2, frame["y"] + frame["height"] / 2
for _ in range(5): # five pans
page.mouse.move(cx, cy)
page.mouse.down()
page.mouse.move(cx + 60, cy + 20, steps=10)
page.mouse.up()
time.sleep(1.5)
time.sleep(3)
after_pans = runs()
time.sleep(5) # then leave it alone
idle = runs() - after_pans
browser.close()
finally:
server.terminate()
server.wait(timeout=30)
print(f"returned_objects {returned:7}: slider dragged to {value}: {after_slider - start} rerun(s); "
f"5 pans: {after_pans - after_slider} rerun(s); idle 5 s: {idle}")
returned_objects default: slider dragged to 38.00: 1 rerun(s); 5 pans: 5 rerun(s); idle 5 s: 0
returned_objects none : slider dragged to 38.00: 1 rerun(s); 5 pans: 0 rerun(s); idle 5 s: 0
Rerunning everything is a reasonable design
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.02 s per interaction with the 1,333 regions cached, against 0.34 s reading them again. 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 map component can keep re-running the app
st_folium returns the map's state β bounds, zoom, last click, drawings β and a component whose returned value changes triggers a rerun. Panning changes the bounds, so every pan re-runs the script: measured in a browser, five pans were five reruns. It does not loop on its own β left alone for five seconds, the app did not re-run at all β but on an app whose rerun costs a second, every pan costs a second.
returned_objects=[...] restricts what it reports. With returned_objects=[] the same five pans caused no rerun; name only what the app actually reads, and interactions that change nothing it uses stop costing anything.
Independent sessions and widget state rules
- 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 why panning a folium map re-runs the app, once per pan.
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?
A slider already reruns only once, on release β dragged through twenty positions in a browser, it caused one rerun. If several widgets are set together, put them in an st.form: nothing reruns until the submit button is pressed.
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 changed value triggers a rerun β measured, one per pan. Restrict it with returned_objects=[...]; with an empty list, five pans caused no rerun.