All 57 lessons

Lesson 40 of 57 · Data Visualization

Visual Storytelling: Building a One-Page KPI Dashboard in Python

By Skillancy Editorial Team

Run the code in the free Python Compiler or on your own computer. Lessons that read files use the FreshBasket practice data: download the dataset (ZIP).

You can now draw almost any chart. The final step in visualisation is the hardest: assembling charts into something a busy manager will actually use. A pile of correct charts is not a report. A report has a point of view: it leads with the headline, supports it with a few well-chosen visuals, and ends with what to do next. This lesson is about that craft, visual storytelling, and puts it into practice by building a single-page FreshBasket KPI dashboard entirely in Python.

We follow a simple recipe. First decide the audience and the question (what decision will this support?). Then choose a handful of KPIs and make them impossible to miss. Add context (comparisons to a previous period or target). Write headline titles that state the finding. Use colour sparingly to direct attention. Finally, export the page as a PNG for chat apps and a PDF for email. Everything uses the tools from this module: Matplotlib subplots, formatters, annotations, and a few layout tricks (GridSpec) that make dashboards look professional.

Worked examples

Example 1: Start with the question

Question: What will the dashboard help a regional manager decide?

brief = {
    "audience": "Operations head",
    "question": "Are we growing healthily, and where do we need to act?",
    "kpis": ["Delivered revenue", "Orders", "Avg delivery time", "On-time rate (<=30 min)", "Cancellation rate"],
    "charts": ["Revenue trend", "City ranking", "Delivery time vs target", "Cancellations by city"],
    "action": "Decide which city gets extra riders next month",
}
for k, v in brief.items():
    print(f"{k:<10}{v}")
print(len(brief["kpis"]), len(brief["charts"]))

Output

audience  Operations head
question  Are we growing healthily, and where do we need to act?
kpis      ['Delivered revenue', 'Orders', 'Avg delivery time', 'On-time rate (<=30 min)', 'Cancellation rate']
charts    ['Revenue trend', 'City ranking', 'Delivery time vs target', 'Cancellations by city']
action    Decide which city gets extra riders next month
5 4

How it works: Before writing any plotting code, write a one-paragraph brief: who reads this, what decision it supports, which five or so numbers matter, which three or four charts show them, and what action should follow. A dashboard that answers one question well beats one that answers ten questions badly. Everything below serves this brief.

Reading the result: The brief fixes 5 KPIs and 4 charts. If a chart does not help the decision, leave it out.

Example 2: Compute the KPIs with a comparison

Question: What are the headline numbers, and how do they compare with the previous month?

import pandas as pd
import numpy as np

orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
items = pd.read_csv("order_items.csv")
stores = pd.read_csv("stores.csv")

value = items.assign(v=items["quantity"] * items["unit_price"]).groupby("order_id")["v"].sum().rename("value")
o = orders.merge(value, on="order_id", how="left").merge(stores[["store_id", "city"]], on="store_id")
o["month"] = o["order_date"].dt.to_period("M")

def kpis(d):
    delivered = d[d["order_status"] == "Delivered"]
    return {
        "revenue": delivered["value"].sum(),
        "orders": len(d),
        "avg_minutes": delivered["delivery_minutes"].mean(),
        "on_time": (delivered["delivery_minutes"] <= 30).mean(),
        "cancel_rate": (d["order_status"] == "Cancelled").mean(),
    }

latest, prev = o["month"].max(), o["month"].max() - 1
cur, old = kpis(o[o["month"] == latest]), kpis(o[o["month"] == prev])

for k in cur:
    delta = (cur[k] - old[k]) / old[k] * 100
    print(f"{k:<12}{cur[k]:>12,.2f}{old[k]:>12,.2f}{delta:>8.1f}%")

Output

revenue       388,871.00  362,430.00     7.3%
orders            630.00      606.00     4.0%
avg_minutes        23.74       24.40    -2.7%
on_time             0.83        0.82     0.8%
cancel_rate         0.06        0.05     7.8%

How it works: A function computes the KPIs for any slice of orders, so we can call it for the latest month and for the month before. A KPI without a comparison is hard to interpret; a number against last month, last year or a target tells the reader whether to worry. The loop prints each KPI, its previous value and the percentage change.

