skip to content

In Streamlit, why does a counter variable reset unless it lives in st.session_state?

level: middleimportance: must knowfreq 70%

answer

  1. the script body re-executes, so assignments repeat
  2. something must outlive the rerun
  3. a per-session dictionary the framework keeps
  4. guard the initialisation or you reset it
  5. widget keys land in that same store

basics

~20 s

Each interaction re-executes the script from the top, so ordinary variables are re-initialised every run. st.session_state is a dict-like store tied to one browser session that Streamlit keeps across reruns, so values placed there persist.

solid answer

~50 s

Streamlit reruns the whole script on every interaction, so `count = 0` at the top means the counter is zero again a moment later. `st.session_state` is the escape hatch: a dictionary-like object scoped to a single browser session that Streamlit preserves between reruns of that session's script. You initialise a key once with a guard — `if "count" not in st.session_state: st.session_state.count = 0` — and mutate it afterwards, typically inside an `on_click` callback that runs just before the rerun. Any widget given a `key` also writes its value into session state under that key, so the widget and the state stay in sync automatically. Two things to be clear about: session state is per session, not per user and not shared across viewers, and it is lost when the tab is closed or the websocket connection is dropped — it is a UI scratchpad, never a database.

code

python · 10 lines
python
import streamlit as st

if "count" not in st.session_state:   # runs once per session
    st.session_state.count = 0

def bump():                           # runs before the rerun
    st.session_state.count += 1

st.button("increment", on_click=bump)
st.write(st.session_state.count)

go deeper

for a junior

Recall the initialise-once idiom with the not-in guard and that reading or writing st.session_state is how a value survives an interaction. Be able to write a working counter.

for a middle

Explain the mechanics: per-session scope, widget keys syncing into the store, callbacks running before the rerun, and the exception you get for assigning to a widget key too late.

for a senior

Show judgment about what belongs where — small control state in session, big data in caches, anything the user must not lose in a real store — and be able to debug state that vanishes after a reconnect.

for a principal

Own the durability story: decide when an internal app needs real persistence, URL-encoded state or an external store rather than in-memory session state, and what that implies for restarts and scaling to multiple replicas.

## Why the reset happens at all Streamlit's execution model is "re-run the script on every interaction". The script body is effectively a render function, so every assignment in it happens again on each run. `count = 0` is not initialisation, it is a statement that executes every time the user touches anything. Incrementing it and expecting growth is like resetting a variable at the top of a loop body and wondering why it never accumulates. ## What st.session_state is `st.session_state` is a dict-like object attached to one *session* — one browser tab connected to the app over one websocket. It supports both access styles: ```python st.session_state["count"] = 0 st.session_state.count = 0 # identical ``` and the usual membership and iteration operations (`in`, `.get()`, `.keys()`, `del`). Streamlit keeps it alive between reruns of that session and destroys it when the session ends. ## The initialise-once idiom Because the script runs repeatedly, initialisation must be guarded: ```python if "count" not in st.session_state: st.session_state.count = 0 ``` Without the guard you would reset the value on every run and be no better off. Do this near the top of the script, once per key, and treat the block as the app's state schema. ## Widget keys write into session state Any widget can take a `key`: ```python name = st.text_input("name", key="name") ``` After this call, `st.session_state["name"]` holds the same value the call returned, and it stays in sync automatically on subsequent runs. This is how you read a widget's value somewhere far from where it was created, and how you pre-seed a widget: set `st.session_state["name"]` *before* the widget is created on that run, and the widget renders with that value. The reverse — assigning to a widget's key *after* the widget has been instantiated in the same run — raises a `StreamlitAPIException`, because you would be contradicting a widget already on screen. ## Callbacks are state mutators Widgets accept `on_change` (or `on_click` for buttons) plus `args` / `kwargs`. The callback runs once, before the rerun triggered by the interaction: ```python def bump(): st.session_state.count += 1 st.button("increment", on_click=bump) st.write(st.session_state.count) ``` The division of labour is clean: callbacks mutate session state, the script body reads state and renders. Rendering inside a callback is a smell — the callback runs before the page is rebuilt. ## What session state is good for - **Remembering a decision** across reruns: which report was loaded, which step of a wizard the user is on, whether a confirmation was accepted. - **Accumulating a list** — chat history, a log of user edits, selected rows. - **Coordinating dependent widgets** — an `on_change` on the country selector clearing the city selection. - **Holding pending edits** before a writeback, so an interaction elsewhere does not lose them. ## What it is emphatically not - **Not shared between users.** Two people using the app have two independent session states. Anything genuinely shared must live in a cache, a database or an external store. - **Not durable.** Closing the tab, a hard refresh, or a dropped and re-established websocket gives a fresh, empty session state. Never make it the only home for work the user would hate to lose; persist to a real store and rehydrate. - **Not a cache.** Caching is keyed on function arguments and shared server-side by `st.cache_data` / `st.cache_resource`; session state is per session and manually managed. Putting a huge DataFrame in session state per user multiplies memory by the number of concurrent sessions. - **Not a place for secrets or authorisation decisions you trust.** The values originate from the browser side of the interaction; enforce authorisation on the data-access path. ## Debugging patterns When state "disappears", check three things in order: is the key initialised behind a guard rather than reassigned every run; is something re-creating the widget with a different `key` (a changed key is a different widget with a fresh default); and did the connection drop, which resets the session entirely. When state "lags one interaction behind", the usual cause is reading a value the callback has not yet updated, or mutating state after the widgets that display it were already rendered — call `st.rerun()` after the mutation so the page reflects the new state in a fresh run.

  • What happens to session state when the user refreshes the browser tab?
    It is gone. A refresh opens a new session with a new websocket and an empty st.session_state, so the app renders as if visited for the first time. If the state matters — a draft, a multi-step form — persist it to a database or a URL query parameter and rehydrate on startup.
  • Why does assigning to a widget's session-state key after the widget is created raise an error?
    Because the widget has already rendered with a value on that run, and letting code overwrite its key would leave the screen and the state disagreeing. Streamlit raises a StreamlitAPIException instead. Set the key before the widget is instantiated, or use an on_change callback, which runs before the rerun.
  • Is it a good idea to keep a large DataFrame in session state?
    Rarely. Session state is per session, so a 500 MB frame becomes 500 MB per concurrent user in one process. Keep the data in a cache, which is shared across sessions and evictable, and keep only small selections, filters and identifiers in session state.

saying these in an interview costs you the question

  • Thinks session state is shared between all app users
  • Treats session state as durable storage instead of a scratchpad
  • Initialises a key without the not-in guard, resetting it every run
  • Renders output inside an on_change callback
  • Stores whole DataFrames per session and then wonders about memory

context