Skip to content

07 · Building Dashboards (Streamlit)

Static reports go stale the moment new data arrives. Streamlit turns a plain Python script into an interactive web app — no HTML/CSS/JS required — making it the fastest way for a data scientist to hand stakeholders a self-serve dashboard.

A minimal dashboard

# app.py
import streamlit as st
import pandas as pd
import numpy as np

st.set_page_config(page_title="Sales Dashboard", layout="wide")
st.title("Regional Sales Dashboard")

@st.cache_data
def load_data():
    np.random.seed(0)
    dates = pd.date_range("2024-01-01", periods=180, freq="D")
    df = pd.DataFrame({
        "date": np.tile(dates, 3),
        "region": np.repeat(["East", "West", "Central"], 180),
        "revenue": np.random.gamma(4, 50, 540),
    })
    return df

df = load_data()
st.dataframe(df.head())

Running streamlit run app.py starts a local server and opens the app in a browser; every widget interaction reruns the script top to bottom. @st.cache_data prevents expensive operations (loading, cleaning) from re-running on every single interaction — without it, this toy example would still be fast, but a real database query or model load would make the app feel sluggish.

Adding interactive filters

region_filter = st.multiselect(
    "Region", options=df["region"].unique(), default=df["region"].unique()
)
date_range = st.date_input(
    "Date range", value=(df["date"].min(), df["date"].max())
)

filtered = df[
    df["region"].isin(region_filter)
    & (df["date"] >= pd.Timestamp(date_range[0]))
    & (df["date"] <= pd.Timestamp(date_range[1]))
]
st.write(f"Showing {len(filtered)} rows")

Widgets return their current value directly as a Python variable — st.multiselect returns a list, st.date_input a tuple of dates. There's no callback wiring to write: filter the DataFrame with the returned values, and any downstream chart or table using filtered updates automatically on the next rerun.

Layout: columns and metrics

col1, col2, col3 = st.columns(3)
col1.metric("Total Revenue", f"${filtered['revenue'].sum():,.0f}")
col2.metric("Avg Daily Revenue", f"${filtered.groupby('date')['revenue'].sum().mean():,.0f}")
col3.metric("Regions Shown", filtered["region"].nunique())

st.columns(n) splits the page into side-by-side sections; st.metric renders a label, a big number, and (optionally) a delta — the standard KPI-tile look expected at the top of a dashboard.

Charts

import plotly.express as px

daily = filtered.groupby(["date", "region"])["revenue"].sum().reset_index()
fig = px.line(daily, x="date", y="revenue", color="region", title="Daily Revenue by Region")
st.plotly_chart(fig, use_container_width=True)

by_region = filtered.groupby("region")["revenue"].sum().sort_values(ascending=False)
st.bar_chart(by_region)

st.plotly_chart embeds a fully interactive Plotly figure (hover tooltips, zoom, legend toggling) with one line; st.bar_chart is a quick built-in alternative when you don't need Plotly's extra interactivity.

with st.sidebar:
    st.header("Filters")
    st.write("Use the controls above to narrow down the data.")
    threshold = st.slider("Highlight days above revenue", 0, 1000, 300)

high_days = filtered[filtered["revenue"] > threshold]
st.subheader(f"Days above ${threshold}")
st.dataframe(high_days.sort_values("revenue", ascending=False))

Moving controls into st.sidebar keeps the main area for content and gives the dashboard a familiar "filters on the left, results on the right" layout stakeholders already expect from BI tools.

Deploying

# requirements.txt
streamlit
pandas
plotly

# Then, from Streamlit Community Cloud or any server:
streamlit run app.py --server.port 8501 --server.address 0.0.0.0

For a stakeholder-facing dashboard, Streamlit Community Cloud (free, git- push-to-deploy) is usually the fastest path from script to shareable URL; for internal data behind a firewall, running it on an internal server or container is more common — either way, keep secrets (database credentials) in st.secrets or environment variables, never hardcoded in the script.

Cheat sheet

Task Code
Cache expensive loads @st.cache_data above a function
Filter widget st.multiselect(...), st.date_input(...)
KPI tile st.metric(label, value, delta=)
Interactive chart st.plotly_chart(fig, use_container_width=True)
Sidebar controls with st.sidebar: ...
Run locally streamlit run app.py

How It Actually Works

Streamlit's entire programming model rests on one mechanical rule: any widget interaction re-executes the whole script from top to bottom — there is no callback graph or partial re-render like a traditional web framework. When you move the region_filter multiselect, Streamlit doesn't call some registered "on change" function; it reruns app.py in its entirety, and on this rerun st.multiselect immediately returns the new selection (Streamlit tracks each widget's current value keyed by its position/label in the script and feeds it back in). This is what makes the code read like a plain, linear script with no event wiring — the "reactivity" is really just "rerun everything, cheaply."

That rerun-everything model is also exactly why @st.cache_data exists and matters. Without it, load_data() — or a real database query — would re-execute on every widget nudge, even though its result never changed. st.cache_data works by hashing the function's arguments (and its source code) to build a cache key; on a rerun with the same arguments, Streamlit skips calling the function and returns the previously stored result directly, turning an O(script reruns × expensive work) cost into O(expensive work once) plus cheap cache lookups thereafter. The cache is invalidated automatically if the function's code or arguments change, which is why it's safe to leave in place during development.

Session state persistence (implicit here, since widget values must survive across reruns) is handled by Streamlit associating each widget instance with a stable key derived from its position and arguments in the script, storing its current value server-side between reruns — this is why two widgets with the exact same label and type can silently collide unless given an explicit key=, since Streamlit has no other way to tell them apart across reruns.

Deployment's --server.address 0.0.0.0 flag matters mechanically because Streamlit's dev server binds to localhost (127.0.0.1) by default, which only accepts connections originating from the same machine; binding to 0.0.0.0 tells the OS to accept connections on any network interface, which is what makes the app reachable from other machines at all (a container's own internal network, or the public internet, depending on what else fronts it) — without that flag, a dashboard "deployed" to a server would still only be visible to someone logged into that server directly.

Exercise

Extend the dashboard above with a st.selectbox letting the user choose an aggregation level ("Daily", "Weekly", "Monthly"), and resample the filtered DataFrame accordingly before charting (reuse the resample skill from Level 2's time series module). Confirm the chart title and KPI tiles update correctly when the selection changes.