Reading the result: In 2026-06, delivered revenue was ₹388,871, 7.3% versus 2026-05. Orders were 630, the on-time rate 82.7% and cancellations 5.9%.

Example 3: Formatting helpers

Question: Format rupees, percentages and changes consistently.

def inr_short(x):
    if abs(x) >= 1e7:
        return f"₹{x / 1e7:.2f} Cr"
    if abs(x) >= 1e5:
        return f"₹{x / 1e5:.2f} L"
    return f"₹{x:,.0f}"

def pct(x, d=1):
    return f"{x * 100:.{d}f}%"

def delta_text(new, old, good_when_up=True):
    change = (new - old) / old
    arrow = "▲" if change >= 0 else "▼"
    good = (change >= 0) == good_when_up
    return f"{arrow} {abs(change) * 100:.1f}%", ("#2A9D8F" if good else "#E76F51")

print(inr_short(cur["revenue"]), pct(cur["on_time"]), pct(cur["cancel_rate"], 2))
print(delta_text(cur["revenue"], old["revenue"]))
print(delta_text(cur["cancel_rate"], old["cancel_rate"], good_when_up=False))

Output

₹3.89 L 82.7% 5.87%
('▲ 7.3%', '#2A9D8F')
('▲ 7.8%', '#E76F51')

How it works: Small helper functions keep formatting identical everywhere, so "₹5.2 L" never appears as "520000" in one place and "5.2 lakh" in another. delta_text returns an arrow with the size of the change and a colour, and it knows that for cancellations an increase is bad, so the colour logic is not simply "up is green". Encoding that business rule once avoids misleading colours.

Reading the result: Revenue shows ▲ 7.3% and cancellations ▲ 7.8%, each coloured by whether the change is good or bad news.

Example 4: KPI tiles

Question: Draw big-number tiles with comparison arrows.

import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch
import skillancy_style

tiles = [
    ("Delivered revenue", inr_short(cur["revenue"]), delta_text(cur["revenue"], old["revenue"])),
    ("Orders", f'{cur["orders"]:,}', delta_text(cur["orders"], old["orders"])),
    ("Avg delivery", f'{cur["avg_minutes"]:.1f} min', delta_text(cur["avg_minutes"], old["avg_minutes"], good_when_up=False)),
    ("On-time (<=30 min)", pct(cur["on_time"]), delta_text(cur["on_time"], old["on_time"])),
    ("Cancellations", pct(cur["cancel_rate"]), delta_text(cur["cancel_rate"], old["cancel_rate"], good_when_up=False)),
]

fig, ax = plt.subplots(figsize=(12, 1.9))
ax.set_xlim(0, len(tiles)); ax.set_ylim(0, 1); ax.axis("off")
for i, (label, big, (change, color)) in enumerate(tiles):
    ax.add_patch(FancyBboxPatch((i + 0.04, 0.06), 0.92, 0.88, boxstyle="round,pad=0.0,rounding_size=0.06", fc="#F4F7F8", ec="#D0D5DD"))
    ax.text(i + 0.5, 0.80, label, ha="center", va="center", fontsize=10, color="#5B6770")
    ax.text(i + 0.5, 0.50, big, ha="center", va="center", fontsize=22, fontweight="bold", color="#264653")
    ax.text(i + 0.5, 0.20, f"{change} vs last month", ha="center", va="center", fontsize=9.5, color=color)

Five KPI tiles showing delivered revenue, orders, average delivery time, on-time rate and cancellation rate with coloured change arrows

How it works: We draw rounded rectangles with FancyBboxPatch and place three text lines in each: a small label, a big number and a coloured change line. The axes are turned off so only the tiles show. The tile pattern gives the reader the answer in a second: the number, and whether it is good news.

Reading the result: The five tiles show revenue ₹3.89 L, orders 630, average delivery 23.7 min, on-time 82.7% and cancellations 5.9%.

Example 5: A dashboard layout with GridSpec

Question: Combine the tiles and four charts on one page.

from matplotlib.gridspec import GridSpec
import matplotlib.dates as mdates
from matplotlib.ticker import FuncFormatter

