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_state and @st.cache_data exist. Code that runs cheaply at the top of the file is fine; expensive computation must be cached.

Install

bash
# 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:

bash
streamlit --version

Output:

text
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.

bash
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:

text
  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 / optionMeaning
streamlit run <script>Start the dev server for the given script
--server.port 8502Override the default port (8501)
--server.headless trueDon't auto-open a browser (servers, CI)
--server.address 0.0.0.0Bind on all interfaces (LAN access)
--server.runOnSave trueAuto-rerun on file save
--theme.base darkForce the dark theme
streamlit helloLaunch the built-in demo app
streamlit config showPrint the merged config
streamlit cache clearClear 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.

python
# 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}!")
bash
streamlit run app.py

Output:

text
  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).

python
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:

text
[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.

python
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:

text
[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 in st.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.

python
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:

text
[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.

python
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:

text
[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.

python
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:

text
[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]:

python
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:

text
{
 "user_name": "Alice Dev",
 "subscribed": true
}

Modifying st.session_state[key] after the widget is rendered raises StreamlitAPIException — Streamlit needs to know the default value before the widget appears. Either set the default in an if "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.

python
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:

text
[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.

python
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.

python
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:

text
[Model prediction result, e.g. array([1])]
DecoratorReturnsUse forMutation safe?
@st.cache_dataFresh copy each callDataFrames, API results, dicts, listsYes (it's a copy)
@st.cache_resourceThe same objectModels, DB connections, HTTP clientsNo — 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.

python
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:

text
[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.

python
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:

text
[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.

text
myapp/
├── app.py                    # main page (the home)
└── pages/
    ├── 1_📊_Dashboard.py
    ├── 2_⚙️_Settings.py
    └── 3_ℹ️_About.py
python
# pages/1_📊_Dashboard.py
import streamlit as st
st.title("Dashboard")
st.write("Charts go here.")
bash
streamlit run app.py

Output:

text
[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"), …]) plus pg.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.

toml
# .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:

dockerfile
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"]
bash
docker build -t alicedev/myapp .
docker run -p 8501:8501 alicedev/myapp

Output:

text
[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.

ToolBest forTrade-off
streamlitData dashboards, ML demos, internal tools"Rerun on every interaction" model; not great for stateful apps with routing
gradioSingle-purpose ML/inference demosBlock-based, less flexible layout; great Hugging Face integration
niceguiVue-style components, websocket-first appsNewer, smaller ecosystem; nicer for traditional UIs
reflexFull SPAs in Python with proper routingCompiles to React; higher learning curve
dashMulti-page analytics dashboardsCallback-based; verbose compared to Streamlit
panelScientific dashboards with Bokeh/HoloViewsTightly integrated with the PyData stack

Common pitfalls

  1. 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).
  2. st.cache_resource for things that should be st.cache_data — returning the same DataFrame instance and then mutating it corrupts every subsequent caller. DataFrames go in cache_data.
  3. Setting st.session_state[key] = value after creating the widget — raises StreamlitAPIException. Initialise before the widget renders, or use on_change= callbacks.
  4. Two widgets without key= — duplicate widget signatures collide. Always pass key= when you have more than one of the same type.
  5. Forgetting use_container_width=True — charts default to fixed pixel widths and look tiny on mobile. Pass use_container_width=True to fill the parent column.
  6. Print statements don't go to the browserprint(...) writes to the terminal, not the page. Use st.write for the page or logging for structured server logs.
  7. st.experimental_* APIs disappear — anything prefixed experimental_ can be renamed or removed across minor versions. Check the release notes when upgrading.
  8. File uploads are in-memoryst.file_uploader returns a BytesIO-like object held in RAM. For large files (>200 MB by default), set server.maxUploadSize in config.toml.
  9. Long-running tasks block the UI — Streamlit's single-threaded rerun model means a 30-second time.sleep freezes the page. Move heavy work to a thread, a worker, or st.status / st.spinner for feedback.
  10. st.dataframest.tablest.dataframe is interactive (sort, resize, paginate); st.table is 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.

python
# 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)
bash
streamlit run explorer.py

Output:

text
[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.

python
# 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:

text
[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.

python
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:

text
[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.

python
# 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.")
toml
# .streamlit/secrets.toml (do NOT commit)
app_password = "correct-horse-battery-staple"

Output:

text
[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.

python
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:

text
[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 — pair st.pyplot(fig) for static charts; plotly/altair for 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.