Skip to content

Fix: Streamlit Not Working — Session State, Cache, and Rerun Problems

FixDevs · (Updated: )

Part of:  Python Errors

Quick Answer

How to fix Streamlit errors — session state KeyError state not persisting, @st.cache deprecated migrate to cache_data cache_resource, file upload resetting, slow app loading on every interaction, secrets not loading, and widget rerun loops.

The Error

You click a button and every widget resets to its default value — the model output disappears, the dropdown reverts to the first option, and the uploaded file is gone.

Or you access session state and get a KeyError:

KeyError: 'st.session_state has no key "results". Did you forget to initialize it?'

Or the app is slow because it reloads a 2GB model on every user interaction:

@st.cache   # DeprecationWarning: st.cache is deprecated.
def load_model():
    return torch.load("model.pt")

Or you deploy to Streamlit Community Cloud and the app crashes because secrets aren’t loading:

KeyError: 'st.secrets has no attribute "API_KEY".'

Most Streamlit confusion traces back to one root cause: the script re-executes from top to bottom on every user interaction. Understanding that model solves most issues before they require a fix.

Why This Happens

Streamlit’s execution model is intentionally simple: every button click, slider move, text input, or selectbox change triggers a full re-run of your Python script. Variables defined at the top of the script are reset. Functions are called again. Data is reloaded.

This design makes Streamlit easy to start with — you write a script, not a callback-heavy event loop. But it means any state you don’t explicitly preserve is lost on every interaction. The tools for preserving state are st.session_state (for values) and @st.cache_data/@st.cache_resource (for expensive computations).

Platform and Environment Differences

Streamlit hides most platform differences behind the same streamlit run command, but the hosting environment changes how session state, file storage, and cache resource sharing behave — often in ways that work locally and break in production.

Streamlit Community Cloud. The free tier gives one container per app with ~1 GB RAM, ephemeral filesystem, and a single Python process. @st.cache_resource works as expected because all users hit the same process. But the filesystem resets on every redeploy and every restart, so writing model checkpoints or user uploads to ./data/ loses them. Use S3, GCS, or a database for any persistence. Community Cloud also forces a sleep state after 7 days of inactivity — the first request wakes the app and takes 20–60 seconds.

Self-host on Render, Fly.io, Heroku. These platforms give more RAM and predictable uptime but also have ephemeral filesystems. Render’s free tier sleeps after 15 minutes of inactivity; Fly.io’s auto_stop_machines does similar. The wake-up cost is worse for Streamlit than for an API because the app must rebuild session state from scratch. Production deployments should pin a minimum 1 machine (min_machines_running = 1 on Fly) to avoid cold starts.

Local filesystem vs ephemeral storage. On your laptop, pd.read_csv("data.csv") reads a file you can see in Finder. On Streamlit Cloud and most container platforms, that file only exists for the lifetime of the container. The same code works locally and silently uses an empty CSV in production. Always use absolute paths for read-only assets bundled in the repo, and an external store (S3, R2, Postgres) for writes.

Session state per platform. Streamlit’s st.session_state is per-browser-session, scoped to the Streamlit server process. On a single-replica deployment (Community Cloud, Render single instance), this works as expected. On multi-replica deployments behind a load balancer without sticky sessions, the same browser session can land on different replicas and see empty state. Solutions: enable sticky sessions at the load balancer, or persist state to Redis. Cloud Run with --max-instances=1 is a quick fix for low-traffic apps.

vs Gradio and Dash deployment. Gradio embeds a FastAPI backend and supports gradio.queue() for concurrent users, making it easier to deploy on serverless platforms. Streamlit holds a WebSocket per session — serverless platforms with short request timeouts (Lambda’s 30s API Gateway, Vercel) won’t work. Dash uses Flask under the hood and scales horizontally with sticky sessions. For Gradio-specific deployment issues that contrast with Streamlit, see Gradio not working.

