The idea
The rerun model resets every normal variable on each interaction. That's why the counter from the rerun module always showed 1. For anything the app must remember (a running total, items added to a list, which step of a wizard the user is on), Streamlit provides st.session_state: a dictionary-like store that survives reruns for as long as the user's browser tab is open.
Normal variables are a whiteboard wiped clean before every run. st.session_state is a notebook on the side of the desk that stays put. Each user has their own notebook.
The counter, fixed
Here's the counter from module 2 again, the one that always showed 1. This time the count is kept in st.session_state, which survives from one rerun to the next:
import streamlit as st
if "count" not in st.session_state:
st.session_state.count = 0
if st.button("Add one"):
st.session_state.count += 1
st.write("Count:", st.session_state.count)
Three things happen:
- Initialise once:
if "count" not in st.session_stateis only true on the very first run, socountstarts at 0 exactly once. - Update: the click adds one to the stored value.
- Read: the display uses the stored value, which now survives reruns.
st.session_state.count and st.session_state["count"] are the same thing; use whichever reads better. The in check and .get("count", 0) work as with a dictionary.
Always initialise session state with an if key not in st.session_state guard. Without it, the initialisation line runs on every rerun and wipes the stored value, which is the original bug in a new disguise.
Remembering a list
A small "shopping list" of products to review:
import streamlit as st
if "watchlist" not in st.session_state:
st.session_state.watchlist = []
product = st.selectbox("Product", ["USB-C Hub", "Desk Lamp LED", "Pour-over Kettle", "Ceramic Mug"])
if st.button("Add to watchlist") and product not in st.session_state.watchlist:
st.session_state.watchlist.append(product)
if st.button("Clear"):
st.session_state.watchlist = []
st.write("Watchlist:", ", ".join(st.session_state.watchlist) or "empty")
The list lives in session state, so it grows across clicks instead of starting empty each time.
Widgets with keys are already in session state
Give a widget a key=, and its value is stored under that key automatically:
import streamlit as st
st.slider("Discount (%)", 0, 50, 10, key="discount")
st.write("Discount from session state:", st.session_state.discount)
This is useful when another part of the app (a callback, another page) needs a widget's value. You can also set a widget's value through session state, but only before the widget is drawn in that run: setting it afterwards raises an error, because the widget already decided its value for this run.
Callbacks: run code when something changes
Widgets accept on_change (or on_click for buttons): a function that runs before the next rerun of the script. Callbacks are the clean way to update state in response to an action:
import streamlit as st
if "count" not in st.session_state:
st.session_state.count = 0
def add(amount):
st.session_state.count += amount
def reset():
st.session_state.count = 0
st.button("+1", on_click=add, args=(1,))
st.button("+10", on_click=add, args=(10,))
st.button("Reset", on_click=reset)
st.metric("Count", st.session_state.count)
Why bother? Because the callback runs before the script, the metric already shows the new value on the rerun. With the if st.button(...) style, any code above the button would still see the old value in that run. With several buttons that change the same state, callbacks keep the logic in small named functions instead of a chain of ifs.
args=(1,) passes arguments to the callback (note the comma: it's a tuple).
What session state is (and isn't)
- Per user, per browser tab. Two people using the app have separate state. Refreshing the page starts a new session, and the state is gone.
- Not a database. Anything that must survive a refresh, or be shared between users, belongs in a file or a database (later modules).
- Not for big data. Store small things (choices, ids, lists of names). Load big DataFrames with caching instead (next module).
Why this matters for clean code
Session state is global, mutable state, the kind clean code tries to keep small and explicit. Keep it tidy: initialise every key in one place at the top of the script (or in a function init_state()), name keys clearly, and change state through small callback functions. Then "where does this value change?" has one answer.
Recap
st.session_stateremembers values across reruns, per user session.- Initialise with
if "key" not in st.session_state:; read and write like a dict or attributes. - Widgets with
key=store their value in session state automatically. on_click/on_changecallbacks (withargs=) update state before the rerun.- Session state disappears on refresh: use files or databases for anything permanent, and caching for big data.