Module 4 — Charts and tables
Dashboards live and die by their visual quality. Module 3 gave us the
frame; this module fills it with the two pillars every business report
rests on: charts and tables. Streamlit offers three tiers of chart
API and one very capable data grid, and knowing which to reach for when
avoids the classic mistake of shipping a plotly.express figure where a
single line would have done.
Native charts: the fastest path
The native chart calls — line_chart, bar_chart, area_chart,
scatter_chart, map — accept a DataFrame and choose sensible defaults.
They are backed by Vega-Lite under the hood, they render fast, and they
are ideal when you do not care what the axes look like beyond the title.
import streamlit as st
import pandas as pd
import numpy as np
months = pd.date_range("2025-01-01", periods=12, freq="MS")
data = pd.DataFrame({
"at_risk": np.random.randint(80, 160, size=12),
"recovered": np.random.randint(10, 40, size=12),
}, index=months)
st.line_chart(data)
st.bar_chart(data, y="at_risk", horizontal=False)
Two habits keep these calls robust. Pass a DataFrame with a DatetimeIndex so Streamlit picks a time axis instead of an integer one, and name your columns in the language of the reader — the legend takes those names verbatim. Beyond that, native charts are deliberately opinionated: if you need a dual axis, a log scale, an annotation, jump straight to Plotly or Altair below.
Plotly: the richest interactivity
st.plotly_chart embeds any Plotly figure and gives it Streamlit's
default toolbar. This is the tool of choice when the user should be able
to zoom, pan, hover on a data point or turn a series off — which is
almost always true on an operational dashboard.
import plotly.express as px
fig = px.bar(
data.reset_index(),
x="index", y=["at_risk", "recovered"],
barmode="group",
labels={"index": "Month", "value": "Customers", "variable": "Category"},
title="At-risk vs recovered customers",
)
fig.update_layout(margin=dict(t=40, b=20), height=350)
st.plotly_chart(fig, use_container_width=True)
Two arguments you should always set. use_container_width=True makes the
chart resize with the column it lives in, which fixes the most common
visual bug of Streamlit apps: a chart that stays 700 px wide inside a
narrow column and gets a horizontal scrollbar. And height= on the
figure gives you a consistent vertical rhythm across the dashboard — one
number in the theme rather than each chart's default.
Altair: the concise grammar
Altair is a Python wrapper around Vega-Lite and is Streamlit's most natural pairing when the data is in a tidy DataFrame. It shines when the chart is a small composition — a bar chart with a horizontal line for the target, a scatter colored by a category — because the "grammar of graphics" makes the intent readable in a few lines.
import altair as alt
target = 100
chart = (
alt.Chart(data.reset_index())
.mark_bar()
.encode(x="index:T", y="at_risk:Q", tooltip=["index:T", "at_risk:Q"])
+ alt.Chart(pd.DataFrame({"y": [target]}))
.mark_rule(color="firebrick")
.encode(y="y:Q")
)
st.altair_chart(chart, use_container_width=True)
The + operator layers two charts on the same axes, which is exactly
what a business target line needs: no legend, no axis mismatch, just a
red horizontal rule at 100. On dense charts Altair also handles
selections cleanly — a brush on one chart can filter another — and
Streamlit surfaces those selections back to Python through
altair_chart(chart, on_select="rerun"), useful for a linked view but
worth the read on the official documentation before deploying.
Interactive and editable tables
st.dataframe renders a live Arrow-backed grid. Users get sorting,
resizing, column pinning and a CSV download from the top-right menu.
Beyond the defaults, column_config= is where the polish lives.
customers = pd.DataFrame({
"id": ["C-01847", "C-02015", "C-02311"],
"name": ["Acme Corp", "Bright LLC", "Delta Ltd"],
"monthly": [72.5, 118.0, 45.0],
"probability":[0.82, 0.61, 0.19],
"at_risk": [True, True, False],
})
st.dataframe(
customers,
hide_index=True,
use_container_width=True,
column_config={
"monthly": st.column_config.NumberColumn("Monthly (USD)", format="USD %.2f"),
"probability": st.column_config.ProgressColumn(
"Churn probability", min_value=0.0, max_value=1.0, format="%.0f%%",
),
"at_risk": st.column_config.CheckboxColumn("At risk"),
},
)
The column_config object comes with a rich library — NumberColumn,
ProgressColumn, LinkColumn, ImageColumn, DateColumn,
SelectboxColumn — and each accepts a help= string that renders as a
tooltip on the column header. That single argument turns a spreadsheet
into something like a documented report.
For editable tables, st.data_editor returns the modified DataFrame
after the user's changes. It supports adding and deleting rows, and the
same column_config applies.
edited = st.data_editor(
customers,
num_rows="dynamic",
hide_index=True,
disabled=["id"],
column_config={
"at_risk": st.column_config.CheckboxColumn("At risk", default=False),
},
)
if not edited.equals(customers):
st.success(f"{len(edited)} rows in the editor (was {len(customers)}).")
disabled=["id"] locks a column against edits — essential for
identifiers. num_rows="dynamic" lets the user add or delete rows;
without it, the row count is frozen.
Adding the two views to the running example
Below is the fourth version of the dashboard. The History tab now shows
the interactive customer table, the Score tab shows a Plotly bar of the
feature contributions, and both use use_container_width=True so the
layout stays clean at every window size.
# app.py — abridged, only the new parts
import streamlit as st
import pandas as pd
import plotly.express as px
with tab_history:
df = pd.read_csv("data/predictions_today.csv")
st.subheader(f"{len(df)} customers scored today")
st.dataframe(
df,
hide_index=True,
use_container_width=True,
column_config={
"monthly": st.column_config.NumberColumn("Monthly (USD)", format="USD %.2f"),
"probability": st.column_config.ProgressColumn(
"Churn probability", min_value=0.0, max_value=1.0, format="%.0f%%",
),
},
)
with tab_score:
# After the scoring button of module 2:
contributions = pd.DataFrame({
"feature": ["Contract", "Tenure", "Monthly bill", "Payment method"],
"impact": [+0.18, -0.09, +0.06, -0.02],
})
fig = px.bar(contributions, x="impact", y="feature", orientation="h",
title="Feature contributions", color="impact",
color_continuous_scale=["#2ca02c", "#d62728"])
fig.update_layout(coloraxis_showscale=False, height=300, margin=dict(t=40, b=20))
st.plotly_chart(fig, use_container_width=True)
The color scale from green to red maps small negative impacts to green and small positive ones to red, which matches the "positive impact increases churn" reading. On a dashboard the color grammar is not just decoration — it is what lets the user parse the chart in a second.
Every chart re-renders on every rerun. On a page with five Plotly
figures, moving a slider retraces the JSON of all five and pushes it over
the websocket. If the data behind a chart does not change, cache the
DataFrame with @st.cache_data (module 5); if the figure itself is
expensive to build, build it inside a cached function.
In summary
- Native charts for a quick view, Plotly for interactivity, and Altair for a concise grammar of layered charts.
- Always pass
use_container_width=Trueonplotly_chartandaltair_chart; it removes the most common visual bug of Streamlit apps. st.dataframewithcolumn_configis the workhorse;st.data_editorwhen the user must modify the data, withdisabled=on identifier columns.- Charts and tables re-render on every interaction; the data behind them belongs behind a cache, which is the topic of the next module.
Next module: @st.cache_data and @st.cache_resource, the two decorators
that make the dashboard fast without a single line of infrastructure.