Designing Streamlit Apps Around Session State: An Architecture Note
An architecture note on st.session_state: what it guarantees, where the trust and memory boundaries sit, how to verify behavior, and when to move persistence elsewhere.
09 Jul 2026, 03:16 UTC

The problem: a stateless script that must act stateful
Streamlit reruns your entire Python script top to bottom on every user interaction — a button click, a slider move, a form submit. Local variables are recreated from scratch on each pass. If your app needs a shopping cart, a multi-step wizard, or a cached API result, you need a deliberate place to put that data. st.session_state is that place: a dictionary-like object that survives reruns within a single browser session. This note covers when it is the right design, where the trust boundaries sit, and what breaks at scale.
Requirements the design must satisfy
Before reaching for session state, pin down what you actually need:
- Survive reruns, not reloads. Session state persists across script reruns but is lost on a full browser refresh, a closed tab, or a session timeout. If users must resume work later, you need external persistence (a database, a file, a signed cookie) in addition to session state.
- Per-user isolation. Each browser tab gets its own session state. Two concurrent users never share keys. This is a feature — but it also means memory cost scales with concurrent users.
- Small, serializable-ish payloads. Session state lives in server RAM. It is well suited to IDs, flags, form drafts, and small DataFrames. It is the wrong home for a 2 GB dataset.
The smallest suitable design
The minimal pattern has three parts: initialize once, read during the run, write in callbacks or after widget interaction.
import streamlit as st
# 1. Initialize once — guard against KeyError on first run
if "cart" not in st.session_state:
st.session_state.cart = []
# 2. Write via a callback so the mutation happens before the rerun
# that the interaction triggers
def add_item():
st.session_state.cart.append(st.session_state.item_input)
st.text_input("Item", key="item_input")
st.button("Add to cart", on_click=add_item)
# 3. Read during the run
st.write(f"Cart ({len(st.session_state.cart)} items):", st.session_state.cart)Two details matter here. First, the key="item_input" argument binds the widget bidirectionally: st.session_state.item_input always reflects the widget, and assigning to it before the widget is instantiated sets the widget's value. Second, callbacks (on_click, on_change) run before the script reruns, which makes them the safe place for mutations that depend on other widget values.
Initialize keys at the top of the script (or in an ensure_state() helper) rather than deep in conditional branches. A key created only inside if page == 2: will raise KeyError on the first visit to page 2 if anything earlier reads it.
Trust and data boundaries
Treat session state as server-side, per-session, and trusted only within that session. Concretely:
- Users cannot directly read another session's state, but anything rendered with
st.writeor leaked into an exception message is visible to that session's user. Do not store secrets (API keys for other users, connection strings) in session state; keep them inst.secretsor environment variables. - Session state is not an authentication boundary. A logged-in flag in session state is fine as a UI convenience, but every rerun should re-validate anything security-critical if the underlying credential can expire.
- Values persist only in the server process's memory. With multiple replicas behind a load balancer, a session is pinned to one process; a restart or failover wipes it. Design for that loss rather than fighting it.
Operational checks
Three quick verifications, all runnable locally with streamlit run app.py:
- Persistence across reruns: build the counter from the snippet above. Click "Add to cart" three times; the list should grow to three items. If it resets to one, the initialization guard is missing or the key is being overwritten.
- Widget binding: add a button whose callback does
st.session_state.item_input = "". The text box should clear on click. Note that assigning to a widget's key after the widget is instantiated in the same run raises an exception — assignments belong in callbacks or before the widget call. - Memory behavior: store a deliberately large object (e.g., a DataFrame of a few hundred MB) in session state, then open several incognito windows and watch the process RSS in your OS monitor. This gives you a rough per-session cost to multiply against expected concurrency.
Failure modes
- Memory exhaustion at scale. N concurrent users × M MB of state each is your floor. Mitigate by storing only IDs or query parameters in state and re-fetching data with
@st.cache_data, which shares one cached copy across sessions instead of duplicating it per user. - Lost work on refresh. A user who refreshes mid-wizard loses everything. If that is unacceptable, checkpoint drafts to a database keyed by a user ID or a token in the URL query params (
st.query_params), and rehydrate on load. - Stale derived state. If key B is computed from key A, and A changes via a widget, B is stale until something recomputes it. Recompute derived values in the
on_changecallback of the source widget, or compute them inline each run instead of storing them. - Callback races. Rapid interactions (or custom components firing events) can interleave callbacks touching the same keys. Keep callbacks short and idempotent; avoid read-modify-write sequences that assume no other event lands in between.
When to change the design
Session state is the right default for per-user, per-visit UI state. Move beyond it when any of these become true: data must survive refreshes (add a database), data must be shared across users (use st.cache_data/st.cache_resource or an external store), per-session memory exceeds your budget (store references, not payloads), or you need audit trails and multi-device continuity (session state has neither). The decision rule is simple: session state answers "what is this user doing right now," and nothing more.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.