Building analytical applications with Streamlit
Build an interactive, well-structured Streamlit app with inputs, uploads, filters, charts, a SQL connection and a predictive model — with error handling and a path to deployment.
This lesson builds on: Visualisation, databases, APIs, BI integration and first models in Python, Machine learning foundations — prediction vs explanation, validation, overfitting and leakage.
- Explain Streamlit's execution model (top-to-bottom reruns) and caching
- Build input widgets, file upload and interactive filters
- Connect to SQL and integrate a trained model
- Structure an app for maintainability and handle errors gracefully
- Deploy an app and manage secrets
How Streamlit works
Streamlit turns a Python script into a web app. Every interaction reruns the script from top to bottom; widgets return their current values. Two consequences:
- Expensive work (loading data, training models) must be cached:
@st.cache_datafor data,@st.cache_resourcefor models/connections. - Values that must survive reruns go in
st.session_state.
Install and run: pip install streamlit pandas scikit-learn → streamlit run app.py → opens at http://localhost:8501.
A complete example: Orion churn explorer
# app.py
import sqlite3
from pathlib import Path
import pandas as pd
import streamlit as st
from sklearn.linear_model import LogisticRegression
st.set_page_config(page_title="Orion churn explorer", layout="wide")
DB = Path("telecom.sqlite") # copy from the OrionStats datasets
@st.cache_data(ttl=3600)
def load_subscribers() -> pd.DataFrame:
if not DB.exists():
raise FileNotFoundError(f"Database not found: {DB.resolve()}")
with sqlite3.connect(DB) as con:
return pd.read_sql_query("SELECT * FROM subscribers", con)
FEATURES = ["contract_type", "payment_method", "region", "acquisition_channel"]
@st.cache_resource
def train_model(df: pd.DataFrame):
X = pd.get_dummies(df[FEATURES], drop_first=False).assign(age=df["age"])
model = LogisticRegression(max_iter=1000).fit(X, df["churned"])
return model, X.columns
def score(model, cols, frame: pd.DataFrame):
X = pd.get_dummies(frame[FEATURES], drop_first=False).assign(age=frame["age"])
X = X.reindex(columns=cols, fill_value=0) # align columns with training
return model.predict_proba(X)[:, 1]
try:
subs = load_subscribers()
except Exception as e: # graceful failure for users
st.error(f"Could not load data: {e}")
st.stop()
st.title("Orion Telecom — churn explorer")
# Sidebar filters
with st.sidebar:
st.header("Filters")
regions = st.multiselect("Region", sorted(subs.region.unique()), default=sorted(subs.region.unique()))
contracts = st.multiselect("Contract", sorted(subs.contract_type.unique()), default=sorted(subs.contract_type.unique()))
age = st.slider("Age range", 18, 80, (18, 80))
view = subs[subs.region.isin(regions) & subs.contract_type.isin(contracts) & subs.age.between(*age)]
c1, c2, c3 = st.columns(3)
c1.metric("Subscribers", f"{len(view):,}")
c2.metric("Churn rate", f"{view.churned.mean():.1%}" if len(view) else "—")
c3.metric("Avg age", f"{view.age.mean():.0f}" if len(view) else "—")
st.subheader("Churn rate by contract type")
st.bar_chart(view.groupby("contract_type")["churned"].mean())
# Model integration: score a hypothetical new subscriber
model, cols = train_model(subs)
st.subheader("Score a new subscriber")
with st.form("score"):
f = {c: st.selectbox(c.replace("_", " ").title(), sorted(subs[c].unique())) for c in FEATURES}
f["age"] = st.number_input("Age", 18, 90, 35)
submitted = st.form_submit_button("Estimate churn risk")
if submitted:
p = score(model, cols, pd.DataFrame([f]))[0]
st.write(f"Estimated churn probability: **{p:.0%}**")
st.caption("Association-based estimate from signup attributes; not a causal prediction. Use for prioritisation, not individual decisions.")
# File upload: score a batch
st.subheader("Score a file")
up = st.file_uploader("CSV with columns: " + ", ".join(FEATURES + ["age"]), type="csv")
if up is not None:
try:
batch = pd.read_csv(up)
missing = set(FEATURES + ["age"]) - set(batch.columns)
if missing:
st.error(f"Missing columns: {', '.join(sorted(missing))}")
else:
batch["churn_risk"] = score(model, cols, batch)
st.dataframe(batch.sort_values("churn_risk", ascending=False).head(50))
st.download_button("Download scores", batch.to_csv(index=False), "scores.csv")
except Exception as e:
st.error(f"Could not process the file: {e}")
What each part teaches:
| Topic | Where |
|---|---|
| Data input & forms | st.form, selectbox, number_input |
| File uploads | st.file_uploader + column validation |
| Interactive filters | sidebar multiselect, slider |
| Visualisations | st.metric, st.bar_chart (Altair/Plotly via st.altair_chart/st.plotly_chart) |
| SQL connection | sqlite3 (or st.connection("sql") with SQLAlchemy for PostgreSQL/SQL Server) |
| Python & model integration | cached training; reindex aligns dummy columns at scoring time |
| Error handling | try/except with st.error + st.stop; input validation |
get_dummies on a single new row creates only the columns for that row's values. Without reindex(columns=training_columns, fill_value=0), the model receives the wrong inputs — silently. Better still: wrap preprocessing and model in one scikit-learn Pipeline with OneHotEncoder(handle_unknown="ignore").
Application structure
For anything beyond a demo:
app/
app.py # thin UI layer
pages/ # multipage apps: 1_Overview.py, 2_Scoring.py
core/data.py # loading & validation (testable without Streamlit)
core/model.py # training/scoring pipeline
tests/ # pytest for core logic
requirements.txt
.streamlit/secrets.toml # never committed
Keep business logic out of the UI so it can be unit-tested.
Deployment fundamentals
- Streamlit Community Cloud: connect a GitHub repo, set secrets in the dashboard — fine for public demos with fictional data.
- Company hosting: container (Docker) on a cloud service (Azure App Service, AWS, GCP Cloud Run) or internal server behind single sign-on.
- Secrets:
st.secrets["db_password"]fromsecrets.tomlor environment variables — never hard-coded. - Cloud fundamentals: compute (where code runs), storage (files/objects), managed databases, identity & access management, networking (who can reach the app), and cost per resource. Choose managed services where possible; restrict access to the minimum.
Practice
Why decorate a data-loading function with @st.cache_data in Streamlit?
A model trained on pd.get_dummies features gives nonsense scores for single rows entered in the app. Most likely cause?
Where should a database password for a deployed Streamlit app live?
Summary
- Streamlit reruns the script on every interaction: cache data/models, use session state.
- Forms, uploads, filters, metrics and charts are a few lines each; validate inputs and handle errors.
- Align features (or use pipelines) when scoring; keep logic in testable modules.
- Deploy with secrets management and appropriate access control.