Docker memory limits. ML models loaded via @st.cache_resource stay in RAM for the life of the process. A 2 GB transformer model plus pandas plus the Streamlit runtime easily hits 3 GB. Container platforms with default 512 MB or 1 GB limits OOM-kill silently — the user sees a connection reset, no traceback in logs. Set explicit memory limits matching your actual usage.

WSL2 on Windows. streamlit run app.py inside WSL2 binds to the WSL network namespace. Windows browsers can reach localhost:8501, but only if WSL2’s port forwarding is active. If the app loads then disconnects, restart WSL with wsl --shutdown and rerun.

Fix 1: Everything Resets on Interaction — The Rerun Model

Before fixing anything, understand what triggers a rerun:

  • Any widget interaction (button click, slider, selectbox, text input)
  • st.rerun() called explicitly
  • The browser tab refreshing

Variables defined at script level are re-created fresh every rerun:

import streamlit as st

# This resets to 0 on every interaction — not a counter
count = 0
if st.button("Increment"):
    count += 1   # Adds 1 to 0 every time — never goes above 1
st.write(count)

Use session state to persist values across reruns:

import streamlit as st

# Initialize once — the `not in` check prevents resetting on each rerun
if "count" not in st.session_state:
    st.session_state.count = 0

if st.button("Increment"):
    st.session_state.count += 1

st.write(f"Count: {st.session_state.count}")

Use st.form() to batch widget interactions and only trigger a rerun when the user submits — not on every individual widget change:

import streamlit as st

with st.form("search_form"):
    query = st.text_input("Search query")
    max_results = st.slider("Max results", 1, 100, 10)
    submitted = st.form_submit_button("Search")

# Code below only runs after the form is submitted, not on every keystroke
if submitted:
    st.write(f"Searching for: {query}, limit: {max_results}")

Without a form, each keystroke in st.text_input triggers a full rerun — 10 keystrokes = 10 model calls.

Fix 2: Session State KeyError — Initialize Before Use

KeyError: 'st.session_state has no key "results".'
StreamlitAPIException: Session state key "results" does not exist.

You’re accessing a key that hasn’t been set yet. Session state is empty on the first run of a fresh session.

Always guard with in before accessing:

import streamlit as st

# WRONG — KeyError on first run because 'results' doesn't exist yet
results = st.session_state["results"]

# CORRECT option 1 — check before accessing
if "results" in st.session_state:
    st.write(st.session_state.results)
else:
    st.info("Run the analysis to see results.")

# CORRECT option 2 — initialize at the top of the script
if "results" not in st.session_state:
    st.session_state.results = None
if "model" not in st.session_state:
    st.session_state.model = None
if "history" not in st.session_state:
    st.session_state.history = []

Attribute-style access (st.session_state.key) raises AttributeError instead of KeyError, but the same guard applies. Use st.session_state.get("key", default) for safe access with a fallback:

# Safe access with default — never raises
results = st.session_state.get("results", None)
history = st.session_state.get("history", [])

Widget keys are automatically stored in session state. Setting a key parameter on any widget creates a session state entry that’s managed by Streamlit:

# This creates st.session_state["search_input"] automatically
query = st.text_input("Search", key="search_input")

# You can read it from session state (same value)
st.write(st.session_state.search_input)

# But don't manually set a widget's key in session state — it conflicts
# WRONG: st.session_state.search_input = "new value" (raises StreamlitAPIException)

Fix 3: @st.cache Is Deprecated — Migrate to New Cache APIs

DeprecationWarning: st.cache is deprecated. Please use one of Streamlit's new caching decorators:
  - st.cache_data for data objects (DataFrames, lists, dicts, etc.)
  - st.cache_resource for shared resources (ML models, DB connections, etc.)

@st.cache was removed in Streamlit 1.18 (February 2023). The replacement is two focused decorators:

OldNewUse for
@st.cache@st.cache_dataDataFrames, API responses, computed results
@st.cache@st.cache_resourceML models, DB connections, shared singletons
@st.experimental_memo@st.cache_dataSame
@st.experimental_singleton@st.cache_resourceSame

@st.cache_data — caches the return value, returns a copy each call:

