Skip to content

Deploying ML Models to Streamlit

What you’ll learn

  • Streamlit’s top-to-bottom rerun model, and why it is the source of every surprise
  • @st.cache_resource@st.cache_resource, and what it prevents — the model was 1,307 KB to deserialise
  • input widgets that cannot produce an invalid feature row
  • batch scoring from an uploaded CSV, with the download back out
  • where Streamlit stops being the right tool, and what to reach for instead

What Streamlit is for

An API serves machines. A Streamlit app serves a person: an analyst who wants to try inputs, a stakeholder who wants to see the model behave, a domain expert who is checking whether it agrees with them.

That is a different job, and it has different success criteria. Nobody cares about your p99 latency. They care that the thing loads, that the controls make sense, and that they cannot accidentally enter a negative tumour radius.

FastAPI serviceStreamlit app
ConsumerAnother programA human
InterfaceJSON over HTTPWidgets in a browser
ConcurrencyHundreds of requests/secA handful of users
Build timeHours to daysMinutes
Right forProduction integrationDemos, internal tools, review
Wrong forA demo nobody can clickAnything a program must call

They are complements, not alternatives. A common and good arrangement is a FastAPI service for production plus a Streamlit app that calls it, so the demo exercises the same code path production does.

The rerun model

This is the one thing to understand, and everything confusing about Streamlit follows from it.

Every interaction reruns your entire script, top to bottom. Move a slider, and the whole file executes again from line 1. There is no callback, no partial update, no component tree that persists by default.

diagram Diagram mermaid

The model is beautifully simple to reason about and catastrophically wasteful if you ignore it. Consider:

python
model = joblib.load("model.joblib")            # runs on EVERY interaction
python
model = joblib.load("model.joblib")            # runs on EVERY interaction

That is the artefact from the saving page: 1,307 KB uncompressed, several thousand NumPy arrays rebuilt, every time somebody nudges a slider. The fix is one decorator.

The one decorator

cached_load.py
import joblib
import streamlit as st
 
 
@st.cache_resource
def load_model(path: str = "model.joblib"):
    """Runs once per server process, not once per interaction."""
    return joblib.load(path)
 
 
model = load_model()      # instant on every rerun after the first
cached_load.py
import joblib
import streamlit as st
 
 
@st.cache_resource
def load_model(path: str = "model.joblib"):
    """Runs once per server process, not once per interaction."""
    return joblib.load(path)
 
 
model = load_model()      # instant on every rerun after the first

Streamlit has two cache decorators and they are not interchangeable:

DecoratorForBehaviourUse it on
@st.cache_resource@st.cache_resourceGlobal, unserialisable objectsOne shared instanceModels, DB connections, sessions
@st.cache_data@st.cache_dataSerialisable return valuesA copy per callerDataFrames, API results, computations

The distinction matters. cache_datacache_data returns a copy, so a caller mutating the result cannot corrupt other users’ data. cache_resourcecache_resource returns the same object to everybody — which is exactly what you want for a read-only model and exactly what you do not want for a DataFrame someone might modify.

both_caches.py
@st.cache_resource                       # one model, shared
def load_model():
    return joblib.load("model.joblib")
 
 
@st.cache_data                           # a copy per caller, safe to mutate
def load_reference_data(path: str):
    return pd.read_csv(path)
 
 
@st.cache_data(ttl=300)                  # refetch at most every 5 minutes
def fetch_live_rates():
    return requests.get(RATES_URL, timeout=5).json()
both_caches.py
@st.cache_resource                       # one model, shared
def load_model():
    return joblib.load("model.joblib")
 
 
@st.cache_data                           # a copy per caller, safe to mutate
def load_reference_data(path: str):
    return pd.read_csv(path)
 
 
@st.cache_data(ttl=300)                  # refetch at most every 5 minutes
def fetch_live_rates():
    return requests.get(RATES_URL, timeout=5).json()

See it move

The sketch is a script tracer. Every slider nudge triggers a rerun, and each line of a five-line app lights up when it executes. The left run has no decorators; the right run caches the model load and the reference CSV. The counters are what the user actually feels: cumulative work per session.

sketch One slider nudge, five lines, two very different bills p5.js
A simulated Streamlit script reruns on every interaction. Without caching, the model load and CSV read execute on each rerun; with cache_resource and cache_data they execute once and are skipped afterwards. Cumulative milliseconds and load counts diverge as the interaction count grows.

