cheat sheet
streamlit
Build interactive web apps for data and ML in pure Python. Covers widgets, layout, session state, caching, multipage apps, and deployment patterns.
streamlit — Data Apps in Pure Python
What it is
Streamlit is an open-source Python framework, maintained by Snowflake, for turning data scripts into shareable web apps without writing any HTML, CSS, or JavaScript. You write a regular app.py top-to-bottom; Streamlit re-executes the script every time a widget changes and re-renders the page from the resulting values. The model is wildly productive for prototypes, internal dashboards, and ML demos — and it scales well enough for small production apps. The main alternatives are gradio (lighter, ML-demo focused), nicegui (Vue-style components in Python), and reflex (more app-like with proper routing and components).
Streamlit's execution model is "re-run on every interaction". This is the single most important thing to understand — it's why
st.session_stateand@st.cache_dataexist. Code that runs cheaply at the top of the file is fine; expensive computation must be cached.
Install
# Recommended (uv is fastest)
uv pip install streamlit
# Or with pip
pip install streamlit
# Sanity check
streamlit hello
Output: (none — opens a demo app at http://localhost:8501)
Verify the install:
streamlit --version
Output:
Streamlit, version 1.35.0
Syntax
The base invocation is streamlit run <script.py>. Streamlit reserves command-line flags from the script's own argv, so use -- to separate them. Once running, the dev server hot-reloads on file save; click "Rerun" in the browser or set "Run on save" in the settings menu.
streamlit run app.py
streamlit run app.py --server.port 8502 --server.headless true
streamlit run app.py -- --my-flag value # forward args to the script
Output:
You can now view your Streamlit app in your browser.
Local URL: http://localhost:8501
Network URL: http://192.168.1.42:8501
Essential options
| Command / option | Meaning |
|---|---|
streamlit run <script> | Start the dev server for the given script |
--server.port 8502 | Override the default port (8501) |
--server.headless true | Don't auto-open a browser (servers, CI) |
--server.address 0.0.0.0 | Bind on all interfaces (LAN access) |
--server.runOnSave true | Auto-rerun on file save |
--theme.base dark | Force the dark theme |
streamlit hello | Launch the built-in demo app |
streamlit config show | Print the merged config |
streamlit cache clear | Clear all @st.cache_data / @st.cache_resource entries |
Hello, world
The minimum app — a title, a slider, and one piece of derived state. Save as app.py and run with streamlit run app.py.
# app.py
import streamlit as st
st.title("Hello, Streamlit")
name = st.text_input("Your name", value="Alice Dev")
n = st.slider("How many greetings?", min_value=1, max_value=10, value=3)
for i in range(n):
st.write(f"{i + 1}. Hello, {name}!")
streamlit run app.py
Output:
You can now view your Streamlit app in your browser.
Local URL: http://localhost:8501
The page shows a title, a text box pre-filled with Alice Dev, a slider, and three lines of greetings. Move the slider — the script reruns and the greeting list updates immediately.
Display elements
Streamlit's st.write is the swiss-army renderer — pass it a string, a number, a pandas.DataFrame, a matplotlib figure, a dict, or even a pydantic model, and it picks the right rendering. For finer control there are typed siblings (st.markdown, st.dataframe, st.json, st.image, st.video, st.audio).
import streamlit as st
import pandas as pd
import numpy as np
st.title("Display gallery")
st.header("Header")
st.subheader("Subheader")
st.markdown("**Bold** and `code` and even $\\LaTeX$.")
st.code("def hello():\n return 'world'", language="python")
df = pd.DataFrame(np.random.randn(5, 3), columns=["A", "B", "C"])
st.dataframe(df) # interactive table
st.table(df.head(2)) # static table
st.metric(label="Active users", value=1_234, delta=12)
st.json({"name": "Alice Dev", "tags": ["python", "streamlit"]})
st.image("https://placehold.co/200x100", caption="A placeholder")
Output:
[rendered page: header text, code block, an interactive 5×3 DataFrame,
a 2-row static table, a metric tile showing 1,234 +12, a JSON viewer, and an image]
Input widgets
Every widget in Streamlit returns its current value on each rerun. Storing the return value into a Python variable is all you need — no event handlers, no onChange, no DOM.
import streamlit as st
from datetime import date
# Text & numbers
name = st.text_input("Name", value="Alice Dev")
notes = st.text_area("Notes", height=120)
age = st.number_input("Age", min_value=0, max_value=120, value=30, step=1)
# Choices
plan = st.selectbox("Plan", ["Free", "Pro", "Enterprise"])
tags = st.multiselect("Tags", ["python", "ml", "web", "data"], default=["python"])
mode = st.radio("Mode", ["light", "dark", "auto"], horizontal=True)
agree = st.checkbox("I agree", value=False)
# Continuous values
volume = st.slider("Volume", 0, 100, 50)
date_range = st.date_input("Range", value=(date(2026, 1, 1), date(2026, 12, 31)))
# Files & buttons
upload = st.file_uploader("CSV", type=["csv"])
go = st.button("Run", type="primary")
download = st.download_button("Download", data="hello\n", file_name="hello.txt")
# Print everything (re-runs on any change)
st.write({"name": name, "plan": plan, "tags": tags, "volume": volume, "go": go})
Output:
[A page with one input of each kind. Below it, a JSON viewer reflects the
current values — updates immediately whenever you touch a widget.]
Pass
key="something"to any widget when you have two of the same type on a page (Streamlit auto-generates keys from widget args and crashes on collisions). The key also becomes the slot inst.session_state.
Layout
Streamlit lays out widgets vertically by default. For two-dimensional layouts, use st.sidebar, st.columns, st.tabs, and st.expander. They all support the context-manager (with) syntax, which makes nesting readable.
import streamlit as st
st.title("Layout primitives")
# Sidebar (anything below this writes to the sidebar)
with st.sidebar:
st.header("Controls")
region = st.selectbox("Region", ["EU", "US", "APAC"])
show_advanced = st.checkbox("Advanced settings")
# Columns: weighted widths
col1, col2, col3 = st.columns([2, 1, 1])
with col1:
st.subheader("Main")
st.line_chart({"sales": [1, 3, 2, 5, 4]})
with col2:
st.metric("Sessions", 1024, 12)
with col3:
st.metric("Errors", 7, -3, delta_color="inverse")
# Tabs
tab_summary, tab_data, tab_logs = st.tabs(["Summary", "Data", "Logs"])
with tab_summary:
st.write(f"Region: **{region}**")
with tab_data:
st.dataframe({"x": [1, 2, 3], "y": [4, 5, 6]})
with tab_logs:
st.code("INFO: started\nWARN: rate limited\n")
# Expander (collapsed by default)
with st.expander("Advanced details", expanded=False):
st.write("Things you usually hide.")
# Container — group elements together, useful when re-rendering a region
placeholder = st.container()
placeholder.info("Reserved space — content can be added later.")
Output:
[A page with: a sidebar containing the region selector and a checkbox; a
three-column main area with a chart and two metric tiles; three tabs that
swap content; a collapsed expander; an info block at the bottom.]
Empty placeholders
st.empty() returns a slot that you can write to (and overwrite) later in the same run. Combined with a loop, it's how progress bars and live-updating panels work.
import streamlit as st
import time
slot = st.empty()
for i in range(5):
slot.metric("Tick", i)
time.sleep(0.5)
slot.success("Done!")
Output:
[Metric tile updates 0→1→2→3→4 over ~2.5 seconds, then is replaced
with a green "Done!" success banner.]
st.session_state
Because Streamlit reruns the whole script on every interaction, all local variables are recomputed from scratch. To remember anything across reruns — a counter, a logged-in user, the chosen item in a list — store it in st.session_state, which is a per-browser-session dict that survives reruns until the user closes the tab.
import streamlit as st
# Initialise once
if "count" not in st.session_state:
st.session_state.count = 0
if st.button("Increment"):
st.session_state.count += 1
if st.button("Reset"):
st.session_state.count = 0
st.write(f"Count: **{st.session_state.count}**")
Output:
[Two buttons and a "Count: 0" line. Click Increment to bump the count.
Streamlit reruns the script but session_state preserves the value.]
Widgets with a key= argument automatically read and write to st.session_state[key]:
import streamlit as st
st.text_input("Name", key="user_name") # reads/writes session_state["user_name"]
st.checkbox("Subscribe", key="subscribed")
st.write(st.session_state)
Output:
{
"user_name": "Alice Dev",
"subscribed": true
}
Modifying
st.session_state[key]after the widget is rendered raisesStreamlitAPIException— Streamlit needs to know the default value before the widget appears. Either set the default in anif "key" not in st.session_state: …block at the top of the script, or use a callback (on_change=) to mutate state after the widget event.
Callbacks
Pass on_click= or on_change= to run a function the moment a widget changes, before the rerun. Useful for resetting other state or stamping a timestamp.
import streamlit as st
from datetime import datetime
def stamp_login():
st.session_state.last_login = datetime.utcnow().isoformat()
st.button("Log in", on_click=stamp_login)
if "last_login" in st.session_state:
st.write(f"Last login: `{st.session_state.last_login}`")
Output:
[A "Log in" button. Click it — the stamp appears as "Last login: 2026-05-25T14:33:01.234".]
Caching — @st.cache_data vs @st.cache_resource
Caching is what makes Streamlit's "rerun everything" model fast enough to be tolerable. Two decorators exist and they serve different purposes; mixing them up is the #1 performance bug.
@st.cache_data is for data: anything you'd happily pickle. Each call hashes the function's arguments and returns a fresh deep copy of the result, so mutating the returned object doesn't poison the cache. Reach for it when loading CSVs, hitting an API, or running an expensive transform that produces a DataFrame.
import streamlit as st
import pandas as pd
@st.cache_data(ttl=600) # 10-minute TTL
def load_sales(path: str) -> pd.DataFrame:
return pd.read_csv(path)
df = load_sales("sales.csv")
st.dataframe(df)
# Clear from a button
if st.button("Refresh"):
load_sales.clear()
Output: (none — DataFrame renders; first call is slow, subsequent are instant)
@st.cache_resource is for shared resources that cannot be deep-copied: database connections, ML model objects, HTTP clients. The same object instance is returned across all sessions on the same server. Don't mutate it casually — there's no isolation between callers.
import streamlit as st
import joblib
@st.cache_resource
def load_model():
return joblib.load("model.pkl")
@st.cache_resource
def get_db_conn():
import sqlite3
return sqlite3.connect("app.db", check_same_thread=False)
model = load_model()
conn = get_db_conn()
st.write(model.predict([[1.0, 2.0, 3.0, 4.0]]))
Output:
[Model prediction result, e.g. array([1])]
| Decorator | Returns | Use for | Mutation safe? |
|---|---|---|---|
@st.cache_data | Fresh copy each call | DataFrames, API results, dicts, lists | Yes (it's a copy) |
@st.cache_resource | The same object | Models, DB connections, HTTP clients | No — shared globally |
If a function takes an unhashable argument (e.g. a DB connection), prefix that parameter name with an underscore:
def query(_conn, sql): …. The leading underscore tells Streamlit to skip hashing that argument.
Charts
Streamlit ships native chart functions backed by Altair (st.line_chart, st.bar_chart, st.area_chart, st.scatter_chart, st.map). For anything richer — annotations, dual axes, custom interactivity — pass a Plotly/Altair/Bokeh/Matplotlib figure directly.
import streamlit as st
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
df = pd.DataFrame({
"x": np.arange(0, 10, 0.1),
"sin": np.sin(np.arange(0, 10, 0.1)),
"cos": np.cos(np.arange(0, 10, 0.1)),
}).set_index("x")
# Built-in (Altair under the hood)
st.line_chart(df)
# Matplotlib figure
fig, ax = plt.subplots()
ax.plot(df.index, df["sin"], label="sin")
ax.plot(df.index, df["cos"], label="cos")
ax.legend()
st.pyplot(fig)
# Plotly figure
import plotly.express as px
fig = px.line(df.reset_index(), x="x", y=["sin", "cos"], title="Sine and Cosine")
st.plotly_chart(fig, use_container_width=True)
Output:
[Three charts stacked vertically — a Streamlit-native line chart, a
matplotlib plot rendered as a PNG, and an interactive Plotly chart with
hover tooltips and a legend.]
Forms
A form batches multiple widget changes into a single rerun, triggered when the user clicks the form's submit button. Without a form, every keystroke in every input re-runs the script.
import streamlit as st
with st.form("signup_form", clear_on_submit=True):
name = st.text_input("Full name")
email = st.text_input("Email")
plan = st.selectbox("Plan", ["Free", "Pro"])
accept = st.checkbox("I accept the terms")
submitted = st.form_submit_button("Create account")
if submitted:
if not accept:
st.error("You must accept the terms.")
else:
st.success(f"Welcome, {name}! Confirmation sent to {email}.")
Output:
[A grouped form with three inputs, a checkbox, and a submit button. The
success message appears only after the user clicks "Create account".]
Multi-page apps
Streamlit auto-discovers pages from a pages/ folder next to the main script. Each .py file in pages/ becomes a navigation entry in the sidebar, sorted alphabetically — prefix filenames with numbers (1_📊_Dashboard.py) to control order and icons.
myapp/
├── app.py # main page (the home)
└── pages/
├── 1_📊_Dashboard.py
├── 2_⚙️_Settings.py
└── 3_ℹ️_About.py
# pages/1_📊_Dashboard.py
import streamlit as st
st.title("Dashboard")
st.write("Charts go here.")
streamlit run app.py
Output:
[Sidebar shows three pages: 📊 Dashboard, ⚙️ Settings, ℹ️ About.
Click each to navigate; the URL updates to /Dashboard, /Settings, etc.]
st.session_state persists across pages within the same browser session, so a setting tweaked on page 2 is visible on page 1 after navigation.
Streamlit 1.30+ also offers a programmatic API:
st.navigation([st.Page("home.py"), …])pluspg.run(). This is more flexible when pages depend on the logged-in user's permissions.
Custom theme
Edit .streamlit/config.toml to set colors, fonts, and the base theme. Changes apply on the next browser refresh — no server restart needed.
# .streamlit/config.toml
[theme]
base = "dark"
primaryColor = "#8a5cff"
backgroundColor = "#0f1117"
secondaryBackgroundColor = "#1a1d29"
textColor = "#e9ecf1"
font = "monospace"
Output: (none — config file)
You can also override individual components inline with st.markdown(..., unsafe_allow_html=True), but treat that as an escape hatch.
Deployment
A Streamlit app is just a Python process listening on a port — deploy it anywhere a Python process runs.
Streamlit Community Cloud (free, easiest)
Push your repo to GitHub (must include requirements.txt or pyproject.toml), connect at https://share.streamlit.io, pick a branch and entry point, and your app is live at https://<user>-<repo>-<entry>.streamlit.app. Free tier sleeps after inactivity; redeploys on every push to the selected branch.
Docker
A minimal Dockerfile pattern for self-hosting:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8501
HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health || exit 1
CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=8501", "--server.headless=true"]
docker build -t alicedev/myapp .
docker run -p 8501:8501 alicedev/myapp
Output:
[2026-05-25 14:00:00.000] You can now view your Streamlit app in your browser.
[2026-05-25 14:00:00.000] URL: http://0.0.0.0:8501
Behind a reverse proxy
Streamlit uses WebSockets for live reruns. Configure nginx/Caddy/Traefik with WS upgrade headers, and set --server.baseUrlPath /myapp if you serve under a path prefix.
Streamlit vs other Python UI frameworks
A short orientation. None of these is "best" — they target different shapes of app.
| Tool | Best for | Trade-off |
|---|---|---|
| streamlit | Data dashboards, ML demos, internal tools | "Rerun on every interaction" model; not great for stateful apps with routing |
| gradio | Single-purpose ML/inference demos | Block-based, less flexible layout; great Hugging Face integration |
| nicegui | Vue-style components, websocket-first apps | Newer, smaller ecosystem; nicer for traditional UIs |
| reflex | Full SPAs in Python with proper routing | Compiles to React; higher learning curve |
| dash | Multi-page analytics dashboards | Callback-based; verbose compared to Streamlit |
| panel | Scientific dashboards with Bokeh/HoloViews | Tightly integrated with the PyData stack |
Common pitfalls
- Forgetting that the script reruns on every interaction — expensive code at module level runs on every keystroke. Cache it with
@st.cache_data(data) or@st.cache_resource(objects). st.cache_resourcefor things that should best.cache_data— returning the same DataFrame instance and then mutating it corrupts every subsequent caller. DataFrames go incache_data.- Setting
st.session_state[key] = valueafter creating the widget — raisesStreamlitAPIException. Initialise before the widget renders, or useon_change=callbacks. - Two widgets without
key=— duplicate widget signatures collide. Always passkey=when you have more than one of the same type. - Forgetting
use_container_width=True— charts default to fixed pixel widths and look tiny on mobile. Passuse_container_width=Trueto fill the parent column. - Print statements don't go to the browser —
print(...)writes to the terminal, not the page. Usest.writefor the page orloggingfor structured server logs. st.experimental_*APIs disappear — anything prefixedexperimental_can be renamed or removed across minor versions. Check the release notes when upgrading.- File uploads are in-memory —
st.file_uploaderreturns aBytesIO-like object held in RAM. For large files (>200 MB by default), setserver.maxUploadSizeinconfig.toml. - Long-running tasks block the UI — Streamlit's single-threaded rerun model means a 30-second
time.sleepfreezes the page. Move heavy work to a thread, a worker, orst.status/st.spinnerfor feedback. st.dataframe≠st.table—st.dataframeis interactive (sort, resize, paginate);st.tableis a static HTML render and ignores all interactive features.
Real-world recipes
A 30-line interactive data explorer
Upload a CSV, pick which column to plot, and see a histogram. Covers the upload widget, caching, and chart rendering in one screen.
# explorer.py
import streamlit as st
import pandas as pd
import matplotlib.pyplot as plt
st.set_page_config(page_title="CSV Explorer", layout="wide")
st.title("CSV Explorer")
uploaded = st.file_uploader("Upload a CSV", type=["csv"])
if not uploaded:
st.info("Drop a CSV file to get started.")
st.stop()
@st.cache_data
def load(file) -> pd.DataFrame:
return pd.read_csv(file)
df = load(uploaded)
st.subheader(f"Shape: {df.shape[0]:,} rows × {df.shape[1]} cols")
st.dataframe(df.head(20), use_container_width=True)
numeric_cols = df.select_dtypes("number").columns.tolist()
if not numeric_cols:
st.warning("No numeric columns to plot.")
st.stop()
col = st.selectbox("Plot column", numeric_cols)
bins = st.slider("Bins", 5, 100, 30)
fig, ax = plt.subplots()
ax.hist(df[col].dropna(), bins=bins, color="#8a5cff", edgecolor="white")
ax.set_title(f"Distribution of {col}")
st.pyplot(fig)
streamlit run explorer.py
Output:
[Upload a CSV → the page shows the shape, a preview of the first 20 rows,
and a histogram. Adjust the slider — the histogram redraws instantly because
load() is cached.]
Chat-style ML demo
Streamlit 1.24+ ships st.chat_message and st.chat_input for chatbot UIs. Combine with st.session_state to remember conversation history.
# chat.py
import streamlit as st
st.title("Echo bot")
if "messages" not in st.session_state:
st.session_state.messages = []
# Render history
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
# Input box pinned to the bottom
if prompt := st.chat_input("Say something"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
reply = f"You said: *{prompt}*"
st.session_state.messages.append({"role": "assistant", "content": reply})
with st.chat_message("assistant"):
st.markdown(reply)
Output:
[A chat UI with a fixed input box at the bottom. Each message is shown
in a styled bubble (user vs assistant). Messages persist across reruns
because they live in session_state.]
Long-running task with progress
Stream progress for a slow operation. st.status (1.27+) is the idiomatic container; st.progress is the older bar widget.
import streamlit as st
import time
if st.button("Process 100 records"):
with st.status("Processing…", expanded=True) as status:
bar = st.progress(0)
for i in range(100):
time.sleep(0.02)
bar.progress((i + 1) / 100, text=f"{i + 1}/100")
status.update(label="Done!", state="complete")
st.success("All records processed.")
Output:
[Click the button → a collapsible "Processing…" panel appears with a
progress bar that fills smoothly. After ~2 seconds, the panel collapses
into "Done!" and a green success banner appears below.]
Authentication wall (simple shared password)
For an internal demo, a single shared password is often enough. For real auth, use Streamlit Cloud's SSO or front the app with an OAuth proxy.
# auth.py
import streamlit as st
import hmac
def check_password() -> bool:
if st.session_state.get("authed"):
return True
with st.form("login"):
pw = st.text_input("Password", type="password")
if st.form_submit_button("Sign in"):
if hmac.compare_digest(pw, st.secrets["app_password"]):
st.session_state.authed = True
st.rerun()
else:
st.error("Wrong password.")
return False
if not check_password():
st.stop()
st.title("Internal dashboard")
st.write("Welcome, Alice Dev.")
# .streamlit/secrets.toml (do NOT commit)
app_password = "correct-horse-battery-staple"
Output:
[Login form on first visit. After a correct submission, the dashboard
renders and stays visible across reruns thanks to session_state.]
Side-by-side model comparison
Two columns, each running a different scikit-learn model on the same uploaded dataset. Demonstrates st.cache_resource for the models and st.cache_data for the data.
import streamlit as st
import pandas as pd
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
@st.cache_data
def load(file) -> pd.DataFrame:
return pd.read_csv(file)
@st.cache_resource
def train(_X, _y, kind: str):
model = (RandomForestClassifier if kind == "rf" else GradientBoostingClassifier)(random_state=0)
return model.fit(_X, _y)
uploaded = st.file_uploader("CSV with a 'target' column", type=["csv"])
if not uploaded:
st.stop()
df = load(uploaded)
y = df.pop("target")
X_train, X_test, y_train, y_test = train_test_split(df, y, random_state=0)
left, right = st.columns(2)
for col, kind, label in [(left, "rf", "RandomForest"), (right, "gb", "GradientBoost")]:
with col:
st.subheader(label)
model = train(X_train, y_train, kind)
preds = model.predict(X_test)
st.metric("Accuracy", f"{accuracy_score(y_test, preds):.3f}")
Output:
[Two columns side by side. Each shows a subheader and a metric tile with
the accuracy of the trained model. Re-uploading the CSV invalidates the
cached data; switching models reuses the cached training.]
See also
sections/python/pandas— every Streamlit data app eventually loads a DataFrame.sections/python/matplotlib— pairst.pyplot(fig)for static charts;plotly/altairfor interactive.sections/python/jupyter— the notebook-first alternative for ad-hoc exploration; Streamlit when you need to share with non-coders.sections/python/reflex— heavier, more component-driven Python web framework.