# Streamlit State Survival: session_state vs Reruns, cache_data vs cache_resource, and Widget-Key Collisions
The concrete problem this skill solves: counters that never count, DataFrames that re-download on every click, and crashes with `StreamlitDuplicateElementKey` / `DuplicateWidgetID` — all caused by Streamlit's execution model: **every widget interaction reruns the entire script top to bottom in a blank slate**. This skill gives you the rules for what survives a rerun, what doesn't, and where each kind of persistence belongs.
## 1. The rerun model: plain variables die, session_state survives
Every button click, slider move, or text input re-executes your script from the first line. Each run starts fresh: no local variables carry over. Use `st.session_state` for anything that must persist across reruns **within one user's browser tab** (one session).
```python
import streamlit as st
# WRONG: `count` is re-created as 0 on every rerun, so it can never exceed 1.
count = 0
if st.button("Increment"):
count += 1
st.write("Count =", count)
# RIGHT: initialize once, guard with a membership test.
if "count" not in st.session_state:
st.session_state.count = 0
if st.button("Increment"):
st.session_state.count += 1
st.write("Count =", st.session_state.count)
```
Rules from the docs:
- Always initialize before use: `if "key" not in st.session_state: st.session_state.key = DEFAULT`. Reading an uninitialized key raises an exception.
- Both APIs work: `st.session_state.key` (attribute) and `st.session_state["key"]` (dict-style).
- Session state is per browser tab (per WebSocket connection). Reloading the tab or navigating away resets it. The Streamlit server crashing wipes it. It is **not** a durable database.
- Session state persists across pages inside a multipage app.
- A callback (`on_click` / `on_change`) executes **before** the script reruns. Read the widget's new value from `st.session_state` inside the callback (e.g. `st.session_state.MY_KEY`); do not pass the value through `args`/`kwargs`.
```python
if "total" not in st.session_state:
st.session_state.total = 0
def add_five():
st.session_state.total += st.session_state.step # reads the LATEST widget value
st.number_input("Step", min_value=1, value=5, key="step")
st.button("Add", on_click=add_five)
st.write("Total:", st.session_state.total)
```
When you actually need a full extra rerun (e.g. a change must be visible in content drawn *above* the widget that caused it), call `st.rerun()`. It re-executes the whole script. Prefer callbacks and containers first — `st.rerun()` is slower, makes logic harder to follow, and can loop forever if placed behind a condition that stays true.
## 2. st.cache_data vs st.cache_resource: copy vs singleton
Caching solves a different problem than session state: **expensive function calls that should run once**, not per-user values. Both decorators cache globally (across all sessions and users), keyed on the function's input parameters plus its source code. Pick the wrong decorator and you get silent data corruption or unpicklable-object crashes.
| | `@st.cache_data` | `@st.cache_resource` |
|---|---|---|
| For | Computations returning **data** | Global **resources** to create once |
| Examples | DataFrame from CSV, NumPy transforms, SQL query results, API responses | DB connection, ML model, file handle, thread pool |
| Return-value requirement | Must be pickle-serializable | Need not be serializable |
| What you get back each call | A **fresh copy** (pickle round-trip) | The **same object** (singleton) |
| Mutation safety | Safe: mutating the result can't corrupt the cache | Unsafe: mutations directly mutate the cached object — it must be thread-safe |
```python
import streamlit as st
import pandas as pd
@st.cache_data # data: serialized, copied on each call
def load_data(url):
return pd.read_csv(url)
df = load_data("https://example.com/dataset.csv")
st.dataframe(df)
```
```python
import streamlit as st
@st.cache_resource # resource: one global instance, returned as-is
def init_connection():
# st.secrets holds the connection parameters (host, database, user, password)
return DATABASE_DRIVER.connect(**st.secrets["postgres"])
conn = init_connection() # same connection object on every rerun, every session
```
The `cache_data` gotcha: because each call returns a deserialized **copy**, mutating the returned object is harmless to the cache — but it also means `df is df` is False across calls, and the pickle round-trip costs time on very large objects. The `cache_resource` gotcha is the mirror: there is exactly **one** global instance shared by all users and all sessions. If your code mutates it (appends to a list, changes model weights, advances a cursor), every other session sees the mutation. Return thread-safe, effectively read-only objects from `cache_resource`.
Decision rule from the docs: if you could store it in a database or on disk (str, int, float, array, DataFrame, list, dict), use `st.cache_data`. If it is a live, unserializable object you would never persist (connection, model, handle, thread), use `st.cache_resource`. When unsure, start with `st.cache_data`. To expire cached data, use `ttl`: `@st.cache_data(ttl=3600)` re-runs the function after one hour.
## 3. Widget keys: why duplicates crash your app
Every widget gets an internal key. If you don't pass `key=`, Streamlit generates one from the widget's **structure** (type + label + parameters). Two widgets with identical structure in the same script run produce the same generated key, and Streamlit raises `StreamlitDuplicateElementKey` (older versions: `DuplicateWidgetID`):
```
StreamlitDuplicateElementKey: There are multiple elements with the same key='MY_KEY'.
```
Common triggers and fixes:
1. **Widgets in loops.** A loop body executes multiple times with identical labels. Always include the loop variable in the key:
```python
for i, filename in enumerate(filenames):
approved = st.checkbox(f"Approve {filename}", key=f"approve_{i}")
```
2. **Widgets rendered in multiple branches/tabs.** Streamlit renders all tab contents in the same pass — a key that is unique *within* one tab collides with the same key in another tab. Namespace keys by context: `key=f"settings_{tab}_{row}"`.
3. **Copy-pasted widgets.** Any two identical `st.button("OK")` calls collide. Give each an explicit unique `key`.
Every widget with a `key` is automatically mirrored in `st.session_state` under that key — `st.session_state.MY_KEY` is the widget's current value. Hard rules on writing widget state:
- **Never set a widget's key in `st.session_state` after the widget has been instantiated in the same run.** This raises `StreamlitAPIException`. Set defaults *before* the widget line executes (or pass `value=` to the widget instead).
- Do not set the key via session state **and** pass `value=` in the widget declaration at the same time — Streamlit warns on the first run; pick one.
- `st.button`, `st.download_button`, and `st.file_uploader` **cannot** have their state set via the session-state API at all. Their `True` state is ephemeral — valid for exactly one run.
```python
# WRONG: raises StreamlitAPIException
slider = st.slider("Choose a value", 1, 10, 5, key="my_slider")
st.session_state.my_slider = 7
# RIGHT: seed the default before the widget is created
if "my_slider" not in st.session_state:
st.session_state.my_slider = 7
slider = st.slider("Choose a value", 1, 10, 5, key="my_slider")
```
## 4. Quick decision checklist
- Value must survive reruns for one user → `st.session_state`, initialized with the `if "k" not in` guard.
- Expensive pure computation or data load, same result for everyone → `@st.cache_data`.
- One shared live object for everyone (connection, model) → `@st.cache_resource`; never mutate the returned object.
- User-specific result that must not leak across sessions → `st.session_state`, never the global caches.
- `StreamlitDuplicateElementKey` crash → find the colliding `key=` (often in a loop or across tabs) and make it unique.
- Display above the widget needs updating after a button click → restructure with a callback or container before reaching for `st.rerun()`.
- State vanished after browser reload → expected: session state lives only as long as the tab's WebSocket connection. Persist real data to a database or file.