Skip to main content

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.

A chart is not free

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=True on plotly_chart and altair_chart; it removes the most common visual bug of Streamlit apps.
  • st.dataframe with column_config is the workhorse; st.data_editor when the user must modify the data, with disabled= 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.