How-to
Automating Reporting WorkflowsDeep dive

Build a Monthly KPI Scorecard with Targets

Build a one-page monthly KPI scorecard in Excel with pandas and XlsxWriter: KPI definitions with targets, direction and tolerance kept as data, red-amber-green status that knows when lower is better, variance against target and last month, year-to-date figures that sum or average correctly, six-month sparklines, and a status check that matches the colours.

The monthly management pack opens with a scorecard: a dozen KPIs, this month's figure, the target, whether it is on track, and which way it is heading. The trap is that each KPI behaves differently. Revenue should be above target, but complaint rate and days sales outstanding should be below it; some KPIs add up over the year, others are averages or ratios that must not be summed; and "on track" usually means "within a tolerance", not "exactly on target". Hand-maintained scorecards get these rules subtly wrong in one row and nobody notices. Putting the rules in a definitions table and computing status in pandas makes the scorecard consistent month after month. This page builds one, writes it as a single Excel sheet with coloured status and sparklines, and checks the colours match the rules. It belongs to Building Finance and Operations Reports.

Prerequisites

Bash
pip install pandas openpyxl xlsxwriter

kpi_actuals.xlsx is a long table with one row per month and KPI from April to September 2026. kpi_targets.xlsx defines each KPI: its monthly target, whether higher or lower is better, the tolerance (as a fraction of target) within which a miss is amber rather than red, and whether the year-to-date figure is a sum or an average.

Step 1 — Keep the KPI rules as data

The definitions table is the heart of the scorecard. Every rule that differs between KPIs lives there, not in code:

Python
import pandas as pd

actuals = pd.read_excel("kpi_actuals.xlsx")
defs = pd.read_excel("kpi_targets.xlsx").set_index("KPI")
print(defs.to_string())

missing = set(actuals["KPI"]) ^ set(defs.index)
assert not missing, f"KPIs without definitions or data: {missing}"

Better says which direction is good, Tolerance how far from target still counts as amber, and YTD how to aggregate across months. The symmetric difference check (^) fails if a KPI appears in the data without a definition or the other way round — adding a KPI then requires deciding its rules, which is exactly the conversation that should happen. Owners can maintain this table in Excel themselves.

Step 2 — Red, amber or green

Status depends on direction. Express each actual as a performance relative to target where positive is always good, then compare with the tolerance:

Python
month = "2026-09"
prev = "2026-08"
wide = actuals.pivot(index="KPI", columns="Month", values="Actual")

def status(actual, target, better, tol):
    perf = (actual - target) / target if better == "higher" else (target - actual) / target
    perf = round(perf, 9)                 # 2.0 vs 2.2 must land exactly on a 10% tolerance
    if perf >= 0:
        return "Green", perf
    return ("Amber" if perf >= -tol else "Red"), perf

card = defs.copy()
card["Actual"] = wide[month]
card["Last month"] = wide[prev]
card[["Status", "Perf"]] = [status(a, t, b, tol) for a, t, b, tol in
                            zip(card["Actual"], card["Target"], card["Better"], card["Tolerance"])]
print(card[["Actual", "Target", "Better", "Perf", "Status"]].round(3).to_string())

For a higher-is-better KPI, performance is (actual − target) / target; for lower-is-better it is flipped, so beating a complaint-rate target of 2.0% with 1.8% would be positive. September's revenue of 468 against 440 is green; gross margin at 37.2% against 38.0% is 2.1% short with a 2% tolerance, so red; complaint rate at 2.2% against a ceiling of 2.0% is 10% worse, exactly at the tolerance, so amber; days sales outstanding at 47 against 40 is far out — red. The round matters more than it looks. Without it, the complaint rate's performance comes out as −0.10000000000000009 in floating point — fractionally beyond the −0.10 tolerance — and the KPI turns red although it sits exactly on the amber boundary. Rounding to a sensible precision before comparing makes boundary cases behave as the definitions say. Defining tolerance as a fraction of the target keeps it meaningful across KPIs measured in thousands of euros and in percentage points alike.