delivered = o[o["order_status"] == "Delivered"].copy()
monthly = delivered.groupby(delivered["order_date"].dt.to_period("M").dt.to_timestamp())["value"].sum()
by_city = delivered.groupby("city").agg(revenue=("value", "sum"), avg=("delivery_minutes", "mean"), on_time=("delivery_minutes", lambda s: (s <= 30).mean())).sort_values("revenue")
canc = (o.assign(c=o["order_status"] == "Cancelled").groupby("city")["c"].mean().sort_values())

fig = plt.figure(figsize=(13, 8.2))
gs = GridSpec(3, 6, figure=fig, height_ratios=[0.8, 2.2, 2.2], hspace=0.55, wspace=0.9)

# tiles across the top
tile_gs = gs[0, :].subgridspec(1, 5, wspace=0.15)
tile_axes = [fig.add_subplot(tile_gs[0, i]) for i in range(5)]
for ax_t, (label, big, (change, color)) in zip(tile_axes, tiles):
    ax_t.axis("off")
    ax_t.text(0.5, 0.78, label, ha="center", fontsize=9, color="#5B6770", transform=ax_t.transAxes)
    ax_t.text(0.5, 0.40, big, ha="center", fontsize=16, fontweight="bold", color="#264653", transform=ax_t.transAxes)
    ax_t.text(0.5, 0.05, change, ha="center", fontsize=9, color=color, transform=ax_t.transAxes)

a1 = fig.add_subplot(gs[1, :4])
a1.plot(monthly.index, monthly.values, marker="o", linewidth=2)
a1.yaxis.set_major_formatter(FuncFormatter(lambda x, p: inr_short(x)))
a1.xaxis.set_major_formatter(mdates.DateFormatter("%b '%y"))
a1.set_title("Revenue is growing every quarter", loc="left")

a2 = fig.add_subplot(gs[1, 4:])
bars = a2.barh(by_city.index, by_city["revenue"] / 1e5, color="#028090")
a2.set_title("Delhi and Bengaluru lead", loc="left")
a2.set_xlabel("Revenue (₹ lakh)")
a2.grid(axis="y", visible=False)

a3 = fig.add_subplot(gs[2, :3])
a3.bar(by_city.index, by_city["avg"], color=["#E76F51" if v == by_city["avg"].max() else "#8D99AE" for v in by_city["avg"]])
a3.axhline(30, color="#264653", linestyle="--")
a3.text(-0.4, 30.4, "service target 30 min", fontsize=8, color="#264653")
a3.set_ylim(0, 34)
a3.set_title("Every city meets the 30-minute target", loc="left")
a3.tick_params(axis="x", rotation=45, labelsize=8)
a3.set_ylabel("Avg minutes")

a4 = fig.add_subplot(gs[2, 3:])
a4.barh(canc.index, canc.values * 100, color="#E76F51")
a4.set_title("Cancellations are 5-7% everywhere", loc="left")
a4.set_xlabel("Cancellation rate (%)")
a4.grid(axis="y", visible=False)

fig.suptitle("FreshBasket - Operations dashboard, latest month and trend", x=0.01, ha="left", fontsize=15, fontweight="bold")

One-page FreshBasket dashboard with a row of five KPI tiles, a revenue trend, a city revenue ranking, delivery time versus target and cancellation rates by city

How it works: GridSpec(rows, cols) divides the page into a grid, and each chart claims a rectangle of cells (gs[1, :4] is row 1, columns 0 to 3). Mixing spans such as 4+2 columns creates a hierarchy: the wide trend chart dominates, the ranking sits beside it. Titles are left-aligned headlines that state the finding. Accent colour is used only for the slowest city's bar; everything else is neutral or the brand teal.

Reading the result: The page combines 5 tiles and four charts. The longest city average is 24.0 minutes, well under the 30-minute target, so the headline "every city meets the target" is true. Check each title against the data before publishing.

Example 6: Highlight the exception

Question: Show which city needs action using one accent colour.

city_stats = by_city.copy()
city_stats["late_pct"] = 1 - city_stats["on_time"]
city_stats = city_stats.sort_values("late_pct")
worst = city_stats.index[-1]