import streamlit as st
import pandas as pd

@st.cache_data
def load_sales_data(file_path: str) -> pd.DataFrame:
    """Runs once per unique file_path argument, then returns cached copy."""
    df = pd.read_csv(file_path)
    df['date'] = pd.to_datetime(df['date'])
    return df

# On first call: reads CSV, processes, caches
# On subsequent calls with same path: returns cached copy instantly
df = load_sales_data("data/sales_2025.csv")

@st.cache_resource — caches the object itself, returns the same shared instance:

import streamlit as st
import torch

@st.cache_resource
def load_model(model_path: str):
    """Loaded once per unique path, shared across all sessions."""
    model = torch.load(model_path, map_location='cpu')
    model.eval()
    return model

# Model loads once when the app starts, shared by all concurrent users
model = load_model("models/classifier.pt")

Key difference: cache_data makes a copy per call (safe for mutable objects like DataFrames — mutations in one session don’t affect others). cache_resource shares one instance (right for models and connections that are expensive to create and safe to share).

Clear the cache when the underlying data changes:

# Clear all caches
st.cache_data.clear()
st.cache_resource.clear()

# Or add a "Refresh Data" button
if st.button("Refresh Data"):
    st.cache_data.clear()
    st.rerun()

Force cache bypass with ttl (time-to-live):

@st.cache_data(ttl=3600)    # Re-run function after 1 hour
def fetch_live_prices():
    return requests.get("https://api.example.com/prices").json()

Fix 4: App Is Slow — Data Reloads on Every Click

If your app feels sluggish, every click probably re-runs an expensive operation. Audit what runs on each interaction:

import streamlit as st
import pandas as pd
import time

# WRONG — this re-reads from disk on every button click, slider move, etc.
df = pd.read_csv("large_dataset.csv")   # 5 seconds every rerun

# CORRECT — cached after first load
@st.cache_data
def load_data():
    return pd.read_csv("large_dataset.csv")

df = load_data()   # ~5s first run, instant after

Move all expensive operations into cached functions. The rule: if a function call takes more than 100ms and doesn’t need to re-run on every interaction, cache it.

import streamlit as st
from sklearn.ensemble import RandomForestClassifier
import numpy as np

@st.cache_resource   # Model is shared — expensive to train
def train_model(n_estimators: int):
    X = np.random.rand(10000, 20)
    y = (X[:, 0] + X[:, 1] > 1).astype(int)
    clf = RandomForestClassifier(n_estimators=n_estimators)
    clf.fit(X, y)
    return clf

@st.cache_data       # Data objects — return copies
def get_test_data():
    return np.random.rand(100, 20)

n_trees = st.slider("Number of trees", 10, 200, 100)
model = train_model(n_trees)    # Re-trains only when n_trees changes
X_test = get_test_data()
predictions = model.predict(X_test)
st.write(f"Accuracy: {(predictions == (X_test[:, 0] + X_test[:, 1] > 1)).mean():.2%}")

Use st.spinner() to show progress for operations that can’t be cached:

with st.spinner("Loading data..."):
    df = fetch_from_database()   # Shows spinner while loading
st.success("Done!")

Fix 5: File Upload Resets After Interaction

# User uploads a file, selects options, clicks button — file disappears
uploaded_file = st.file_uploader("Upload CSV")
# File resets to None on the next rerun

The UploadedFile object lives in widget state for one rerun cycle. When any other widget changes (a dropdown selection, button click), the file uploader resets unless you explicitly store the contents.

Store file contents in session state immediately on upload:

import streamlit as st
import pandas as pd

# Save the file contents to session state when uploaded
uploaded_file = st.file_uploader("Upload a CSV file", type=["csv"])
if uploaded_file is not None:
    # Read and store immediately — before any other interaction can reset it
    if "uploaded_df" not in st.session_state or st.session_state.get("last_filename") != uploaded_file.name:
        st.session_state.uploaded_df = pd.read_csv(uploaded_file)
        st.session_state.last_filename = uploaded_file.name

