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

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.

~/.claude/skills/streamlit-apps/SKILL.md

--- name: streamlit-apps description: Builds Streamlit data apps that stay fast and predictable - the rerun execution model, st.cache_data vs st.cache_resource with TTLs and… license: MIT ---

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_state does.
  • Widgets are identified by their parameters or key. Changing a widget’s label or options creates a new widget and drops its value; give stable key= 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})
ReturnsDecorator
DataFrames, lists, dicts, query results, API responsesst.cache_data (serialized; each call gets a copy, safe to mutate)
DB engines, HTTP sessions, ML models, LLM clientsst.cache_resource (one shared instance; must be thread-safe, never mutate per user)

Rules:

  • Always set ttl on data from live sources; set max_entries when 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, or st.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_state after the widget is created in the same run (raises); set it in a callback or before the widget call.
  • Widget values bound to key are 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_params for 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_config must be the first Streamlit call in the entry script.
  • Inputs in st.sidebar, KPIs in st.columns, detail views in st.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) with st.column_config number/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.error with 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_resource objects 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

TargetNotes
Streamlit Community Cloudpublic repo or private with account; set secrets in the UI; pin requirements.txt
Docker / any VMstreamlit 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_resource function changes it for every user.
  • st.cache_data on functions returning unpicklable objects fails; use cache_resource.
  • Large session_state per 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.
  • AppTest suite passes with no at.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.

esc