Streamlit turns Python scripts into interactive data applications. It is effective for prototypes, internal tools, and explorable reports, but input validation, access control, and performance still matter.
Install it with pandas:
python -m pip install streamlit pandas
streamlit run app.py
Upload and validate data
import pandas as pd
import streamlit as st
st.set_page_config(page_title="Sales", layout="wide")
st.title("Sales dashboard")
file = st.file_uploader("Upload CSV", type=["csv"])
if file is None:
st.info("Upload a file to begin.")
st.stop()
df = pd.read_csv(file)
required = {"date", "category", "value"}
if not required.issubset(df.columns):
st.error("CSV must contain date, category, and value.")
st.stop()
Validate types before calculating metrics. The Python data science guide provides a broader workflow.
Add interaction
options = sorted(df["category"].dropna().unique())
selected = st.multiselect("Categories", options, default=options)
filtered = df[df["category"].isin(selected)]
st.metric("Revenue", f"${filtered['value'].sum():,.2f}")
daily = filtered.groupby("date", as_index=False)["value"].sum()
st.line_chart(daily, x="date", y="value")
st.dataframe(filtered, use_container_width=True)
Streamlit reruns the script after interaction. Its official concepts guide explains this execution model.
Use @st.cache_data(ttl=600) for repeatable data-loading functions. Do not place user-specific sensitive values in shared cache without understanding isolation. The caching documentation distinguishes data from resource caches.
Normalize data before displaying it
A dashboard should have one transformation path, regardless of whether the source is an uploaded file, a database, or an API. Put parsing and validation in a function so that widgets do not become mixed with data rules:
def prepare_sales(raw: pd.DataFrame) -> pd.DataFrame:
required = {"date", "category", "value"}
missing = required.difference(raw.columns)
if missing:
raise ValueError(f"Missing columns: {', '.join(sorted(missing))}")
clean = raw.copy()
clean["date"] = pd.to_datetime(clean["date"], errors="coerce")
clean["value"] = pd.to_numeric(clean["value"], errors="coerce")
clean["category"] = clean["category"].astype("string").str.strip()
clean = clean.dropna(subset=["date", "category", "value"])
return clean
try:
df = prepare_sales(pd.read_csv(file))
except (ValueError, pd.errors.ParserError, UnicodeDecodeError) as error:
st.error(f"Could not process this file: {error}")
st.stop()
Copying the frame prevents surprising mutations of the original object. Invalid dates and amounts become missing values and are removed deliberately. In a real product, also report how many rows were rejected; silently discarding half a file could make a polished chart factually wrong. Currency, timezone, decimal separator, and whether refunds are negative values should be explicit business rules.
For large uploads, reject unreasonable sizes before parsing and select only necessary columns. A file extension is not a security guarantee. Never pass uploaded content to a shell command, and do not deserialize untrusted pickle files.
Build filters that handle empty results
Date and category filters should derive their boundaries from the validated frame. Widgets also need stable keys if similar controls appear on multiple pages.
start = df["date"].min().date()
end = df["date"].max().date()
period = st.date_input("Period", value=(start, end), min_value=start, max_value=end)
if len(period) == 2:
start_date, end_date = period
mask = df["date"].dt.date.between(start_date, end_date)
filtered = df.loc[mask & df["category"].isin(selected)].copy()
else:
filtered = df.iloc[0:0].copy()
if filtered.empty:
st.warning("No sales match the selected filters.")
st.stop()
Stopping here avoids showing NaN averages or an empty graph without explanation. For a multi-page application, place filters in st.sidebar and retain only genuine user choices in st.session_state. Session state is useful for selections across reruns, but it is not durable storage and should not be treated as a database.
Define metrics instead of merely formatting numbers
“Revenue” may mean gross sales, net sales, recognized revenue, or cash received. State the definition near the number. Compare equal periods when showing a delta, and avoid a green increase when growth is not necessarily desirable. The interface can calculate a previous-period comparison explicitly:
current_total = filtered["value"].sum()
order_count = len(filtered)
average = current_total / order_count if order_count else 0
left, middle, right = st.columns(3)
left.metric("Revenue", f"${current_total:,.2f}")
middle.metric("Rows", f"{order_count:,}")
right.metric("Average value", f"${average:,.2f}")
If rows are line items rather than orders, label the second metric “line items”; otherwise the dashboard exaggerates transaction count. Group dates at the intended grain before charting. Daily data may be too noisy for a year, so a weekly or monthly selector can improve interpretation without changing underlying records.
Cache the right boundary
Caching is most useful around deterministic, expensive work. An uploaded in-memory file changes when its bytes change, so a cached parser can accept the bytes rather than a global variable:
from io import BytesIO
@st.cache_data(ttl=600, show_spinner="Reading data...")
def read_sales(content: bytes) -> pd.DataFrame:
return prepare_sales(pd.read_csv(BytesIO(content)))
df = read_sales(file.getvalue())
st.cache_data returns data copies and suits queries or transformations. st.cache_resource suits shared resources such as a model or database connection. Do not cache functions that send email, write payments, or otherwise have side effects: a cache hit would skip the action. Add a TTL when freshness matters, and offer a controlled refresh action if operators need it.
Organize and test the application
As the dashboard grows, keep pure transformations in a module such as sales.py and Streamlit calls in app.py. Pure functions can be tested without starting a browser:
def test_prepare_sales_removes_invalid_values():
raw = pd.DataFrame({
"date": ["2026-01-02", "invalid"],
"category": ["Books", "Books"],
"value": ["12.50", "unknown"],
})
result = prepare_sales(raw)
assert len(result) == 1
assert result.iloc[0]["value"] == 12.5
Test missing columns, empty files, duplicate rows, negative values, date boundaries, and unusual encodings. Streamlit also provides application testing APIs, documented in its official app testing reference, for checking widget-driven flows. Use unit tests for business rules and a smaller number of interface tests for integration.
Prepare a responsible deployment
Create a reproducible dependency file and run the app in a clean environment before publishing. Configure secrets through the deployment platform rather than committing credentials. A database account should receive only the permissions needed for dashboard queries.
Access control belongs in the design, not in a hidden URL. If different users may see different rows, enforce authorization when fetching data instead of downloading everything and filtering only in the interface. Logs should help diagnose errors without recording uploaded content, tokens, or personal data. Establish file-size limits, query timeouts, cache freshness, and an owner responsible for metric definitions.
Before deployment, pin dependencies, test empty and malformed files, limit uploads, keep secrets outside Git, and require authentication for internal data. Streamlit removes interface boilerplate, not the need to explain sources, filters, and metric definitions.
Diagnose problems before adding features
If a widget loses its value, check whether its key or available options change during the rerun. Stable keys and one normalization rule prevent surprising resets. If an expensive query runs after every click, confirm that the cached function receives stable arguments and does not depend on an unlisted global value.
When totals disagree with another report, compare the source snapshot, timezone, inclusive date boundaries, duplicate policy, filters, and aggregation grain. Record those choices beside the metric. For memory problems, aggregate at the source, paginate detailed tables, and measure with a representative file.
Accessibility is also correctness. Use descriptive labels, do not communicate status only through color, keep a table when a chart carries exact values, and explain unfamiliar metrics. Test keyboard navigation and narrow screens. Version changes to metric definitions, display the last data refresh, and identify stale data during an outage. A dashboard succeeds when readers can reach the same interpretation as its author.
Before releasing a new version, walk through the application with a representative file and a deliberately malformed one. Confirm that filters can be cleared, the selected period remains visible, empty states explain what happened, and downloads contain the same filtered records shown on screen. Compare one total manually with the source.
Monitor loading time, rejected files, query failures, and memory without recording sensitive content. Assign ownership for both the data source and the metric definition. Keep a rollback path for code changes and make schema changes fail with a useful message instead of silently producing zero. Review the dashboard with a domain expert: engineering can prove that a calculation is reproducible, while domain knowledge determines whether it answers the intended question.