# Now use the stored DataFrame — persists across reruns
if "uploaded_df" in st.session_state:
    df = st.session_state.uploaded_df
    st.write(f"Loaded {len(df)} rows")
    
    # Other widgets won't reset the DataFrame
    column = st.selectbox("Choose column", df.columns)
    st.bar_chart(df[column])

Reading different file types from UploadedFile:

import streamlit as st
import pandas as pd
import json

uploaded_file = st.file_uploader("Upload file", type=["csv", "json", "xlsx"])

if uploaded_file is not None:
    file_type = uploaded_file.name.split(".")[-1].lower()

    if file_type == "csv":
        df = pd.read_csv(uploaded_file)
    elif file_type == "xlsx":
        df = pd.read_excel(uploaded_file)
    elif file_type == "json":
        data = json.load(uploaded_file)   # UploadedFile is file-like
    
    # Reset read position if reading multiple times
    uploaded_file.seek(0)
    raw_bytes = uploaded_file.read()

Pro Tip: Use st.file_uploader("...", accept_multiple_files=True) to accept multiple files. It returns a list of UploadedFile objects — or an empty list when no files are uploaded, never None.

Fix 6: streamlit: command not found

$ streamlit run app.py
bash: streamlit: command not found

Streamlit installs a streamlit script into your Python environment’s bin/ or Scripts/ directory. If that directory isn’t on your PATH, the command isn’t found.

The immediate fix — run via Python module:

python -m streamlit run app.py

This bypasses the PATH issue entirely.

Add the scripts directory to PATH:

# Find where streamlit is installed
python -m site --user-scripts   # e.g., ~/.local/bin

# Add to PATH (bash)
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc

If you’re using a virtual environment, activate it first:

source .venv/bin/activate
streamlit run app.py

Check the installed version and location:

python -m streamlit --version
pip show streamlit   # Shows Location field

For module-not-found errors if Streamlit installs successfully but app.py can’t import its dependencies, the environment issue is identical to the one covered in Python ModuleNotFoundError in venv.

Fix 7: Secrets and Environment Variables

Local development — store secrets in ~/.streamlit/secrets.toml:

# ~/.streamlit/secrets.toml (never commit this file)
API_KEY = "sk-abc123"
DATABASE_URL = "postgresql://user:pass@host:5432/db"

[database]
host = "localhost"
port = 5432
name = "mydb"

Access in your app:

import streamlit as st

# Top-level keys
api_key = st.secrets["API_KEY"]

# Nested keys
db_host = st.secrets["database"]["host"]
# or
db_host = st.secrets.database.host

On Streamlit Community Cloud — set secrets in the app settings UI (App → Settings → Secrets). The format is the same TOML structure. Never put secrets in requirements.txt, app.py, or any committed file.

Fall back to environment variables for local/CI flexibility:

import streamlit as st
import os

# Try Streamlit secrets first, fall back to environment variable
api_key = st.secrets.get("API_KEY") or os.environ.get("API_KEY")
if not api_key:
    st.error("API_KEY not configured. Set it in .streamlit/secrets.toml or as an environment variable.")
    st.stop()   # Halts script execution cleanly

st.stop() is the correct way to abort the script after showing an error — it stops execution of all remaining code without throwing an exception.

requirements.txt for Community Cloud deployment must list all your dependencies explicitly:

# requirements.txt — place in the root of your GitHub repo
streamlit>=1.30.0
pandas>=2.0.0
scikit-learn>=1.3.0
plotly>=5.0.0
requests>=2.31.0

Fix 8: Layout and Component Issues

Columns don’t wrap on narrow screens — Streamlit columns use CSS flexbox and don’t wrap automatically. On mobile or narrow windows, columns get squeezed. Set column widths as ratios and test on the target screen size:

import streamlit as st

# Equal columns
col1, col2, col3 = st.columns(3)

# Custom ratios — col1 is twice as wide as col2 and col3
col1, col2, col3 = st.columns([2, 1, 1])

# With gap
col1, col2 = st.columns(2, gap="large")   # "small", "medium", "large"

