Streamlit Apps skill for Claude Code
Data apps that do not re-query on every click. Claude designs Streamlit apps around the rerun model: data loaders cached with TTLs, shared clients in cache_resource, multi-input filters inside forms, and live panels in fragments so one widget does not refresh the world. State lives in session_state with stable keys and callbacks, secrets come from st.secrets, and an AppTest suite covers the main path before deploy.
- Name
streamlit-apps- Size
- 210 lines · ≈2,100 tokens
- Files
- 1
- License
- MIT
- Updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds Streamlit data apps that stay fast and predictable - the rerun execution model, st.cache_data vs st.cache_resource with TTLs and unhashable arguments, st.session_state with widget keys and callbacks, forms and fragments to limit reruns, multipage apps with st.navigation, st.connection and secrets, layout with columns/tabs/sidebar, testing with AppTest, and deployment to Community Cloud, Docker, or Snowflake. Use when building or fixing a Streamlit app, the app is slow or reruns too much, state resets unexpectedly, or preparing one for deployment. Try asking
- Our Streamlit dashboard re-runs the warehouse query every time a slider moves
- Build a Streamlit app to explore the orders table with filters and KPIs
- My Streamlit selections reset when I switch pages - fix the state handling
Use it when
- Building an internal data app or prototype in Streamlit
- A Streamlit app is slow or reruns expensive work
- Widget values or session state reset unexpectedly
- Preparing a Streamlit app for deployment with secrets
Skip it when
- You need a governed BI dashboard for many business users - use bi-dashboards
- Charts themselves are the question - use data-visualization
How to check the result
Expensive loaders run only on cache miss or TTL expiry while you click through widgets, an AppTest suite passes without exceptions, and a fresh session works.
The common mistake it prevents
Putting a database client or model in st.cache_data, or per-user data in st.cache_resource; the first breaks on pickling, the second leaks one user's data to everyone.
A Streamlit app is done when every expensive call is cached with the right decorator and
a TTL, widget state survives the interactions users perform, a slider drag does not
re-query the warehouse, secrets come from st.secrets, and an AppTest run covers the
main path.
The execution model
Every widget interaction reruns the whole script top to bottom. Design around it:
- Code at top level runs on every interaction. Anything slow must be cached, inside a form, inside a fragment, or behind a button.
- Variables do not persist across reruns;
st.session_statedoes. - Widgets are identified by their parameters or
key. Changing a widget’s label or options creates a new widget and drops its value; give stablekey=values.
Workflow
- [ ] 1. Sketch pages and the inputs -> query -> transform -> display flow
- [ ] 2. Put data access in cached functions (cache_data) and clients in cache_resource
- [ ] 3. Group inputs that should apply together into st.form
- [ ] 4. Isolate independently refreshing parts with @st.fragment
- [ ] 5. Manage cross-rerun state with session_state and callbacks
- [ ] 6. Lay out with sidebar, columns, tabs; add empty and error states
- [ ] 7. Test with AppTest; deploy with pinned requirements and secrets
2. Caching
import streamlit as st
import pandas as pd
import sqlalchemy as sa
@st.cache_resource # one shared object per process: clients, models, engines
def get_engine():
return sa.create_engine(st.secrets["db"]["url"], pool_pre_ping=True)
@st.cache_data(ttl="10m", show_spinner="Loading orders...") # returns a copy per caller
def load_orders(start: str, end: str, region: str) -> pd.DataFrame:
q = sa.text("SELECT * FROM orders WHERE created_at >= :s AND created_at < :e AND region = :r")
with get_engine().connect() as conn:
return pd.read_sql(q, conn, params={"s": start, "e": end, "r": region})
| Returns | Decorator |
|---|---|
| DataFrames, lists, dicts, query results, API responses | st.cache_data (serialized; each call gets a copy, safe to mutate) |
| DB engines, HTTP sessions, ML models, LLM clients | st.cache_resource (one shared instance; must be thread-safe, never mutate per user) |
Rules:
- Always set
ttlon data from live sources; setmax_entrieswhen arguments vary widely. - Arguments must be hashable; prefix a parameter with underscore (
_conn) to exclude it from the cache key. - Do not put per-user data in
cache_resource; every session shares it. - Cached functions should not render widgets or output; return data and render outside.
- Clear with
load_orders.clear()after a write, orst.cache_data.clear()for all.
3. Forms
with st.form("filters"):
start, end = st.date_input("Date range", value=(default_start, default_end))
region = st.selectbox("Region", REGIONS, key="region")
submitted = st.form_submit_button("Apply")
if submitted or "orders" not in st.session_state:
st.session_state.orders = load_orders(str(start), str(end), region)
Widgets inside a form do not trigger reruns until submit. Use forms whenever several inputs feed one expensive query.
4. Fragments
@st.fragment(run_every="30s")
def live_kpis():
df = load_latest_metrics() # cached with a short ttl
c1, c2 = st.columns(2)
c1.metric("Orders today", int(df.orders.sum()))
c2.metric("Revenue", f"${df.revenue.sum():,.0f}")
live_kpis()
Interacting with a widget inside a fragment reruns only that fragment. Use for
auto-refreshing panels, chart controls that should not re-query everything, and dialogs
(@st.dialog). Call st.rerun() to force a full-app rerun when a fragment changes
shared state.
5. Session state and callbacks
if "cart" not in st.session_state:
st.session_state.cart = []
def add_item(sku):
st.session_state.cart.append(sku)
st.button("Add", on_click=add_item, args=("SKU-1",), key="add-sku-1")
- Initialize every key before use; reading a missing key raises.
- Callbacks run before the script reruns, so the rerun sees the updated state; prefer them over checking a button’s return value and mutating afterwards.
- Do not set a widget’s value through
session_stateafter the widget is created in the same run (raises); set it in a callback or before the widget call. - Widget values bound to
keyare removed when the widget is not rendered (e.g. on another page). Copy values you need across pages into your own keys. - Use
st.query_paramsfor shareable state (filters in the URL).
6. Layout and pages
# streamlit_app.py
pages = [
st.Page("pages/overview.py", title="Overview", icon=":material/dashboard:", default=True),
st.Page("pages/orders.py", title="Orders"),
]
st.set_page_config(page_title="Ops", layout="wide")
st.navigation(pages).run()
st.set_page_configmust be the first Streamlit call in the entry script.- Inputs in
st.sidebar, KPIs inst.columns, detail views inst.tabs. Tabs render all content on every run; put heavy content behind a selectbox if only one view is needed. - Use
st.dataframe(df, column_config={...}, hide_index=True)withst.column_confignumber/date/link formats instead of preformatting strings. - Handle states explicitly:
if df.empty: st.info("No orders in this range"); st.stop(). - Show errors as
st.errorwith a useful message; never let a raw traceback be the UI for expected failures.
Connections and secrets
# .streamlit/secrets.toml (git-ignored; set in the host's secrets UI for deployment)
[connections.warehouse]
url = "postgresql+psycopg://user:pass@host:5432/db"
conn = st.connection("warehouse", type="sql")
df = conn.query("SELECT region, sum(amount) AS revenue FROM orders GROUP BY 1", ttl="10m")
st.connection caches the connection and conn.query caches results with the given TTL.
Never hardcode credentials or commit secrets.toml. Use parameterized queries for any
user-supplied value.
Performance checklist
- Data loads cached with TTL; nothing slow at top level uncached
- Large DataFrames aggregated in SQL, not in Python after loading everything
- Charts receive aggregated data (thousands of points, not millions)
- Forms around multi-input queries; fragments around independent panels
-
st.cache_resourceobjects are thread-safe
Testing
from streamlit.testing.v1 import AppTest
def test_filters_apply():
at = AppTest.from_file("streamlit_app.py", default_timeout=30).run()
assert not at.exception
at.selectbox(key="region").select("EU")
at.button[0].click().run()
assert not at.exception
assert at.session_state.orders["region"].eq("EU").all()
Mock data functions (monkeypatch the loader module) so tests do not hit real systems.
Deployment
| Target | Notes |
|---|---|
| Streamlit Community Cloud | public repo or private with account; set secrets in the UI; pin requirements.txt |
| Docker / any VM | streamlit run streamlit_app.py --server.port=8501 --server.address=0.0.0.0; health check /_stcore/health; needs WebSocket support and sticky sessions behind a load balancer |
| Snowflake (Streamlit in Snowflake) | runs next to the data with Snowflake auth; package set is restricted |
Authentication: put the app behind the platform’s auth or an identity-aware proxy; recent
Streamlit versions also provide OIDC login (st.login, st.user) - check the installed
version before using it.
Gotchas
- Mutating the return value of a
cache_resourcefunction changes it for every user. st.cache_dataon functions returning unpicklable objects fails; usecache_resource.- Large
session_stateper user multiplies memory by concurrent sessions; store ids, not DataFrames, when many users connect. - File uploads (
st.file_uploader) default to a 200 MB limit (server.maxUploadSize). - Long computations block that session’s rerun; offload jobs over ~30 s to a queue and poll.
Verification
- Interact with each widget while watching the terminal or logs: expensive loaders run only on cache miss or TTL expiry.
AppTestsuite passes with noat.exception.- Fresh-session test (incognito) works without previously set state.
- Deployed health endpoint returns ok; secrets are read from the platform, not the repo.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.