fig, ax = plt.subplots(figsize=(8, 4.2))
colors = ["#E76F51" if c == worst else "#C5CCD3" for c in city_stats.index]
bars = ax.barh(city_stats.index, city_stats["late_pct"] * 100, color=colors)
ax.bar_label(bars, labels=[f"{v * 100:.1f}%" for v in city_stats["late_pct"]], padding=3, fontsize=9)
ax.set_xlabel("Deliveries slower than 30 minutes (%)")
ax.set_title(f"{worst} is slowest to deliver on time, but only narrowly", loc="left")
ax.grid(axis="y", visible=False)
ax.set_xlim(0, city_stats["late_pct"].max() * 100 * 1.2)

Output

(0.0, 20.813704496788006)

Horizontal bar chart of the share of late deliveries per city with the worst city highlighted in coral and the others grey

How it works: Grey for everything except the point you want to make is the most effective use of colour in storytelling: the eye goes straight to the highlighted bar. The title names the city and is honest about how small the gap is. Without the highlight, the reader would have to search for the maximum.

Reading the result: Pune has 17.3% late deliveries against 12.1% in the best city. Several cities are almost tied, so the title says "narrowly" rather than calling for action on one city.

Example 7: Add context with a target and annotation

Question: Make the revenue trend tell a story with a milestone.

fig, ax = plt.subplots(figsize=(9, 4.2))
ax.plot(monthly.index, monthly.values, linewidth=2.5, color="#028090")
ax.fill_between(monthly.index, monthly.values, alpha=0.12, color="#028090")
ax.yaxis.set_major_formatter(FuncFormatter(lambda x, p: inr_short(x)))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b '%y"))

first, last = monthly.iloc[0], monthly.iloc[-1]
ax.annotate(inr_short(first), (monthly.index[0], first), xytext=(10, 30), textcoords="offset points", arrowprops=dict(arrowstyle="-", color="#8D99AE"))
ax.annotate(inr_short(last), (monthly.index[-1], last), xytext=(-60, 5), textcoords="offset points", fontweight="bold", color="#028090")
ax.set_title(f"Monthly delivered revenue grew {last / first:.0f}x in {len(monthly) - 1} months", loc="left")
ax.set_ylim(bottom=0)
fig.autofmt_xdate()

Area line chart of monthly delivered revenue with labels at the first and last point showing growth of about twelve times

How it works: Labelling only the first and last points tells the whole story (where we started, where we are) without clutter. The fill_between adds weight, and the title quantifies the growth, which we compute from the data so the headline can never go stale: it is an f-string.

Reading the result: Revenue went from ₹32,635 to ₹3.89 L, growth of 11.9x. Building titles from computed values ensures that next month's refresh stays correct.

Example 8: Export as PNG and PDF

Question: Save the dashboard for chat and for email.

from matplotlib.backends.backend_pdf import PdfPages
from pathlib import Path

Path("out").mkdir(exist_ok=True)

fig.savefig("out/trend.png", dpi=200, bbox_inches="tight", facecolor="white")

with PdfPages("out/report.pdf") as pdf:
    for title, vals in [("Revenue by month", monthly), ("Late share by city", city_stats["late_pct"])]:
        f, a = plt.subplots(figsize=(8.27, 5))
        a.plot(range(len(vals)), vals.values, marker="o")
        a.set_title(title)
        pdf.savefig(f, bbox_inches="tight")
        plt.close(f)

for p in sorted(Path("out").glob("*")):
    if p.name in ("trend.png", "report.pdf"):
        print(p.name, p.stat().st_size > 2000)

Output

report.pdf True
trend.png True

How it works: fig.savefig writes PNG (for chat and slides). For a multi-page PDF, open PdfPages and call pdf.savefig(fig) once per figure; each becomes a page. Use facecolor="white" so the background is not transparent in messaging apps, and close figures after saving to release memory.

Reading the result: The PNG and a 2-page PDF were created. A weekly report script can regenerate both files automatically, as the automation module shows.

Example 9: A reusable dashboard function

Question: Make the dashboard repeatable for any month.