How a KPI gets its colour The actual is turned into a performance figure relative to target, flipped for lower-is-better KPIs so positive is always good. At or above zero is green. Below zero but within the tolerance is amber. Further below is red. Performance vs target (positive = good) ≥ 0 Green on or better than target within tolerance Amber e.g. complaint rate 2.2 vs 2.0 beyond tolerance Red e.g. DSO 47 vs 40

Step 3 — Variance, trend and year to date

The scorecard also shows the variance against target and last month, and a year-to-date figure that respects how each KPI aggregates:

Python
card["vs target"] = card["Actual"] - card["Target"]
card["vs last month"] = card["Actual"] - card["Last month"]

ytd_months = [m for m in wide.columns if m <= month]
def ytd(kpi):
    values = wide.loc[kpi, ytd_months]
    return values.sum() if defs.loc[kpi, "YTD"] == "sum" else values.mean()

card["YTD"] = [ytd(k) for k in card.index]
card["YTD target"] = [defs.loc[k, "Target"] * (len(ytd_months) if defs.loc[k, "YTD"] == "sum" else 1) for k in card.index]
print(card[["Actual", "vs target", "vs last month", "YTD", "YTD target"]].round(1).to_string())

Revenue and new customers add up — six months of revenue total 2,556k against a year-to-date target of 2,640k. Percentages and days outstanding must not be summed; their year-to-date figure is the average of the months (a weighted average is more accurate when volumes differ — weight margin by revenue, for example — and is worth doing if the definitions table carries the weight column). The month-on-month change gives direction: days sales outstanding worsened by 3 days, on-time delivery improved by 3.2 points.

Year to date — sum or average? Volume KPIs such as revenue and new customers add up across months, and their year-to-date target is the monthly target times the number of months. Rate KPIs such as margin, complaint rate, days outstanding and on-time delivery are averaged, ideally weighted by volume, against the same monthly target. YTD: sum Revenue (k€): 2,556 vs 2,640 New customers: 255 vs 270 target × months YTD: average Gross margin %: 37.9 vs 38 Days sales outstanding: 42.3 vs 40 weight by volume if you can

Step 4 — Write a one-page scorecard

XlsxWriter writes the sheet with a status cell coloured by conditional formatting, number formats per KPI and a sparkline of the last six months on each row:

Python
import xlsxwriter

book = xlsxwriter.Workbook("kpi_scorecard_2026-09.xlsx")
ws = book.add_worksheet("Scorecard")
data = book.add_worksheet("Trend")
bold = book.add_format({"bold": True, "bottom": 1, "bg_color": "#EEF2FF"})
title = book.add_format({"bold": True, "font_size": 16, "font_color": "#4338CA"})
num = book.add_format({"num_format": "#,##0.0"})
signed = book.add_format({"num_format": "+#,##0.0;[Red]-#,##0.0;0.0"})
fills = {"Green": "#C6EFCE", "Amber": "#FFEB9C", "Red": "#FFC7CE"}

ws.write("A1", "KPI scorecard — September 2026", title)
headers = ["KPI", "Actual", "Target", "Status", "vs target", "vs last month", "YTD", "YTD target", "Trend (6 months)"]
ws.write_row(2, 0, headers, bold)
data.write_row(0, 0, ["KPI"] + list(wide.columns))
for r, (kpi, row) in enumerate(card.iterrows(), start=3):
    ws.write(r, 0, kpi)
    for c, col in enumerate(["Actual", "Target"], start=1):
        ws.write_number(r, c, row[col], num)
    ws.write(r, 3, row["Status"])
    ws.write_number(r, 4, row["vs target"], signed)
    ws.write_number(r, 5, row["vs last month"], signed)
    ws.write_number(r, 6, row["YTD"], num)
    ws.write_number(r, 7, row["YTD target"], num)
    data.write(r - 2, 0, kpi)
    data.write_row(r - 2, 1, list(wide.loc[kpi]))
    ws.add_sparkline(r, 8, {"range": f"Trend!B{r - 1}:G{r - 1}", "markers": True, "last_point": True,
                            "series_color": "#4338CA", "last_color": "#B91C1C"})
last = 3 + len(card) - 1
for status_name, colour in fills.items():
    ws.conditional_format(3, 3, last, 3, {"type": "cell", "criteria": "==", "value": f'"{status_name}"',
                                          "format": book.add_format({"bg_color": colour, "bold": True})})