The two bars are the same app. Twenty nudges of the slider cost 20×(240+90+5)=6,70020 \times (240 + 90 + 5) = 6{,}700 ms of work uncached and 240+90+20×5=430240 + 90 + 20 \times 5 = 430 ms cached — around 15× — and the ratio keeps growing with session length because the uncached cost is per-interaction while the cached cost is per-process. The model file here is the 1,307 KB artefact from the saving page; on a 100 MB deep-learning checkpoint the uncached version is not slow, it is unusable.

A complete app

app.py
import io
 
import joblib
import numpy as np
import pandas as pd
import streamlit as st
 
FEATURES = ["mean_radius", "mean_texture", "mean_perimeter"]
VERSION = "2026-08-03"
THRESHOLD = 0.5
 
st.set_page_config(page_title="Tumour classifier", page_icon="🔬")
 
 
@st.cache_resource
def load_model(path: str = "model.joblib"):
    return joblib.load(path)
 
 
model = load_model()
 
st.title("Tumour classifier")
st.caption(f"model version {VERSION} · decision threshold {THRESHOLD}")
 
tab_single, tab_batch = st.tabs(["Single prediction", "Score a CSV"])
 
with tab_single:
    col_a, col_b = st.columns(2)
    with col_a:
        radius = st.slider("Mean radius (mm)", 6.0, 30.0, 14.0, 0.1)
        texture = st.slider("Mean texture", 9.0, 40.0, 19.0, 0.1)
    with col_b:
        perimeter = st.slider("Mean perimeter (mm)", 40.0, 200.0, 92.0, 0.5)
 
    # The widgets cannot produce an out-of-range value, so no validation
    # branch is needed here — the bounds ARE the validation.
    row = np.array([[radius, texture, perimeter]])
 
    if st.button("Predict", type="primary"):
        proba = float(model.predict_proba(row)[0, 1])
        label = "Malignant" if proba >= THRESHOLD else "Benign"
 
        left, right = st.columns(2)
        left.metric("Prediction", label)
        right.metric("Probability", f"{proba:.4f}")
        st.progress(proba)
 
        if 0.4 < proba < 0.6:
            st.warning("This case is close to the threshold — treat the label "
                       "as low-confidence and review it manually.")
 
with tab_batch:
    uploaded = st.file_uploader("CSV with one row per case", type="csv")
    if uploaded is not None:
        df = pd.read_csv(uploaded)
        missing = [c for c in FEATURES if c not in df.columns]
        if missing:
            st.error(f"Missing required columns: {missing}")
        else:
            proba = model.predict_proba(df[FEATURES])[:, 1]
            out = df.copy()
            out["probability"] = proba.round(4)
            out["prediction"] = (proba >= THRESHOLD).astype(int)
            out["model_version"] = VERSION
 
            st.success(f"Scored {len(out):,} rows · "
                       f"{int(out['prediction'].sum()):,} flagged")
            st.dataframe(out.head(50), width="stretch")
 
            buffer = io.StringIO()
            out.to_csv(buffer, index=False)
            st.download_button("Download scored CSV", buffer.getvalue(),
                               file_name="scored.csv", mime="text/csv")
app.py
import io
 
import joblib
import numpy as np
import pandas as pd
import streamlit as st
 
FEATURES = ["mean_radius", "mean_texture", "mean_perimeter"]
VERSION = "2026-08-03"
THRESHOLD = 0.5
 
st.set_page_config(page_title="Tumour classifier", page_icon="🔬")
 
 
@st.cache_resource
def load_model(path: str = "model.joblib"):
    return joblib.load(path)
 
 
model = load_model()
 
st.title("Tumour classifier")
st.caption(f"model version {VERSION} · decision threshold {THRESHOLD}")
 
tab_single, tab_batch = st.tabs(["Single prediction", "Score a CSV"])
 