def month_summary(df, month):
    month = pd.Period(month)
    cur = kpis(df[df["month"] == month])
    old = kpis(df[df["month"] == month - 1])
    lines = []
    for key, label in [("revenue", "Delivered revenue"), ("orders", "Orders"), ("on_time", "On-time rate")]:
        text, _ = delta_text(cur[key], old[key])
        value_txt = inr_short(cur[key]) if key == "revenue" else (pct(cur[key]) if key == "on_time" else f"{cur[key]:,}")
        lines.append(f"{label}: {value_txt} ({text} vs {month - 1})")
    return lines

for m in ["2026-04", "2026-05", "2026-06"]:
    print(m, month_summary(o, m)[0])

print(month_summary(o, "2026-06")[2])

Output

2026-04 Delivered revenue: ₹3.76 L (▲ 18.0% vs 2026-03)
2026-05 Delivered revenue: ₹3.62 L (▼ 3.7% vs 2026-04)
2026-06 Delivered revenue: ₹3.89 L (▲ 7.3% vs 2026-05)
On-time rate: 82.7% (▲ 0.8% vs 2026-05)

How it works: Packaging the KPI computation into a function that takes a month means the same code produces last month's, this month's or any month's summary. This is the first step toward automation: a script that runs every Monday and emails the result. The text summary lines can double as the caption of a chart or the body of an email.

Reading the result: The function produced one line per KPI for each month: Delivered revenue: ₹3.89 L (▲ 7.3% vs 2026-05). Reusable functions turn a one-off analysis into a product.

Example 10: A final quality checklist

Question: Is the dashboard ready to share?

checklist = [
    ("Each chart title states a finding", True),
    ("Axes labelled with units", True),
    ("Bars start at zero", True),
    ("One accent colour, grey for context", True),
    ("Numbers formatted for the audience (lakh/crore)", True),
    ("Every title claim checked against data", True),
    ("Source and date shown", False),
]
done = sum(ok for _, ok in checklist)
for item, ok in checklist:
    print("[x]" if ok else "[ ]", item)
print(done, len(checklist))

Output

[x] Each chart title states a finding
[x] Axes labelled with units
[x] Bars start at zero
[x] One accent colour, grey for context
[x] Numbers formatted for the audience (lakh/crore)
[x] Every title claim checked against data
[ ] Source and date shown
6 7

How it works: Before you share, walk through a short checklist. The last item reminds us that a dashboard should also say where the data came from and as of when, which we have not yet added. A footer such as "Source: FreshBasket orders, as of 30 Jun 2026" prevents a lot of questions.

Reading the result: 6 of 7 checks pass. The missing one is easy to fix with fig.text(0.01, 0.01, "Source: ...").

Common mistakes

1. Dashboards with no clear question, packed with every chart you could think of.

2. Number formats that do not match the audience (millions instead of lakh and crore).

3. Colour without meaning, where every bar is a different colour.

4. Titles that describe instead of conclude ("Orders by city").

5. Hard-coded text in titles that becomes wrong when the data refreshes.

fig, ax = plt.subplots()
ax.set_title(f"Revenue grew {int(last) // 0}x")

Output

Traceback (most recent call last):
  File "example.py", line 2, in <module>
    ax.set_title(f"Revenue grew {int(last) // 0}x")
                                 ~~~~~~~~~~^^~~
ZeroDivisionError: integer division or modulo by zero

Practice questions

Practice 1 (Easy): Add a source footer

Add a footer to the revenue trend figure saying "Source: FreshBasket orders, to 30 Jun 2026" and save it as out/trend_footer.png.

Show solution
fig.text(0.01, -0.02, "Source: FreshBasket orders, to 30 Jun 2026", fontsize=8, color="#8D99AE")
fig.savefig("out/trend_footer.png", dpi=150, bbox_inches="tight", facecolor="white")
print(Path("out/trend_footer.png").exists())

Output

True

fig.text places text in figure coordinates (0 to 1), so a negative y puts it below the axes, and bbox_inches="tight" keeps it in the saved image.

Practice 2 (Medium): Compare two months of cancellations

For the latest two months, compute the cancellation rate per city and plot a grouped bar chart.