ws.set_column("A:A", 24); ws.set_column("B:H", 12); ws.set_column("I:I", 20)
ws.set_landscape(); ws.fit_to_pages(1, 1); ws.hide_gridlines(2)
book.close()
print("scorecard rows:", len(card), "| status counts:", card["Status"].value_counts().to_dict())

Status is written as text and coloured by three conditional formats, so a reader who prints in black and white still sees the word, and anyone who sorts or filters keeps the colour attached. The sparklines read from a Trend sheet holding six months per KPI, with the latest point marked in red; they make it obvious that days sales outstanding has climbed two months running, from 39 to 47. fit_to_pages(1, 1) keeps the scorecard on one landscape page when printed or exported to PDF. Sparklines in more depth are covered in Add Sparklines to an Excel Report with XlsxWriter.

The September scorecard Six KPIs with actual, target and status. Revenue, new customers and on-time delivery are green, complaint rate is amber, gross margin and days sales outstanding are red. Each row ends with a six-month sparkline. A B C D E 1 KPI Actual Target Status vs last month 2 Revenue (k€) 468.0 440.0 Green +67.0 3 Gross margin % 37.2 38.0 Red +0.8 4 New customers 44.0 45.0 Amber +11.0 5 Complaint rate % 2.2 2.0 Amber -0.4 6 Days sales outstanding 47.0 40.0 Red +3.0 7 On-time delivery % 96.4 95.0 Green +3.2

Step 5 — Check the colours match the rules

A scorecard with a wrong colour is worse than none. Read the written file back and recompute every status from the definitions:

Python
from openpyxl import load_workbook

ws = load_workbook("kpi_scorecard_2026-09.xlsx")["Scorecard"]
mismatches = []
for r in range(4, 4 + len(card)):
    kpi, actual, target, shown = (ws.cell(r, c).value for c in (1, 2, 3, 4))
    d = defs.loc[kpi]
    expected, _ = status(actual, target, d["Better"], d["Tolerance"])
    if shown != expected:
        mismatches.append((kpi, shown, expected))
print("status mismatches:", mismatches or "none")
print("lower-is-better KPIs:", list(defs.index[defs["Better"] == "lower"]))

openpyxl warns that it does not support the sparkline extension — harmless when only reading, but never save the scorecard with openpyxl, or the sparklines are dropped. The check reads what the reader will see — the values and the status text in the workbook — and recomputes each status from the definitions independently of the code that wrote it. Lower-is-better KPIs are listed explicitly because they are where hand-built scorecards most often go wrong. Running this in the job before the scorecard is distributed catches a broken rule the month it breaks.

Common pitfalls

  • Treating every KPI as higher-is-better. Store the direction and flip the comparison.
  • Summing percentages for year to date. Average them, ideally weighted.
  • Tolerances in absolute units. A fraction of target works across KPIs of any scale.
  • Colour without text. Write the status word; colour alone fails in print and for colour-blind readers.
  • Rules hidden in code. Keep targets, direction, tolerance and aggregation in a definitions table.

Conclusion

A reliable KPI scorecard starts with a definitions table — target, direction, tolerance and year-to-date rule per KPI — and computes status from a direction-aware performance figure, so lower-is-better KPIs and amber bands are handled the same way every month. Add variance against target and last month, year-to-date figures that sum or average correctly, and six-month sparklines, write it as one landscape page with status text coloured by conditional formatting, and recheck the written statuses before the pack goes out.

Frequently asked questions

How do I calculate red, amber, green status for KPIs in Python? Compute performance as actual minus target over target, flipped for KPIs where lower is better. Zero or more is green, a shortfall within the tolerance is amber, and anything worse is red.

How do I handle KPIs where lower is better? Store the direction in the KPI definitions and compute performance as target minus actual over target for those KPIs, so a positive result always means better than target.

How do I calculate year-to-date for percentage KPIs? Do not sum them. Average the monthly values, or better, compute a weighted average using the underlying volumes, such as margin weighted by revenue.

How do I add trend sparklines to an Excel scorecard? With XlsxWriter, write each KPI's monthly values to a data sheet and call add_sparkline on the scorecard row with the range of those values.

More on report layouts & templates

More on report layouts & templates →