with tab_single:
    col_a, col_b = st.columns(2)
    with col_a:
        radius = st.slider("Mean radius (mm)", 6.0, 30.0, 14.0, 0.1)
        texture = st.slider("Mean texture", 9.0, 40.0, 19.0, 0.1)
    with col_b:
        perimeter = st.slider("Mean perimeter (mm)", 40.0, 200.0, 92.0, 0.5)
 
    # The widgets cannot produce an out-of-range value, so no validation
    # branch is needed here — the bounds ARE the validation.
    row = np.array([[radius, texture, perimeter]])
 
    if st.button("Predict", type="primary"):
        proba = float(model.predict_proba(row)[0, 1])
        label = "Malignant" if proba >= THRESHOLD else "Benign"
 
        left, right = st.columns(2)
        left.metric("Prediction", label)
        right.metric("Probability", f"{proba:.4f}")
        st.progress(proba)
 
        if 0.4 < proba < 0.6:
            st.warning("This case is close to the threshold — treat the label "
                       "as low-confidence and review it manually.")
 
with tab_batch:
    uploaded = st.file_uploader("CSV with one row per case", type="csv")
    if uploaded is not None:
        df = pd.read_csv(uploaded)
        missing = [c for c in FEATURES if c not in df.columns]
        if missing:
            st.error(f"Missing required columns: {missing}")
        else:
            proba = model.predict_proba(df[FEATURES])[:, 1]
            out = df.copy()
            out["probability"] = proba.round(4)
            out["prediction"] = (proba >= THRESHOLD).astype(int)
            out["model_version"] = VERSION
 
            st.success(f"Scored {len(out):,} rows · "
                       f"{int(out['prediction'].sum()):,} flagged")
            st.dataframe(out.head(50), width="stretch")
 
            buffer = io.StringIO()
            out.to_csv(buffer, index=False)
            st.download_button("Download scored CSV", buffer.getvalue(),
                               file_name="scored.csv", mime="text/csv")

Run it:

bash
pip install streamlit
streamlit run app.py
bash
pip install streamlit
streamlit run app.py

Three deliberate choices in that file:

The widget bounds are the validation. st.slider("Mean radius (mm)", 6.0, 30.0, ...)st.slider("Mean radius (mm)", 6.0, 30.0, ...) cannot return −5. The API needed a Pydantic constraint and a 422422 branch; here the constraint is the control itself.

The threshold is displayed. A user who does not know whether 0.51 counts as malignant cannot interpret the answer.

Borderline cases say so. A probability of 0.52 and one of 0.98 both render as “Malignant”, and they are not the same claim. Flagging the middle band is the difference between a demo and a tool someone trusts.

Session state, when you need it

Because the script reruns, ordinary Python variables do not survive an interaction. When you need them to — a running log, a multi-step form, a counter — use st.session_statest.session_state:

session_state.py
import streamlit as st
 
if "history" not in st.session_state:
    st.session_state.history = []          # initialised once per session
 
if st.button("Predict"):
    proba = float(model.predict_proba(row)[0, 1])
    st.session_state.history.append({"radius": radius, "probability": proba})
 
if st.session_state.history:
    st.subheader(f"This session: {len(st.session_state.history)} predictions")
    st.dataframe(pd.DataFrame(st.session_state.history), width="stretch")
session_state.py
import streamlit as st
 
if "history" not in st.session_state:
    st.session_state.history = []          # initialised once per session
 
if st.button("Predict"):
    proba = float(model.predict_proba(row)[0, 1])
    st.session_state.history.append({"radius": radius, "probability": proba})
 
if st.session_state.history:
    st.subheader(f"This session: {len(st.session_state.history)} predictions")
    st.dataframe(pd.DataFrame(st.session_state.history), width="stretch")

The if "history" not in st.session_stateif "history" not in st.session_state guard is required. Without it the list is reset on every rerun, which is exactly the bug the guard exists to prevent.

Secrets

Never put a credential in the source. Streamlit reads .streamlit/secrets.toml.streamlit/secrets.toml, and every hosting option provides an equivalent:

.streamlit/secrets.toml
[api]
key = "sk-..."
 
[database]
url = "postgresql://..."
.streamlit/secrets.toml
[api]
key = "sk-..."
 
[database]
url = "postgresql://..."
python
api_key = st.secrets["api"]["key"]
python
api_key = st.secrets["api"]["key"]

Add .streamlit/secrets.toml.streamlit/secrets.toml to .gitignore.gitignore immediately — before you write anything into it. A key committed to git is compromised even after you delete it, because it stays in the history.

Where to run it

OptionGood forWatch out for
Streamlit Community CloudPublic demos, freePublic by default; sleeps when idle
Hugging Face SpacesML demos, free tierPublic unless you pay
A container on your own infraInternal tools with real dataYou own auth and TLS
Behind a reverse proxy with SSOAnything with customer dataStreamlit has no built-in auth

