Learn how to build a professional Excel sales dashboard that regenerates itself from a live data source using pandas and openpyxl. Covers automated chart generation, four types of conditional formatting, named ranges, and Excel Tables — all from a clean, schedulable Python script.

Picture this: every Monday morning, your sales director asks for the same dashboard — revenue by region, top reps by quota attainment, month-over-month trends, and a traffic-light summary of which territories are hitting their targets. Every Monday morning, someone opens an Excel file, pastes in new data from the CRM export, re-runs some formulas, drags the chart ranges, and adjusts the conditional formatting that inevitably broke again. That someone is probably you.
There's a better way. By combining pandas for data transformation with openpyxl for Excel writing, you can build a Python script that consumes a live data source — a CRM export, a SQL query, a REST API — and produces a fully formatted, chart-equipped, conditionally highlighted dashboard every time you run it. Not a script that dumps raw data and leaves formatting to a human. A real dashboard: professional-grade, ready to email or share the moment the script finishes.
By the end of this lesson, you'll have built a self-updating sales dashboard pipeline from scratch. The techniques here sit at the intersection of data engineering and reporting — you're not just writing analysis code, you're building infrastructure that removes a recurring manual task forever.
What you'll learn:
Workbook API with full formatting controlYou should be comfortable with:
groupby, and pivot_table — the groupby article covers this in depthInstall dependencies if you haven't already:
pip install pandas openpyxl requests
Before writing a single line of code, let's establish what we're building against. Real dashboards fail when they're designed around one specific CSV layout that changes the moment someone renames a column. Our design will be explicit about the expected schema and fail loudly when the input doesn't match it.
Our data source is a monthly CRM export with individual sales transaction rows. We'll simulate this with generated data, but the same pattern works whether you're reading from a CSV file, a SQL database via SQLAlchemy, or an API response.
import pandas as pd
import numpy as np
from datetime import date, timedelta
import random
def generate_sales_data(n_rows: int = 2000, seed: int = 42) -> pd.DataFrame:
"""
Simulate a CRM transaction export.
Columns match what you'd get from Salesforce or HubSpot exports.
"""
rng = np.random.default_rng(seed)
random.seed(seed)
regions = ["Northeast", "Southeast", "Midwest", "West", "International"]
products = ["Enterprise License", "Pro License", "Support Contract", "Training", "Professional Services"]
reps = {
"Northeast": ["Priya Anand", "Marcus Webb", "Tina Okonkwo"],
"Southeast": ["Jorge Delgado", "Samantha Ruiz", "Ben Hartley"],
"Midwest": ["Claire Fontaine", "Tyler Moss", "Aisha Johnson"],
"West": ["Rafael Kim", "Natalie Chen", "Derek Pham"],
"International": ["Sofia Eriksson", "Liam O'Brien", "Yuki Tanaka"],
}
# Generate dates spread across the last 12 months
base_date = date(2024, 1, 1)
dates = [base_date + timedelta(days=int(d)) for d in rng.integers(0, 365, n_rows)]
region_col = rng.choice(regions, n_rows)
rep_col = [random.choice(reps[r]) for r in region_col]
product_col = rng.choice(products, n_rows, p=[0.3, 0.25, 0.2, 0.15, 0.1])
base_prices = {
"Enterprise License": 45000,
"Pro License": 12000,
"Support Contract": 8000,
"Training": 3000,
"Professional Services": 18000,
}
amounts = [
base_prices[p] * rng.uniform(0.8, 1.3)
for p in product_col
]
# Quotas by region
quotas = {
"Northeast": 800000,
"Southeast": 650000,
"Midwest": 700000,
"West": 900000,
"International": 600000,
}
df = pd.DataFrame({
"close_date": dates,
"region": region_col,
"rep_name": rep_col,
"product": product_col,
"amount": np.round(amounts, 2),
"stage": rng.choice(["Closed Won", "Closed Lost"], n_rows, p=[0.68, 0.32]),
})
df["quota"] = df["region"].map(quotas)
df["close_date"] = pd.to_datetime(df["close_date"])
df["month"] = df["close_date"].dt.to_period("M")
return df
df_raw = generate_sales_data()
print(df_raw.head())
print(df_raw.dtypes)
Note
In production, replace generate_sales_data() with your actual data ingestion call — a pd.read_csv(), a pd.read_sql() using SQLAlchemy, or a call to flatten a JSON API response. The transformation and export code that follows is completely decoupled from the source.
The dashboard will have four sheets: an executive summary, a regional breakdown, a rep leaderboard, and a monthly trend sheet. Each sheet is driven by a summary DataFrame produced in pandas before we touch openpyxl at all. This separation is intentional — your aggregation logic should be testable independently of your formatting logic.
Everything downstream operates on closed-won deals. Let's filter once and name the result clearly.
df_won = df_raw[df_raw["stage"] == "Closed Won"].copy()
def build_regional_summary(df: pd.DataFrame) -> pd.DataFrame:
"""
Revenue by region with quota attainment and deal count.
"""
regional = (
df.groupby("region", as_index=False)
.agg(
total_revenue=("amount", "sum"),
deal_count=("amount", "count"),
avg_deal_size=("amount", "mean"),
)
)
# Each region has a fixed quota — grab it from the first row per region
quotas = df.groupby("region")["quota"].first().reset_index()
regional = regional.merge(quotas, on="region")
regional["attainment_pct"] = (regional["total_revenue"] / regional["quota"] * 100).round(1)
regional["status"] = pd.cut(
regional["attainment_pct"],
bins=[0, 70, 90, float("inf")],
labels=["At Risk", "On Track", "Exceeding"],
)
return regional.sort_values("total_revenue", ascending=False).reset_index(drop=True)
regional_df = build_regional_summary(df_won)
print(regional_df)
This is a clean, self-contained transformation. Notice we're using pd.cut to assign performance buckets — a technique covered more thoroughly in Conditional Column Creation in pandas.
def build_rep_leaderboard(df: pd.DataFrame, top_n: int = 15) -> pd.DataFrame:
leaderboard = (
df.groupby(["rep_name", "region"], as_index=False)
.agg(
total_revenue=("amount", "sum"),
deal_count=("amount", "count"),
avg_deal_size=("amount", "mean"),
)
)
leaderboard["rank"] = leaderboard["total_revenue"].rank(
ascending=False, method="min"
).astype(int)
return (
leaderboard
.sort_values("total_revenue", ascending=False)
.head(top_n)
.reset_index(drop=True)
)
leaderboard_df = build_rep_leaderboard(df_won)
def build_monthly_trend(df: pd.DataFrame) -> pd.DataFrame:
trend = (
df.groupby("month", as_index=False)
.agg(revenue=("amount", "sum"), deals=("amount", "count"))
)
trend["month_str"] = trend["month"].astype(str) # "2024-01" etc.
trend["mom_change"] = trend["revenue"].pct_change() * 100
return trend.sort_values("month").reset_index(drop=True)
trend_df = build_monthly_trend(df_won)
def build_product_summary(df: pd.DataFrame) -> pd.DataFrame:
product = (
df.groupby("product", as_index=False)
.agg(revenue=("amount", "sum"), deals=("amount", "count"))
)
product["revenue_pct"] = (product["revenue"] / product["revenue"].sum() * 100).round(1)
return product.sort_values("revenue", ascending=False).reset_index(drop=True)
product_df = build_product_summary(df_won)
Key insight
Do all of your aggregation in pandas. openpyxl's job is formatting and chart generation — it has no concept of group-by or window functions. Keep those layers completely separate or you'll end up with fragile, hard-to-debug report generation code.
Now we hand control to openpyxl. The architecture is a class-based builder that receives our summary DataFrames and knows how to write each sheet.
from openpyxl import Workbook
from openpyxl.styles import (
Font, PatternFill, Alignment, Border, Side, numbers
)
from openpyxl.styles.numbers import FORMAT_NUMBER_COMMA_SEP1, FORMAT_PERCENTAGE_00
from openpyxl.utils import get_column_letter, column_index_from_string
from openpyxl.utils.dataframe import dataframe_to_rows
from openpyxl.chart import BarChart, LineChart, Reference
from openpyxl.chart.series import DataPoint
from openpyxl.formatting.rule import (
ColorScaleRule, DataBarRule, IconSetRule, CellIsRule, FormulaRule
)
from openpyxl.workbook.defined_name import DefinedName
from openpyxl.worksheet.table import Table, TableStyleInfo
import datetime
class SalesDashboardBuilder:
"""
Builds a fully formatted Excel sales dashboard from summary DataFrames.
"""
# Brand colors (hex, no #)
DARK_BLUE = "1F3864"
MID_BLUE = "2E75B6"
LIGHT_BLUE = "D6E4F0"
GREEN = "375623"
GREEN_FILL = "E2EFDA"
AMBER = "833C00"
AMBER_FILL = "FCE4D6"
RED = "9C0006"
RED_FILL = "FFC7CE"
WHITE = "FFFFFF"
LIGHT_GRAY = "F2F2F2"
def __init__(
self,
regional_df: pd.DataFrame,
leaderboard_df: pd.DataFrame,
trend_df: pd.DataFrame,
product_df: pd.DataFrame,
report_date: date = None,
):
self.regional = regional_df
self.leaderboard = leaderboard_df
self.trend = trend_df
self.product = product_df
self.report_date = report_date or date.today()
self.wb = Workbook()
# Remove the default sheet
self.wb.remove(self.wb.active)
def build(self) -> Workbook:
self._build_summary_sheet()
self._build_regional_sheet()
self._build_leaderboard_sheet()
self._build_trend_sheet()
self._set_workbook_properties()
return self.wb
def save(self, path: str) -> None:
self.wb.save(path)
print(f"Dashboard saved to {path}")
This class will gain methods section by section. Let's start filling them in.
The summary sheet is the first thing stakeholders see. It should communicate the key numbers without requiring them to click anywhere else.
def _build_summary_sheet(self):
ws = self.wb.create_sheet("Executive Summary")
ws.sheet_view.showGridLines = False
# ── Title block ──────────────────────────────────────────────────────
ws.merge_cells("A1:G1")
title_cell = ws["A1"]
title_cell.value = "Sales Performance Dashboard"
title_cell.font = Font(name="Calibri", size=20, bold=True, color=self.WHITE)
title_cell.fill = PatternFill("solid", fgColor=self.DARK_BLUE)
title_cell.alignment = Alignment(horizontal="center", vertical="center")
ws.row_dimensions[1].height = 36
ws.merge_cells("A2:G2")
subtitle = ws["A2"]
subtitle.value = f"Report Date: {self.report_date.strftime('%B %d, %Y')}"
subtitle.font = Font(name="Calibri", size=11, italic=True, color=self.WHITE)
subtitle.fill = PatternFill("solid", fgColor=self.MID_BLUE)
subtitle.alignment = Alignment(horizontal="center", vertical="center")
ws.row_dimensions[2].height = 20
# ── KPI cards ─────────────────────────────────────────────────────────
total_revenue = self.regional["total_revenue"].sum()
total_deals = self.regional["deal_count"].sum()
avg_attainment = self.regional["attainment_pct"].mean()
top_region = self.regional.iloc[0]["region"]
kpis = [
("Total Revenue", f"${total_revenue:,.0f}", self.MID_BLUE),
("Total Deals Closed", f"{total_deals:,}", self.MID_BLUE),
("Avg Quota Attainment", f"{avg_attainment:.1f}%", self.MID_BLUE),
("Top Region", top_region, self.MID_BLUE),
]
# Each KPI card occupies two columns, starting at row 4
card_start_row = 4
for i, (label, value, color) in enumerate(kpis):
col_start = i * 2 + 1 # columns 1, 3, 5, 7
col_letter = get_column_letter(col_start)
col_letter2 = get_column_letter(col_start + 1)
ws.merge_cells(f"{col_letter}{card_start_row}:{col_letter2}{card_start_row}")
label_cell = ws[f"{col_letter}{card_start_row}"]
label_cell.value = label
label_cell.font = Font(name="Calibri", size=9, bold=True, color=self.WHITE)
label_cell.fill = PatternFill("solid", fgColor=color)
label_cell.alignment = Alignment(horizontal="center")
ws.merge_cells(f"{col_letter}{card_start_row+1}:{col_letter2}{card_start_row+1}")
value_cell = ws[f"{col_letter}{card_start_row+1}"]
value_cell.value = value
value_cell.font = Font(name="Calibri", size=16, bold=True, color=color)
value_cell.fill = PatternFill("solid", fgColor=self.LIGHT_BLUE)
value_cell.alignment = Alignment(horizontal="center", vertical="center")
ws.row_dimensions[card_start_row + 1].height = 28
# ── Regional summary table embedded on summary sheet ──────────────────
self._write_dataframe_styled(
ws=ws,
df=self.regional[["region", "total_revenue", "deal_count", "attainment_pct", "status"]],
start_row=8,
start_col=1,
headers=["Region", "Revenue", "Deals", "Attainment %", "Status"],
col_widths=[18, 18, 10, 16, 14],
)
# Column widths for the card layout
for col in range(1, 9):
ws.column_dimensions[get_column_letter(col)].width = 14
The _write_dataframe_styled helper writes a DataFrame into a sheet with formatted headers and alternating row colors. Let's define that next — it's used on every sheet.
def _write_dataframe_styled(
self,
ws,
df: pd.DataFrame,
start_row: int,
start_col: int,
headers: list = None,
col_widths: list = None,
number_formats: dict = None, # {col_index: format_string}
) -> tuple:
"""
Write a DataFrame to ws starting at (start_row, start_col).
Returns (last_row, last_col) for chart anchoring.
"""
headers = headers or list(df.columns)
number_formats = number_formats or {}
header_fill = PatternFill("solid", fgColor=self.DARK_BLUE)
header_font = Font(name="Calibri", size=10, bold=True, color=self.WHITE)
alt_fill = PatternFill("solid", fgColor=self.LIGHT_GRAY)
border_side = Side(style="thin", color="CCCCCC")
thin_border = Border(
left=border_side, right=border_side,
top=border_side, bottom=border_side
)
# Write headers
for j, col_name in enumerate(headers):
cell = ws.cell(row=start_row, column=start_col + j, value=col_name)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center")
cell.border = thin_border
# Write data rows
for i, row_data in enumerate(df.itertuples(index=False), start=1):
fill = alt_fill if i % 2 == 0 else PatternFill("solid", fgColor=self.WHITE)
for j, val in enumerate(row_data):
cell = ws.cell(row=start_row + i, column=start_col + j, value=val)
cell.fill = fill
cell.border = thin_border
cell.alignment = Alignment(horizontal="center")
if j in number_formats:
cell.number_format = number_formats[j]
# Set column widths
if col_widths:
for j, width in enumerate(col_widths):
ws.column_dimensions[get_column_letter(start_col + j)].width = width
last_row = start_row + len(df)
last_col = start_col + len(headers) - 1
return last_row, last_col
Conditional formatting is where dashboards go from informative to actionable. openpyxl supports four major rule types: color scales, data bars, icon sets, and formula-based rules. We'll use all four.
On the regional sheet, we want entire rows highlighted based on the "status" column value.
def _build_regional_sheet(self):
ws = self.wb.create_sheet("Regional Breakdown")
ws.sheet_view.showGridLines = False
# Write the data
last_row, last_col = self._write_dataframe_styled(
ws=ws,
df=self.regional,
start_row=2,
start_col=1,
headers=["Region", "Revenue", "Deals", "Avg Deal Size", "Quota", "Attainment %", "Status"],
col_widths=[18, 16, 10, 16, 16, 14, 14],
number_formats={
1: '"$"#,##0',
3: '"$"#,##0',
4: '"$"#,##0',
5: '0.0"%"',
},
)
# Number of data rows (exclude header)
n_data_rows = len(self.regional)
data_start = 3 # row 2 is header, data starts row 3
data_end = 2 + n_data_rows
full_range = f"A{data_start}:G{data_end}"
# "At Risk" rows → red
ws.conditional_formatting.add(
full_range,
FormulaRule(
formula=[f'$G{data_start}="At Risk"'],
fill=PatternFill("solid", fgColor=self.RED_FILL),
font=Font(color=self.RED, bold=True),
),
)
# "Exceeding" rows → green
ws.conditional_formatting.add(
full_range,
FormulaRule(
formula=[f'$G{data_start}="Exceeding"'],
fill=PatternFill("solid", fgColor=self.GREEN_FILL),
font=Font(color=self.GREEN, bold=True),
),
)
# "On Track" rows → amber
ws.conditional_formatting.add(
full_range,
FormulaRule(
formula=[f'$G{data_start}="On Track"'],
fill=PatternFill("solid", fgColor=self.AMBER_FILL),
font=Font(color=self.AMBER),
),
)
# Color scale on attainment column (column F = index 5, 1-based col 6)
attainment_col_letter = get_column_letter(6)
attainment_range = f"{attainment_col_letter}{data_start}:{attainment_col_letter}{data_end}"
ws.conditional_formatting.add(
attainment_range,
ColorScaleRule(
start_type="num", start_value=0, start_color="F8696B",
mid_type="num", mid_value=90, mid_color="FFEB84",
end_type="num", end_value=120, end_color="63BE7B",
),
)
# Data bars on the revenue column (col B = col 2)
revenue_col_letter = get_column_letter(2)
revenue_range = f"{revenue_col_letter}{data_start}:{revenue_col_letter}{data_end}"
ws.conditional_formatting.add(
revenue_range,
DataBarRule(
start_type="min", start_value=None,
end_type="max", end_value=None,
color=self.MID_BLUE,
),
)
# Add a bar chart anchored below the table
self._add_regional_bar_chart(ws, data_start, data_end)
Warning
FormulaRule formulas must reference the first row of the applied range using a mixed reference (e.g., $G3 anchors the column but allows the row to shift as Excel evaluates each row). Get this wrong and every row will evaluate the formula against the same cell. Always lock the column with $, never the row.
Named ranges are one of the most underused features in programmatic Excel generation. They let you write formulas like =SUM(RegionalRevenue) instead of =SUM('Regional Breakdown'!B3:B7). When your data grows, the named range expands — the formula doesn't need to change.
def _define_named_range(self, sheet_title: str, cell_range: str, name: str):
"""
Define a workbook-level named range.
"""
ref = f"'{sheet_title}'!{cell_range}"
defn = DefinedName(name=name, attr_text=ref)
self.wb.defined_names[name] = defn
Call this after writing each sheet:
# After writing regional data (rows 3 to data_end, columns B through F)
n = len(self.regional)
self._define_named_range(
"Regional Breakdown",
f"$B$3:$B${2+n}",
"RegionalRevenue"
)
self._define_named_range(
"Regional Breakdown",
f"$F$3:$F${2+n}",
"RegionalAttainment"
)
self._define_named_range(
"Regional Breakdown",
f"$A$3:$A${2+n}",
"RegionNames"
)
Tip
Named range names cannot contain spaces or start with a number. Stick to PascalCase or underscore-separated names. Also note that self.wb.defined_names is a dictionary-like object in newer openpyxl versions — in older versions (before 3.1), the API was self.wb.defined_names.append(defn). Check your installed version with import openpyxl; print(openpyxl.__version__).
openpyxl's chart objects are driven by Reference objects that point at cell ranges. When those ranges contain fresh data on every run, the charts automatically reflect it. This is the mechanism that makes the dashboard "self-updating" — the chart definition is relative to the data, not hardcoded to specific values.
def _add_regional_bar_chart(self, ws, data_start: int, data_end: int):
chart = BarChart()
chart.type = "col"
chart.grouping = "clustered"
chart.title = "Revenue by Region"
chart.style = 10
chart.y_axis.title = "Revenue ($)"
chart.x_axis.title = "Region"
chart.legend = None
chart.width = 20
chart.height = 12
# Data reference: column B (revenue), rows data_start to data_end
revenue_ref = Reference(
ws,
min_col=2,
max_col=2,
min_row=data_start - 1, # include header row for series label
max_row=data_end,
)
# Category reference: column A (region names)
categories_ref = Reference(
ws,
min_col=1,
max_col=1,
min_row=data_start,
max_row=data_end,
)
chart.add_data(revenue_ref, titles_from_data=True)
chart.set_categories(categories_ref)
# Style the series
series = chart.series[0]
series.graphicalProperties.solidFill = self.MID_BLUE
series.graphicalProperties.line.solidFill = self.MID_BLUE
# Anchor chart to cell I2
ws.add_chart(chart, "I2")
The trend sheet gets a dual-axis chart: revenue as a line, deal count as a bar on the secondary axis. This is a common business chart pattern that openpyxl can produce but requires careful construction.
def _build_trend_sheet(self):
ws = self.wb.create_sheet("Monthly Trend")
ws.sheet_view.showGridLines = False
df = self.trend[["month_str", "revenue", "deals", "mom_change"]].copy()
last_row, _ = self._write_dataframe_styled(
ws=ws,
df=df,
start_row=2,
start_col=1,
headers=["Month", "Revenue", "Deals", "MoM Change %"],
col_widths=[14, 18, 10, 16],
number_formats={
1: '"$"#,##0',
3: '0.00"%"',
},
)
n = len(df)
data_start = 3
data_end = 2 + n
# Named ranges for trend data
self._define_named_range("Monthly Trend", f"$B$3:$B${data_end}", "MonthlyRevenue")
self._define_named_range("Monthly Trend", f"$A$3:$A${data_end}", "MonthLabels")
# Line chart for revenue
line_chart = LineChart()
line_chart.title = "Monthly Revenue Trend"
line_chart.style = 10
line_chart.y_axis.title = "Revenue ($)"
line_chart.x_axis.title = "Month"
line_chart.width = 24
line_chart.height = 14
revenue_ref = Reference(ws, min_col=2, min_row=data_start - 1, max_row=data_end)
line_chart.add_data(revenue_ref, titles_from_data=True)
month_ref = Reference(ws, min_col=1, min_row=data_start, max_row=data_end)
line_chart.set_categories(month_ref)
# Style the line
series = line_chart.series[0]
series.graphicalProperties.line.solidFill = self.MID_BLUE
series.graphicalProperties.line.width = 25000 # EMUs
series.marker.symbol = "circle"
series.marker.size = 6
series.marker.graphicalProperties.solidFill = self.DARK_BLUE
# Conditional formatting on MoM column
mom_range = f"D{data_start}:D{data_end}"
ws.conditional_formatting.add(
mom_range,
ColorScaleRule(
start_type="num", start_value=-20, start_color="F8696B",
mid_type="num", mid_value=0, mid_color="FFEB84",
end_type="num", end_value=20, end_color="63BE7B",
),
)
ws.add_chart(line_chart, "F2")
Key insight
Chart dimensions in openpyxl are specified in centimeters (chart.width, chart.height), but line widths inside series are in EMUs (English Metric Units), where 12700 EMUs = 1 point. A typical visible line is 12700–25400 EMUs wide. This inconsistency in units trips up almost everyone the first time.
The leaderboard uses icon sets — the traffic-light icons native to Excel — to communicate rank visually. openpyxl's IconSetRule lets you control exactly which icon thresholds trigger which icon.
def _build_leaderboard_sheet(self):
ws = self.wb.create_sheet("Rep Leaderboard")
ws.sheet_view.showGridLines = False
df = self.leaderboard[["rank", "rep_name", "region", "total_revenue", "deal_count", "avg_deal_size"]]
last_row, _ = self._write_dataframe_styled(
ws=ws,
df=df,
start_row=2,
start_col=1,
headers=["Rank", "Rep Name", "Region", "Revenue", "Deals", "Avg Deal Size"],
col_widths=[8, 20, 16, 18, 10, 16],
number_formats={
3: '"$"#,##0',
5: '"$"#,##0',
},
)
n = len(df)
data_start = 3
data_end = 2 + n
# Named range for revenue column
self._define_named_range("Rep Leaderboard", f"$D$3:$D${data_end}", "RepRevenue")
# Icon set on Rank column — top third get green, middle amber, bottom red
rank_range = f"A{data_start}:A{data_end}"
ws.conditional_formatting.add(
rank_range,
IconSetRule(
icon_style="3TrafficLights1",
type="percent",
values=[0, 34, 67],
reverse=True, # Low rank number = best, so reverse the icon direction
),
)
# Data bar on revenue
rev_range = f"D{data_start}:D{data_end}"
ws.conditional_formatting.add(
rev_range,
DataBarRule(
start_type="min", start_value=None,
end_type="max", end_value=None,
color=self.MID_BLUE,
),
)
# Top 3 rows get a gold highlight via formula rule
ws.conditional_formatting.add(
f"A{data_start}:F{data_end}",
FormulaRule(
formula=[f"$A{data_start}<=3"],
fill=PatternFill("solid", fgColor="FFF2CC"),
font=Font(bold=True),
),
)
# Add a horizontal bar chart for rep revenue
chart = BarChart()
chart.type = "bar" # horizontal
chart.grouping = "clustered"
chart.title = "Top Reps by Revenue"
chart.style = 10
chart.width = 22
chart.height = 16
chart.legend = None
rev_ref = Reference(ws, min_col=4, min_row=data_start - 1, max_row=data_end)
cat_ref = Reference(ws, min_col=2, min_row=data_start, max_row=data_end)
chart.add_data(rev_ref, titles_from_data=True)
chart.set_categories(cat_ref)
series = chart.series[0]
series.graphicalProperties.solidFill = self.MID_BLUE
ws.add_chart(chart, "H2")
One thing that separates a professional programmatic dashboard from a quick data dump is registering the data ranges as proper Excel Tables. Excel Tables auto-expand when new rows are added, support structured references in formulas, and work with Power Query for further downstream processing.
from openpyxl.worksheet.table import Table, TableStyleInfo
def _add_excel_table(self, ws, table_ref: str, table_name: str, style: str = "TableStyleMedium9"):
"""
Register a range as a named Excel Table.
table_ref: e.g., "A2:G7"
"""
tab = Table(displayName=table_name, ref=table_ref)
tab.tableStyleInfo = TableStyleInfo(
name=style,
showFirstColumn=False,
showLastColumn=False,
showRowStripes=True,
showColumnStripes=False,
)
ws.add_table(tab)
Call this after writing each sheet's data block:
# In _build_regional_sheet, after writing data:
self._add_excel_table(
ws,
table_ref=f"A2:G{2 + len(self.regional)}",
table_name="RegionalData",
)
Warning
Table names must be unique across the entire workbook, not just the sheet. If you add a table named Data on two sheets, Excel will throw a corruption error when opening the file. Use descriptive names like RegionalData, LeaderboardData, etc.
Let's wire everything together into a complete pipeline.
def _set_workbook_properties(self):
self.wb.properties.title = "Sales Dashboard"
self.wb.properties.subject = "Sales Performance"
self.wb.properties.creator = "Wicked Smart Data Pipeline"
self.wb.properties.description = f"Auto-generated {self.report_date}"
# Set the Executive Summary as the active sheet on open
self.wb.active = self.wb["Executive Summary"]
def run_dashboard(output_path: str = "sales_dashboard.xlsx"):
"""
Full pipeline: generate data → transform → build dashboard → save.
In production, replace generate_sales_data() with your actual data source.
"""
print("Ingesting data...")
df_raw = generate_sales_data()
print("Filtering and transforming...")
df_won = df_raw[df_raw["stage"] == "Closed Won"].copy()
regional_df = build_regional_summary(df_won)
leaderboard_df = build_rep_leaderboard(df_won)
trend_df = build_monthly_trend(df_won)
product_df = build_product_summary(df_won)
print("Building workbook...")
builder = SalesDashboardBuilder(
regional_df=regional_df,
leaderboard_df=leaderboard_df,
trend_df=trend_df,
product_df=product_df,
)
builder.build()
builder.save(output_path)
if __name__ == "__main__":
run_dashboard()
Run it:
python sales_dashboard.py
Open sales_dashboard.xlsx and you'll see a four-sheet workbook with formatted tables, conditional formatting, charts, named ranges, and registered Excel Tables — all generated from data, with zero manual intervention.
To connect this to a real data source, replace the generate_sales_data() call with your actual ingestion code. If you're reading from a SQL database, the SQLAlchemy integration article covers the connection setup. If you're pulling from a REST API, flattening nested JSON shows how to normalize the response into a DataFrame. To schedule the script to run automatically, the scheduling article walks you through cron and Task Scheduler.
This pipeline handles a few thousand rows comfortably. As your data grows, a few patterns become important:
Pre-aggregate before the dashboard run. If your CRM has 2 million rows, don't load them all into memory to produce five summary tables. Run your aggregations in SQL or use chunked reading. The handling large datasets article covers efficient strategies for large inputs.
Cell-by-cell writes are slow. The _write_dataframe_styled method iterates row by row using itertuples, which is fine for tables with fewer than 10,000 rows. For larger sheets, write data with dataframe_to_rows and apply formatting in a second pass over the cell range, or use ws.append() in bulk and then format the range as a block.
openpyxl is pure Python. It doesn't use libxlsxwriter or any C extensions. For very large workbooks (50+ sheets, 100k+ cells), consider xlsxwriter for data writing and openpyxl only for post-write formatting tasks — though you'll lose the round-trip read capability.
Cache your workbook structure. If you're running the same dashboard template with updated data, the sheet scaffolding (header rows, column widths, chart positions) doesn't need to be regenerated from scratch every time. You could template the workbook with a "skeleton" file and use openpyxl to inject new data into pre-defined named ranges rather than rebuilding the entire file. This approach is more complex but dramatically faster for stable dashboard formats.
Now it's your turn. Extend the dashboard you built with the following additions:
Product mix sheet: Write a new _build_product_sheet() method that writes product_df to a new sheet called "Product Mix." Add a pie chart (openpyxl's PieChart) showing revenue share by product. Apply a color scale to the revenue_pct column from light to dark blue.
Dynamic title with quarter detection: Modify _build_summary_sheet() to detect which fiscal quarter the report covers based on self.report_date and display "Q1 FY2025 Sales Dashboard" instead of a generic title.
Named range formula verification: In the Executive Summary sheet, add a cell that uses an Excel formula referencing the RegionalRevenue named range you defined: =SUM(RegionalRevenue). Verify this matches the hardcoded KPI total on the same sheet. Hint: set the cell's value to the string "=SUM(RegionalRevenue)" — openpyxl will write it as a formula.
Month-over-month warning flag: Add a new column to the trend table called "Flag" that contains "⚠️" if the MoM change is below -10%, "✅" if above +10%, and "" otherwise. Apply an icon set rule to the column.
Email-ready mode: Wrap the run_dashboard() function with an optional --email CLI flag using argparse. When the flag is set, attach the output file to an email using Python's smtplib and email modules. This is a real-world automation pattern.
Most likely cause: Duplicate table names. Every Table added to a workbook must have a unique displayName. Also check for invalid characters in table names — only letters, numbers, and underscores are allowed, and names can't start with a number.
Second most likely cause: A chart Reference that points to a range on a different sheet than the chart's host sheet without proper sheet qualification. Always create Reference objects using the worksheet object, not string references.
Check that the range string you pass to ws.conditional_formatting.add() matches the actual data range exactly, including whether you're using 1-based or 0-based row numbers. openpyxl uses 1-based cell coordinates everywhere. A range that starts at row 2 should be "A2:G7", not "A1:G6".
Also check rule priority. openpyxl writes rules in the order you add them. Excel evaluates them top to bottom and stops at the first match (by default). If your "Exceeding" rule is listed before "At Risk", it may shadow it for cells that match both — though with mutually exclusive status values, this shouldn't happen.
This almost always means the Reference min/max rows or columns are off by one. The most common mistake is forgetting to include the header row in the data reference when using titles_from_data=True. If you pass min_row=data_start - 1, Excel reads the header from that row as the series name. If you pass min_row=data_start, the first data row becomes the series name and the chart drops one data point.
Named ranges defined with DefinedName must have their attr_text formatted exactly as 'Sheet Name'!$A$1:$A$10. Note the single quotes around the sheet name — required when the name contains spaces. Omitting them causes a silent failure where the name is defined but resolves to a reference error in Excel.
Tip
After generating your workbook, open the Excel Name Manager (Formulas → Name Manager) to verify that all your named ranges appear correctly. This is the fastest way to debug named range issues without looking at XML internals.
The _write_dataframe_styled method uses itertuples, which converts column names to valid Python identifiers. A column called "Total Revenue" becomes Total_Revenue. This doesn't affect the data written to cells, but if you're doing any logic inside the loop based on attribute access, use iterrows() instead or clean column names before calling the method.
If attainment_pct exceeds your upper bin edge (e.g., a rep at 200% attainment when your top edge is float("inf") — actually this won't happen, but watch out if you hardcode edges). The safe pattern is always to use float("inf") as the upper bound of the last bin and -float("inf") as the lower bound of the first bin. Alternatively, pass include_lowest=True to pd.cut to capture boundary values in the lowest bin.
You've built a complete, production-grade dashboard generation pipeline. Let's recap what you accomplished:
groupby, merge, and pd.cut logic lives in pure pandas functions that you can test independentlybuild() / save() interface that's easy to test, extend, and scheduleWhere to go from here depends on which dimension you want to push. If the formatting complexity is growing, consider externalizing the style configuration into a YAML or JSON config file so non-Python colleagues can adjust colors and thresholds without touching code. If you want to add period-over-period analysis to your summary tables, the month-over-month and year-over-year calculations article shows how to build those cleanly in pandas before the data ever reaches openpyxl. And if this dashboard is part of a larger reporting infrastructure, the reusable ETL pipeline article covers the architectural patterns for making the data ingestion layer robust and maintainable.
The goal of a self-updating dashboard isn't automation for its own sake — it's freeing your analytical attention for work that actually requires human judgment, rather than spending it reformatting the same workbook every Monday morning.