Build a Sales Dashboard with Streamlit, Pandas and Plotly

Turn a sales CSV into an interactive dashboard in about 70 lines of Python with Streamlit, pandas and Plotly: filters, KPIs and charts.

By CrunchCubeUpdated 21 October 20265 min read

Every Monday someone asks the same questions: how much did we sell, which category is winning, is this month better than last? You could rerun a notebook and paste charts into an email. Or you could build a dashboard once and let people answer their own questions.

With Streamlit, that dashboard is a single Python file. Here's one in about 70 lines, built step by step.

What we're building

  • Filters in a sidebar: channel, category and date range
  • Three headline numbers: revenue, orders and median order value
  • A monthly revenue trend and a revenue-by-category chart
  • An expandable table of the matching orders

We'll use the same orders dataset as the EDA workflow guide. Download orders.csv and put it next to your script.

Setup

pip install streamlit plotly pandas

How Streamlit thinks

Streamlit has one big idea: your script reruns from top to bottom every time someone touches a widget. Change a filter, and the whole file runs again with the new value.

It's like a cricket scoreboard operator who rewrites the entire board after every ball, rather than tracking which numbers changed. It sounds wasteful, but it makes the code dead simple: no callbacks, no event handlers, just top-to-bottom Python. The one thing you must avoid is redoing slow work on every rerun, which is what caching is for.

Step 1: Load the data once

Create app.py:

import pandas as pd
import plotly.express as px
import streamlit as st
 
st.set_page_config(page_title="Sales dashboard", layout="wide")
 
 
@st.cache_data
def load_data(path: str) -> pd.DataFrame:
    df = pd.read_csv(path, parse_dates=["order_date"])
    return df.dropna(subset=["revenue"])
 
 
df = load_data("orders.csv")

@st.cache_data remembers the result. The CSV is read on the first run; after that, every rerun reuses the loaded table instantly. With a big file or a database query, this is the difference between a snappy dashboard and a sluggish one.

We drop the 12 orders with no revenue so they don't distort the totals. In a real report, mention that somewhere.

Step 2: Sidebar filters

st.sidebar.header("Filters")
channels = sorted(df["channel"].unique())
categories = sorted(df["category"].unique())
 
picked_channels = st.sidebar.multiselect("Channel", channels, default=channels)
picked_categories = st.sidebar.multiselect("Category", categories, default=categories)
dates = st.sidebar.date_input(
    "Order date",
    value=(df["order_date"].min().date(), df["order_date"].max().date()),
)
 
if len(dates) != 2:
    st.info("Pick an end date to apply the date filter.")
    st.stop()
 
start, end = pd.Timestamp(dates[0]), pd.Timestamp(dates[1])
view = df[
    df["channel"].isin(picked_channels)
    & df["category"].isin(picked_categories)
    & df["order_date"].between(start, end)
]

Each widget returns the user's current choice as a normal Python value. multiselect gives a list; passing a tuple to date_input makes it a range picker.

The len(dates) != 2 check handles a real quirk: while the user is picking a range, they've clicked the start date but not the end yet, so only one date comes back. st.stop() halts this run politely instead of crashing.

view is the filtered table. Everything below uses view, so every number and chart respects the filters automatically.

Step 3: Headline numbers

st.title("Sales dashboard")
 
if view.empty:
    st.warning("No orders match these filters.")
    st.stop()
 
col1, col2, col3 = st.columns(3)
col1.metric("Revenue", f"₹{view['revenue'].sum():,.0f}")
col2.metric("Orders", f"{len(view):,}")
col3.metric("Median order", f"₹{view['revenue'].median():,.0f}")

st.columns(3) splits the row into three, and metric shows a large number with a label. With all filters on, you'll see revenue of ₹1,548,185 across 588 orders.

Why median order instead of average? A few big electronics orders pull the average up. The median is the honest "typical order", which is what people actually want to know.

The empty check matters too: if someone deselects every channel, the median of nothing would show "nan". A clear message is better.

Step 4: Charts

monthly = (view.set_index("order_date")
               .resample("MS")["revenue"].sum()
               .reset_index())
fig_trend = px.line(monthly, x="order_date", y="revenue", markers=True,
                    title="Revenue by month",
                    labels={"order_date": "", "revenue": "Revenue (₹)"})
 
by_category = (view.groupby("category", as_index=False)["revenue"].sum()
                   .sort_values("revenue"))
fig_cat = px.bar(by_category, x="revenue", y="category", orientation="h",
                 title="Revenue by category",
                 labels={"revenue": "Revenue (₹)", "category": ""})
 
left, right = st.columns(2)
left.plotly_chart(fig_trend, width="stretch")
right.plotly_chart(fig_cat, width="stretch")

Plotly charts are interactive inside Streamlit: hover for exact values, drag to zoom, double-click to reset. A horizontal bar chart sorted by value is easier to read than a pie, especially once you have more than three categories. (Not sure which charting library to use where? See Matplotlib vs Seaborn vs Plotly.)

resample("MS") groups by month start, so each point is one month.

Step 5: The raw data, on request

with st.expander("See the orders"):
    st.dataframe(view.sort_values("order_date", ascending=False), hide_index=True)

Managers trust a number more when they can see the rows behind it. An expander keeps the table out of the way until someone asks. st.dataframe is sortable and searchable, and it has a download button built in.

Run it

streamlit run app.py

Your browser opens at http://localhost:8501. Untick "Marketplace" in the sidebar and watch every number and chart update. Choose only "App" and revenue drops to ₹473,090 from 167 orders.

What goes wrong

  • Forgetting @st.cache_data. Every click re-reads the file. Fine for 600 rows, painful for 6 million.
  • Mutating cached data. Don't change the cached df in place (for example adding a column to it). Work on filtered copies like view, or return a fully prepared table from the cached function.
  • Averages on skewed money data. Prefer the median, or show both.
  • Ignoring empty filters. Always handle "no rows match" before calculating.

Share it

Put app.py, orders.csv and a requirements.txt (listing streamlit, plotly and pandas) in a GitHub repository, then deploy on Streamlit Community Cloud. For company data, check first whether it's allowed to leave your servers; if not, run streamlit run on an internal machine instead.

In one line: cache the data load, read filters from sidebar widgets, build one filtered table, and drive every metric and Plotly chart from it, because Streamlit simply reruns your script on each change.

Keep reading

New games, tools and tutorials

About one short email a week. No spam, one-click unsubscribe.