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 survives a Streamlit rerun and what does not. the script is the appst.session_stateper session, explicitwidget values, keyed by key=bound to session_state@st.cache_data resultsper server, by arguments@st.cache_resource objectsper server, sharedeverything else in memoryrebuilt from the first lineAny state a user creates inside a component must be captured and passed back spatialworkflow.io
That is the whole explanation for the slow app, the resetting map and the vanishing drawing.

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_folium returns 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.

What triggers a Streamlit rerun, and what to do about each. four triggers, one surprisetriggernotea widget value changesexpected β€” batch with st.form ifexpensivest.rerun()use sparingly, neverunconditionallya component returns a valuest_folium panning β€” restrictreturned_objectsthe file is editeddevelopment onlyThe third is why panning a folium map reruns the app β€” once per pan, measured spatialworkflow.io
Another user’s interaction never re-runs your session β€” sessions are independent.

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.

Script reruns counted in headless Chromium: a slider dragged through twenty positions, five pans of a folium map with default returns, and five with returned_objects set to an empty list. reruns counted in a real browserslider, 20 positions1 rerun, on release5 pans, defaults5 reruns5 pans, returns off0 reruns05The slider was never the problem; the map component was spatialworkflow.io
Counted with Playwright against a live streamlit run (Example 4). Left alone, neither version re-ran at all.

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.

The three places state can live in a Streamlit app. three kinds of memorysession_stateper browser tabmultiplied by concurrent userscache_dataper server, copies outbound it with max_entriescache_resourceper server, shared objectconnections and models onlylocal variablesper rerungone before the next interactionConfusing the first two is how an app’s memory ends up scaling with viewers spatialworkflow.io
Store the selection in session_state, and the data in a cache.

Independent sessions and widget state rules

  • Sessions are independent. One user's rerun does not affect another's.
  • st.session_state is per session; cache_resource is per server. Do not confuse them.
  • Setting a widget's session_state after it is created raises β€” do it in an on_click callback.
  • cache_data hashes arguments; prefix unhashable ones with _ to skip them.
  • ttl and max_entries bound a cache that would otherwise grow.
  • Mutating a cache_data result is safe β€” it is a copy. Mutating a cache_resource object 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.

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.