Streamlit and Python Shiny
Lesson 13 — Python
Learning objectives
- Build an app with Streamlit and understand its rerun model
- Build the same app with Shiny for Python and understand reactivity
- Manage state and caching in each
- Choose between them
- Deploy both
Two models
| Streamlit | Shiny for Python | |
|---|---|---|
| Execution | Reruns the entire script on any interaction | Recomputes only what changed |
| State | st.session_state |
Reactive values |
| Learning curve | Very shallow | Steeper |
| Performance at scale | Caching required | Efficient by design |
| Modularity | Functions, fragments | Modules |
| Testing | Awkward | pytest + shiny.playwright |
| Maturity | Since 2019, large ecosystem | Since 2022, mirrors R Shiny |
Streamlit optimises for getting something working in twenty minutes. Shiny optimises for the app still being maintainable at 3,000 lines. Both are reasonable choices; they are optimising different things.
Streamlit
pip install streamlit
streamlit run app.pyimport streamlit as st
import pandas as pd
import plotly.express as px
st.set_page_config(page_title="ADSL Explorer", layout="wide")
st.title("ADSL Explorer")
@st.cache_data
def load_data(path: str) -> pd.DataFrame:
return pd.read_parquet(path)
adsl = load_data("data/adam/adsl.parquet")
# --- Sidebar ---------------------------------------------------------------
with st.sidebar:
st.header("Filters")
arm = st.selectbox("Treatment arm", ["All"] + sorted(adsl["TRT01A"].unique()))
age_range = st.slider(
"Age range",
int(adsl["AGE"].min()), int(adsl["AGE"].max()),
(int(adsl["AGE"].min()), int(adsl["AGE"].max())),
)
saffl_only = st.checkbox("Safety population only", value=True)
# --- Filter ----------------------------------------------------------------
filtered = adsl.copy()
if arm != "All":
filtered = filtered[filtered["TRT01A"] == arm]
if saffl_only:
filtered = filtered[filtered["SAFFL"] == "Y"]
filtered = filtered[filtered["AGE"].between(*age_range)]
# --- Metrics ---------------------------------------------------------------
c1, c2, c3 = st.columns(3)
c1.metric("Subjects", f"{len(filtered):,}")
c2.metric("Mean age", f"{filtered['AGE'].mean():.1f}")
c3.metric("Female", f"{(filtered['SEX'] == 'F').mean():.1%}")
# --- Tabs ------------------------------------------------------------------
tab_table, tab_plot = st.tabs(["Table", "Plot"])
with tab_table:
st.dataframe(filtered, use_container_width=True, hide_index=True)
st.download_button(
"Download CSV",
filtered.to_csv(index=False).encode(),
file_name="filtered.csv",
mime="text/csv",
)
with tab_plot:
if filtered.empty:
st.warning("No subjects match the current filters.")
else:
fig = px.histogram(filtered, x="AGE", color="TRT01A", nbins=20,
template="plotly_white")
st.plotly_chart(fig, use_container_width=True)The rerun model
The entire script runs top to bottom on every interaction. Move a slider, the whole file executes again.
This is what makes Streamlit simple — there is no callback graph, just a script. It is also what makes it slow without caching, and it is the thing to internalise before writing anything non-trivial.
@st.cache_data # for data: the result is serialised
def load_data(path):
return pd.read_parquet(path)
@st.cache_data(ttl=3600) # expire after an hour
def query_database(sql):
return pd.read_sql(sql, con)
@st.cache_resource # for connections and models: NOT serialised
def get_connection():
return create_engine(DB_URL)cache_data versus cache_resource
@st.cache_data returns a copy each time, so mutating the result cannot corrupt the cache. Use it for DataFrames and any returned data.
@st.cache_resource returns the same object. Use it for database connections, ML models and anything that must not be duplicated — and never mutate what it returns.
Getting this backwards produces either mysterious data corruption (cache_resource on a mutated DataFrame) or a new database connection per interaction (cache_data on a connection, which will not even serialise).
Session state
State that survives reruns:
if "history" not in st.session_state:
st.session_state.history = []
if st.button("Apply filter"):
st.session_state.history.append({"arm": arm, "age": age_range})
st.write(f"{len(st.session_state.history)} filters applied")
if st.button("Reset"):
st.session_state.history = []
st.rerun()Widgets with a key write into session state automatically:
st.selectbox("Arm", options, key="selected_arm")
st.session_state.selected_armForms
Batch several inputs so the script reruns once, not per widget:
with st.form("filters"):
arm = st.selectbox("Arm", arms)
age = st.slider("Age", 18, 90, (18, 90))
submitted = st.form_submit_button("Apply")
if submitted:
run_expensive_analysis(arm, age)Without a form, each widget triggers a full rerun. With one, nothing happens until Apply.
Fragments
Rerun only part of the app:
@st.fragment
def live_metrics():
st.metric("Records", len(load_latest()))
if st.button("Refresh"):
st.rerun(scope="fragment") # only this fragment rerunsFragments were Streamlit’s answer to the “everything reruns” problem and are worth using for any expensive section that does not depend on the widget that changed.
Multi-page
app.py
pages/
├── 1_Demographics.py
├── 2_Adverse_Events.py
└── 3_Laboratory.py
Streamlit discovers pages/ automatically. The numeric prefix sets the order.
Shiny for Python
pip install shiny
shiny run --reload app.pyfrom shiny import App, reactive, render, ui
import pandas as pd
import plotly.express as px
from shinywidgets import output_widget, render_widget
adsl = pd.read_parquet("data/adam/adsl.parquet") # loaded ONCE, at startup
app_ui = ui.page_sidebar(
ui.sidebar(
ui.input_select("arm", "Treatment arm",
["All"] + sorted(adsl["TRT01A"].unique())),
ui.input_slider("age", "Age range",
min=int(adsl["AGE"].min()),
max=int(adsl["AGE"].max()),
value=(int(adsl["AGE"].min()), int(adsl["AGE"].max()))),
ui.input_checkbox("saffl", "Safety population only", value=True),
ui.download_button("download", "Download CSV"),
title="Filters",
),
ui.layout_columns(
ui.value_box("Subjects", ui.output_text("n_subjects")),
ui.value_box("Mean age", ui.output_text("mean_age")),
ui.value_box("Female", ui.output_text("pct_female")),
),
ui.navset_card_tab(
ui.nav_panel("Table", ui.output_data_frame("table")),
ui.nav_panel("Plot", output_widget("plot")),
),
title="ADSL Explorer",
)
def server(input, output, session):
@reactive.calc
def filtered():
d = adsl
if input.arm() != "All":
d = d[d["TRT01A"] == input.arm()]
if input.saffl():
d = d[d["SAFFL"] == "Y"]
lo, hi = input.age()
return d[d["AGE"].between(lo, hi)]
@render.text
def n_subjects():
return f"{len(filtered()):,}"
@render.text
def mean_age():
d = filtered()
return f"{d['AGE'].mean():.1f}" if len(d) else "-"
@render.text
def pct_female():
d = filtered()
return f"{(d['SEX'] == 'F').mean():.1%}" if len(d) else "-"
@render.data_frame
def table():
return render.DataGrid(filtered(), filters=True, selection_mode="row")
@render_widget
def plot():
d = filtered()
req(len(d) > 0)
return px.histogram(d, x="AGE", color="TRT01A", nbins=20,
template="plotly_white")
@render.download(filename=lambda: f"adsl_{pd.Timestamp.now():%Y%m%d}.csv")
def download():
yield filtered().to_csv(index=False)
app = App(app_ui, server)The same app. Note that filtered() is computed once per change and used by four outputs — Streamlit would recompute the filter on every rerun unless cached.
Reactivity
@reactive.calc # a cached value; call it with ()
def filtered():
return adsl[adsl["TRT01A"] == input.arm()]
@reactive.effect # a side effect; runs eagerly
def _():
ui.update_select("site", choices=sites_for(input.study()))
@reactive.effect
@reactive.event(input.run) # only when the button is clicked
def _():
run_analysis()
@reactive.calc
@reactive.event(input.run)
def results():
return expensive_model(filtered())Mutable state:
counter = reactive.value(0)
@reactive.effect
@reactive.event(input.increment)
def _():
counter.set(counter.get() + 1)
@render.text
def count():
return str(counter.get())This is directly the R Shiny model — reactive.calc is reactive(), reactive.effect is observe(), reactive.event is bindEvent(). If you know R Shiny’s reactive programming, you already know this.
Modules
from shiny import module
@module.ui
def filter_ui():
return ui.TagList(
ui.input_select("arm", "Arm", ["All", "Placebo", "Drug A"]),
ui.input_slider("age", "Age", 18, 90, (18, 90)),
)
@module.server
def filter_server(input, output, session, data):
@reactive.calc
def result():
d = data()
if input.arm() != "All":
d = d[d["TRT01A"] == input.arm()]
lo, hi = input.age()
return d[d["AGE"].between(lo, hi)]
return result
# In the app
app_ui = ui.page_sidebar(ui.sidebar(filter_ui("filters")), ...)
def server(input, output, session):
filtered = filter_server("filters", data=reactive.value(adsl))Namespacing is automatic — no NS() calls needed, which is an improvement over R Shiny. Modules are the reason Shiny scales to large apps; Streamlit has no equivalent.
Shinylive
Shiny for Python can run entirely in the browser via WebAssembly, with no server:
pip install shinylive
shinylive export app_dir site_dir
python -m http.server --directory site_dirThe result is static files deployable to GitHub Pages or any static host. The constraints: only pure-Python packages plus those in the Pyodide distribution, no filesystem, no database, and a large initial download. For a teaching demo or a self-contained tool it is remarkable.
Choosing
Prototype, internal tool, one person, a weekend? -> Streamlit
Data science demo, quick dashboard? -> Streamlit
Complex state, many inputs, needs to be maintained? -> Shiny
Team already knows R Shiny? -> Shiny for Python
Must run without a server? -> Shinylive
Needs unit tests and modules? -> Shiny
Performance-critical with large data? -> Shiny
Concretely: Streamlit becomes painful at roughly the point where you find yourself fighting the rerun model — caching everything, adding fragments, and managing state manually. That usually happens somewhere around 500 lines.
Deployment
Streamlit
# Streamlit Community Cloud — free, connect a GitHub repo
# requirements.txt in the repo root
# DockerFROM python:3.11-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.port=8501", "--server.address=0.0.0.0"]Shiny for Python
# Posit Connect
rsconnect deploy shiny . --name myserver --title "ADSL Explorer"
# shinyapps.io
rsconnect deploy shiny . --account myaccount
# DockerFROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["shiny", "run", "--host", "0.0.0.0", "--port", "8000", "app.py"]Both need the same discipline as R Shiny deployment: relative paths, pinned dependencies, secrets in environment variables, no patient data on public hosts.
R Shiny and Python side by side
| R Shiny | Shiny for Python | Streamlit |
|---|---|---|
fluidPage() |
ui.page_fluid() |
implicit |
selectInput() |
ui.input_select() |
st.selectbox() |
sliderInput() |
ui.input_slider() |
st.slider() |
actionButton() |
ui.input_action_button() |
st.button() |
plotOutput() |
ui.output_plot() |
st.pyplot() |
renderPlot() |
@render.plot |
(inline) |
reactive() |
@reactive.calc |
@st.cache_data |
observe() |
@reactive.effect |
(inline) |
observeEvent() |
@reactive.event |
if st.button() |
reactiveVal() |
reactive.value() |
st.session_state |
moduleServer() |
@module.server |
none |
req() |
req() |
st.stop() |
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Loading data without @st.cache_data |
Reloads on every interaction | Cache it |
@st.cache_data on a connection |
Will not serialise | @st.cache_resource |
Mutating a cache_resource result |
Corrupts shared state | Never mutate it |
| Ignoring the Streamlit rerun model | Unexplained slowness | Cache, forms, fragments |
| Loading data inside the Shiny server function | Per-session copy | Load at module level |
| Streamlit for a complex stateful app | Fighting the framework | Use Shiny |
No req() in Shiny outputs |
Errors on startup | req(input.x()) |
| Patient data on a public host | Reportable incident | Synthetic data only |
Exercise 8.1 — The same app, twice
Build a lab data explorer — parameter selector, visit range, a mean-profile plot by treatment arm, a summary table and a CSV download — in both Streamlit and Shiny for Python. Compare.
Show solution
Streamlit
# app_streamlit.py
import streamlit as st
import pandas as pd
import plotly.express as px
st.set_page_config(page_title="Lab Explorer", layout="wide")
@st.cache_data
def load_data(path: str) -> pd.DataFrame:
return pd.read_parquet(path)
@st.cache_data
def summarise(df: pd.DataFrame) -> pd.DataFrame:
return (
df.groupby(["TRT01A", "AVISITN"], as_index=False)
.agg(n=("AVAL", "count"), mean=("AVAL", "mean"), sd=("AVAL", "std"))
.assign(se=lambda d: d["sd"] / d["n"] ** 0.5)
)
adlb = load_data("data/adam/adlb.parquet")
st.title("Laboratory Data Explorer")
with st.sidebar:
st.header("Selection")
param = st.selectbox("Parameter", sorted(adlb["PARAMCD"].unique()))
vmin, vmax = int(adlb["AVISITN"].min()), int(adlb["AVISITN"].max())
visits = st.slider("Visit range", vmin, vmax, (vmin, vmax))
arms = st.multiselect("Treatment arms",
sorted(adlb["TRT01A"].unique()),
default=sorted(adlb["TRT01A"].unique()))
filtered = adlb[
(adlb["PARAMCD"] == param)
& adlb["AVISITN"].between(*visits)
& adlb["TRT01A"].isin(arms)
& adlb["ANL01FL"].eq("Y")
]
if filtered.empty:
st.warning("No records match the current selection.")
st.stop()
summary = summarise(filtered)
c1, c2, c3 = st.columns(3)
c1.metric("Records", f"{len(filtered):,}")
c2.metric("Subjects", f"{filtered['USUBJID'].nunique():,}")
c3.metric("Visits", filtered["AVISITN"].nunique())
fig = px.line(summary, x="AVISITN", y="mean", color="TRT01A",
error_y=summary["se"] * 1.96, markers=True,
template="plotly_white",
labels={"AVISITN": "Study week", "mean": f"{param} (mean ± 95% CI)"})
st.plotly_chart(fig, use_container_width=True)
st.subheader("Summary")
st.dataframe(summary.round(2), use_container_width=True, hide_index=True)
st.download_button(
"Download summary CSV",
summary.to_csv(index=False).encode(),
file_name=f"{param}_summary.csv",
mime="text/csv",
)Shiny for Python
# app_shiny.py
from shiny import App, reactive, render, req, ui
from shinywidgets import output_widget, render_widget
import pandas as pd
import plotly.express as px
adlb = pd.read_parquet("data/adam/adlb.parquet") # once, at startup
PARAMS = sorted(adlb["PARAMCD"].unique())
ARMS = sorted(adlb["TRT01A"].unique())
VMIN, VMAX = int(adlb["AVISITN"].min()), int(adlb["AVISITN"].max())
app_ui = ui.page_sidebar(
ui.sidebar(
ui.input_select("param", "Parameter", PARAMS),
ui.input_slider("visits", "Visit range", VMIN, VMAX, (VMIN, VMAX)),
ui.input_checkbox_group("arms", "Treatment arms", ARMS, selected=ARMS),
ui.download_button("download", "Download summary CSV"),
title="Selection",
),
ui.layout_columns(
ui.value_box("Records", ui.output_text("n_records")),
ui.value_box("Subjects", ui.output_text("n_subjects")),
ui.value_box("Visits", ui.output_text("n_visits")),
),
ui.card(ui.card_header("Mean profile"), output_widget("plot")),
ui.card(ui.card_header("Summary"), ui.output_data_frame("summary_table")),
title="Laboratory Data Explorer",
)
def server(input, output, session):
@reactive.calc
def filtered():
lo, hi = input.visits()
return adlb[
(adlb["PARAMCD"] == input.param())
& adlb["AVISITN"].between(lo, hi)
& adlb["TRT01A"].isin(input.arms())
& adlb["ANL01FL"].eq("Y")
]
@reactive.calc
def summary():
d = filtered()
req(len(d) > 0)
return (
d.groupby(["TRT01A", "AVISITN"], as_index=False)
.agg(n=("AVAL", "count"), mean=("AVAL", "mean"), sd=("AVAL", "std"))
.assign(se=lambda x: x["sd"] / x["n"] ** 0.5)
)
@render.text
def n_records():
return f"{len(filtered()):,}"
@render.text
def n_subjects():
return f"{filtered()['USUBJID'].nunique():,}"
@render.text
def n_visits():
return str(filtered()["AVISITN"].nunique())
@render_widget
def plot():
s = summary()
return px.line(
s, x="AVISITN", y="mean", color="TRT01A",
error_y=s["se"] * 1.96, markers=True, template="plotly_white",
labels={"AVISITN": "Study week",
"mean": f"{input.param()} (mean ± 95% CI)"},
)
@render.data_frame
def summary_table():
return render.DataGrid(summary().round(2))
@render.download(filename=lambda: f"{input.param()}_summary.csv")
def download():
yield summary().to_csv(index=False)
app = App(app_ui, server)Comparison
| Streamlit | Shiny | |
|---|---|---|
| Lines | ~60 | ~85 |
| Time to first working version | ~20 min | ~40 min |
| Data loading | @st.cache_data required |
Module level, natural |
| Filter computed | Every rerun (or cached) | Once per change |
| Empty state | st.stop() |
req() |
| Reading order | Top to bottom, like a script | Declarative graph |
| Adding a module | No mechanism | @module |
| Unit testing | Awkward | pytest on the server function |
What the comparison actually shows
Streamlit is genuinely faster to write and easier to read for someone who has never seen it — it is a script. That is a real advantage and it should not be dismissed.
The Shiny version’s filtered() and summary() are computed once per change and shared by six outputs. Streamlit recomputes the filter on every rerun unless you cache it — and caching a DataFrame keyed on four widget values means a growing cache and a subtle staleness risk if the underlying data changes.
At this size the difference is negligible. The divergence appears when:
- The dataset is 5 million rows and the filter takes two seconds
- There are twelve interdependent inputs
- Two people are maintaining it
- It needs unit tests before it can be deployed
At that point Shiny’s extra 25 lines are cheap and Streamlit’s rerun model becomes something you spend time working around.
Recommendation: prototype in Streamlit, and rewrite in Shiny if the app survives. Most prototypes do not, so the Streamlit version was the right investment; the ones that do survive earn the rewrite.Recap
- Streamlit reruns the entire script on every interaction — cache accordingly
@st.cache_datareturns a copy;@st.cache_resourcereturns the same object- Shiny for Python mirrors R Shiny:
reactive.calc,reactive.effect,reactive.event - Shiny modules namespace automatically; Streamlit has no equivalent
- Load data at module level in Shiny; cache it in Streamlit
- Shinylive runs Shiny in the browser with no server
- Prototype in Streamlit; rewrite in Shiny if the app survives
Next: Testing.