Streamlit and Python Shiny

Lesson 13 — Python

Lesson 13 of 20 Intermediate ~90 min

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.py
import 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)
Importantcache_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_arm

Forms

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 reruns

Fragments 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.py
from 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_dir

The 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

# Docker
FROM 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

# Docker
FROM 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_data returns a copy; @st.cache_resource returns 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.

Back to top