Show solution
rates = (
    o[o["month"].isin([prev, latest])]
    .assign(c=lambda d: d["order_status"] == "Cancelled")
    .pivot_table(index="city", columns="month", values="c", aggfunc="mean")
    * 100
)
ax = rates.plot(kind="bar", figsize=(9, 4), color=["#8D99AE", "#E76F51"], width=0.75)
ax.set_ylabel("Cancellation rate (%)")
ax.set_xlabel("")
ax.set_title("Cancellation rates by city, last two months")
ax.legend(title="Month", frameon=False)
plt.xticks(rotation=30)

Output

(array([0, 1, 2, 3, 4, 5, 6, 7]), [Text(0, 0, 'Ahmedabad'), Text(1, 0, 'Bengaluru'), Text(2, 0, 'Chennai'), Text(3, 0, 'Delhi'), Text(4, 0, 'Hyderabad'), Text(5, 0, 'Kolkata'), Text(6, 0, 'Mumbai'), Text(7, 0, 'Pune')])

Grouped bar chart comparing each city's cancellation rate in the previous month and the latest month

The pivot gives one column per month. The highest latest-month rate is 8.9% in Ahmedabad. With small monthly counts per city, expect noisy rates.

Practice 3 (Hard): Write the narrative

Write a function headline(o, month) that returns a one-sentence headline such as "Revenue ₹X (up Y% on last month); on-time rate Z%; weakest city: C", built from the data.

Show solution
def headline(df, month):
    month = pd.Period(month)
    d = df[df["month"] == month]
    c, p = kpis(d), kpis(df[df["month"] == month - 1])
    change = (c["revenue"] - p["revenue"]) / p["revenue"]
    word = "up" if change >= 0 else "down"
    late = (d[d["order_status"] == "Delivered"].groupby("city")["delivery_minutes"].apply(lambda s: (s > 30).mean()))
    return f"Revenue {inr_short(c['revenue'])} ({word} {abs(change):.0%} on last month); on-time rate {c['on_time']:.0%}; weakest city: {late.idxmax()}"

print(headline(o, "2026-06"))
print(headline(o, "2026-05"))

Output

Revenue ₹3.89 L (up 7% on last month); on-time rate 83%; weakest city: Pune
Revenue ₹3.62 L (down 4% on last month); on-time rate 82%; weakest city: Kolkata

Generating text from data keeps the narrative accurate as numbers change. The June headline is: Revenue ₹3.89 L (up 7% on last month); on-time rate 83%; weakest city: Pune.

Key takeaways

  • Start with the audience, the question and the decision, then pick a few KPIs.
  • Always show comparison: previous period, target or benchmark.
  • Use KPI tiles for the headline numbers and GridSpec to lay out charts in a hierarchy.
  • Write titles that state the finding, and generate them from data with f-strings.
  • Use one accent colour and grey for context; encode "good or bad" with business rules.
  • Format numbers for your audience (lakh and crore) with shared helper functions.
  • Export PNG and PDF, add a source line, and wrap the whole thing in a function so it can be re-run.

Frequently asked questions

How many charts should a dashboard have?

Usually four to six plus KPI tiles. If you need more, split into pages by topic.

Should I use Matplotlib or a BI tool for dashboards?

For a one-off or automated static report, Matplotlib is excellent and fully scriptable. For interactive, shared, always-on dashboards, use Power BI, Tableau, Looker Studio or a Python framework such as Streamlit.

How do I choose colours that work for colour-blind readers?

Avoid red-versus-green as the only signal; combine colour with arrows, labels or position. The Skillancy palette's teal and coral differ in lightness as well as hue.

How do I update the dashboard every week?

Wrap the build in a function that takes a date, and schedule the script with cron, Task Scheduler or a cloud job. The automation module returns to this.

What is the best way to present uncertainty?

Show ranges or confidence bands, avoid false precision (do not show 12.3456%) and say when sample sizes are small.

Why use left-aligned titles?

They read like newspaper headlines and line up with the axis, which makes a page of charts feel orderly.

Newsletter

Keep learning Python

One email a week with Python and analytics guides, new practice questions and cohort dates. No spam, unsubscribe anytime.

Ready to practise? Try the Python interview questions