That last row is the one that matters. Streamlit has no authentication. If the app can reach your data, anyone who can reach the app can reach your data. Put it behind your identity provider before it touches anything real.

When Streamlit is the wrong tool

Reach for something else when:

  • A program needs to call it. Streamlit speaks to browsers. Build an API.
  • You need hundreds of concurrent users. The rerun model puts real work on the server per interaction.
  • You need fine-grained layout control. You will fight the framework and lose.
  • You need per-user authorisation rules. There is no primitive for it.
  • The computation is slow and the user interacts constantly. Every widget change reruns everything not behind a cache.

Streamlit’s whole value is that it turns a Python script into a usable UI in twenty minutes. Push it past that and you have written a bad web framework by accident.

Pitfalls

Loading the model at module scope without a cache decorator. 1,307 KB deserialised on every slider nudge.

Using @st.cache_data@st.cache_data for the model. It will try to serialise a copy per caller. @st.cache_resource@st.cache_resource is the one for models.

Assuming variables persist between interactions. They do not. That is st.session_statest.session_state.

Initialising session state without the not innot in guard. It resets on every rerun and the bug looks like Streamlit “losing” your data.

Showing a probability with no threshold and no confidence band. 0.52 and 0.98 both say “Malignant”, and they are very different claims.

Deploying to Community Cloud with real data. It is public by default, and there is no auth layer to forget to configure — there is no auth layer at all.

Committing secrets.tomlsecrets.toml. Add it to .gitignore.gitignore first, not after.

Recap

  • Streamlit serves people; an API serves programs. Often you want both, with the app calling the API.
  • Every interaction reruns the whole script. Everything confusing follows from that.
  • @st.cache_resource@st.cache_resource for models and connections; @st.cache_data@st.cache_data for values. Without it, the 1,307 KB artefact is rebuilt on every click.
  • Widget bounds are your validation — a slider cannot emit an impossible value.
  • Show the version, show the threshold, and flag borderline probabilities.
  • st.session_statest.session_state for anything that must survive a rerun, with the not innot in guard.
  • No built-in authentication. Put it behind SSO before it touches real data.
quizCheck yourself
  1. A user moves a slider in your Streamlit app. What executes?

    Show answer

    B — The entire script, from line 1, except anything behind a cache decorator — Top-to-bottom rerun is Streamlit's core model. It makes the code trivially easy to reason about and means any unguarded expensive work — model loading, a database query, a big read — happens on every single interaction.

  2. Which decorator belongs on your model-loading function, and why?

    Show answer

    B — @st.cache_resource, because the model is a global unserialisable object shared by everyone — cache_data returns a copy per caller, which is right for a DataFrame someone might mutate and wrong for a read-only model. cache_resource hands out one shared instance — exactly what a model wants. Module scope alone does not help, because the module is re-executed on every rerun.

  3. Why does the Streamlit app need less input validation than the FastAPI service?

    Show answer

    B — The widget bounds are the validation — a slider limited to 6.0-30.0 cannot emit -5 — The API accepts arbitrary JSON, so it needs Pydantic constraints and a 422 path. A slider physically cannot return an out-of-range value, so the control itself enforces the contract.

  4. Your list of past predictions empties on every interaction. What is wrong?

    Show answer

    B — It is an ordinary variable — the rerun re-initialises it. Use st.session_state with a 'not in' guard — Ordinary variables are recreated on every rerun. st.session_state persists across them, but only if you guard the initialisation with `if key not in st.session_state` — otherwise you reset it each time and reproduce the same bug.

  5. You want to show an internal app containing real patient data to your team. Where should it run?

    Show answer

    B — Behind your organisation's SSO, because Streamlit has no authentication of its own — Streamlit provides no auth layer at all, and Community Cloud is public by default. Anyone who can reach the app can reach whatever the app can reach. An unguessable URL is not access control.

🧪 Try It Yourself

Exercise 1 – Count the reruns

Exercise 2 – Widget bounds as validation

Exercise 3 – Guard the session state

Exercise 4 – Check the uploaded CSV before scoring

Exercise 5 – Flag the borderline cases

Exercise 6 – Price the missing decorator

Next

Dockerizing an ML Application — packaging the API, the model and the exact dependency versions into one artefact, and the layer ordering that keeps your rebuilds fast.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did