Skip to content
OrionStats
Module 13 · Streamlit Applications · Building Streamlit apps

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.

Advanced90 min
Learning objectives
  • 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:

  1. Expensive work (loading data, training models) must be cached: @st.cache_data for data, @st.cache_resource for models/connections.
  2. 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
Misaligned features at scoring time

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"] from secrets.toml or 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?

Log in to answer and get feedback

A model trained on pd.get_dummies features gives nonsense scores for single rows entered in the app. Most likely cause?

Log in to answer and get feedback

Where should a database password for a deployed Streamlit app live?

Log in to answer and get feedback

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.