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 service | Streamlit app | |
|---|---|---|
| Consumer | Another program | A human |
| Interface | JSON over HTTP | Widgets in a browser |
| Concurrency | Hundreds of requests/sec | A handful of users |
| Build time | Hours to days | Minutes |
| Right for | Production integration | Demos, internal tools, review |
| Wrong for | A demo nobody can click | Anything 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.
flowchart TD A["User moves a slider"] --> B["Streamlit reruns the WHOLE script
from line 1"] B --> C{"Is this line wrapped
in a cache decorator?"} C -->|"no"| D["It runs again.
Every time."] C -->|"yes"| E["Cached value returned.
Body skipped."] D --> F["Page redrawn"] E --> F
The model is beautifully simple to reason about and catastrophically wasteful if you ignore it. Consider:
model = joblib.load("model.joblib") # runs on EVERY interactionmodel = joblib.load("model.joblib") # runs on EVERY interactionThat 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
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 firstimport 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 firstStreamlit has two cache decorators and they are not interchangeable:
| Decorator | For | Behaviour | Use it on |
|---|---|---|---|
@st.cache_resource@st.cache_resource | Global, unserialisable objects | One shared instance | Models, DB connections, sessions |
@st.cache_data@st.cache_data | Serialisable return values | A copy per caller | DataFrames, 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.
@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()@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.
The two bars are the same app. Twenty nudges of the slider cost ms of work uncached and 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
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")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:
pip install streamlit
streamlit run app.pypip install streamlit
streamlit run app.pyThree 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:
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")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:
[api]
key = "sk-..."
[database]
url = "postgresql://..."[api]
key = "sk-..."
[database]
url = "postgresql://..."api_key = st.secrets["api"]["key"]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
| Option | Good for | Watch out for |
|---|---|---|
| Streamlit Community Cloud | Public demos, free | Public by default; sleeps when idle |
| Hugging Face Spaces | ML demos, free tier | Public unless you pay |
| A container on your own infra | Internal tools with real data | You own auth and TLS |
| Behind a reverse proxy with SSO | Anything with customer data | Streamlit 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_resourcefor models and connections;@st.cache_data@st.cache_datafor 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_statefor anything that must survive a rerun, with thenot innot inguard.- No built-in authentication. Put it behind SSO before it touches real data.
A user moves a slider in your Streamlit app. What executes?
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.
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.
Which decorator belongs on your model-loading function, and why?
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.
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.
Why does the Streamlit app need less input validation than the FastAPI service?
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.
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.
Your list of past predictions empties on every interaction. What is wrong?
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.
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.
You want to show an internal app containing real patient data to your team. Where should it run?
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.
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 coffeeWas this page helpful?
Let us know how we did