with col1:
    st.metric("Revenue", "$1.2M", "+12%")
with col2:
    st.metric("Users", "42,000", "+8%")

st.empty() for updating a single element in-place:

import streamlit as st
import time

placeholder = st.empty()

for i in range(10):
    with placeholder.container():
        st.write(f"Step {i+1}/10")
        st.progress((i + 1) / 10)
    time.sleep(0.5)

placeholder.empty()   # Clear it when done

Without st.empty(), each loop iteration adds a new element — you get 10 progress bars stacked.

Tabs:

import streamlit as st

tab1, tab2, tab3 = st.tabs(["Overview", "Data", "Model"])

with tab1:
    st.header("Overview")
    st.write("Summary here")

with tab2:
    st.header("Data Explorer")
    # Only runs when user clicks this tab... actually no:
    # All tab code runs on every rerun — only display is deferred

Common Mistake: Assuming code inside with tab2: only runs when that tab is active. All code runs on every rerun — only the display is tab-gated. If tab content is expensive to compute, cache it:

@st.cache_data
def get_model_metrics():
    # Expensive computation — cached regardless of which tab is active
    return compute_metrics()

with tab2:
    metrics = get_model_metrics()   # Cached — fast even though code always runs
    st.table(metrics)

Still Not Working?

Multipage Apps

Create a pages/ directory alongside your main app.py. Any .py file in pages/ becomes a separate page in the sidebar navigation:

my_app/
├── app.py              ← Main page
├── pages/
│   ├── 1_Data.py       ← "Data" page
│   ├── 2_Model.py      ← "Model" page
│   └── 3_Results.py    ← "Results" page
└── requirements.txt

Session state persists across page navigations within the same session. On page switch, Streamlit re-runs the new page’s script from the top.

Running on a Different Port

If port 8501 is taken, change it:

streamlit run app.py --server.port 8502

For general port conflict diagnosis, the patterns in port already in use apply — find the PID using the port and kill it, or just choose a free port.

Docker Deployment

FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8501

# Must bind to 0.0.0.0 — default localhost binding isn't reachable outside the container
CMD ["streamlit", "run", "app.py",
     "--server.port=8501",
     "--server.address=0.0.0.0"]

Without --server.address=0.0.0.0, Streamlit binds to 127.0.0.1 inside the container and is unreachable from the host.

Using Streamlit with ML Models

For large ML models that need to stay loaded across sessions, @st.cache_resource is the right pattern — it loads the model once per server process, shared across all users. If you’re deploying scikit-learn models, see scikit-learn not working for patterns around model persistence with joblib.

st.rerun() Loops and RerunException

Calling st.rerun() inside a callback or in code that always runs creates an infinite loop — the script reruns, hits st.rerun() again, and never finishes. Streamlit catches this as a RerunException after a threshold and stops the app. Wrap any st.rerun() in a condition that flips a flag in session state, so the second pass takes the other branch.

CORS and Iframe Embedding

Streamlit blocks iframe embedding by default. To embed an app inside another site (your docs, a portal), set --server.enableCORS=false and --server.enableXsrfProtection=false. Production embedding also needs the parent site listed in [server] allowedOrigins in .streamlit/config.toml. Without this, the iframe loads but WebSocket connections are blocked and widgets don’t respond.

WebSocketsConnectionFailed Behind a Proxy

When fronted by nginx, Cloudflare, or an enterprise proxy, Streamlit’s WebSocket upgrade can be stripped. Symptom: the app loads, shows the initial render, then says “Connecting…” forever. Fix: configure the proxy to forward Upgrade and Connection headers. For Cloudflare specifically, enable WebSockets in the Network settings of the dashboard — it’s off by default on free plans.

streamlit hello Works But Your App Doesn’t

If the demo app runs but yours crashes immediately, the error is in your imports. Streamlit swallows import-time errors and shows a generic browser message. Run python -c "import app" (substitute your script name) to see the real traceback. The most common cause: missing dependencies in requirements.txt that work locally because of a globally installed package but fail in the container.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles