How-to
Automating Reporting WorkflowsDeep dive

Build a One-Page Executive Summary Sheet

Build the first sheet of a monthly Excel report as a one-page executive summary with Python: four headline figures against budget and last year, three sentences of commentary written from the numbers, a small trend chart, the biggest movers, and page setup that prints on exactly one A4 sheet — checked by converting it to PDF.

Most people who receive a monthly report read one page of it. An executive summary sheet accepts that and puts everything they need on that page: a handful of headline figures with their comparison to budget and last year, two or three sentences saying what happened, a small chart showing the trend, and the few items that moved the most. Everything else — the detail by region, product and month — lives on the sheets behind it for those who want it. The difference from a dashboard is discipline: the summary is printed, forwarded and read on a phone, so it must fit one page, read top to bottom, and make sense without any interaction. This page builds such a sheet with pandas and openpyxl, writes the commentary from the numbers so it is never out of date, and proves it fits on one page by converting it to PDF. It belongs to Building Multi-Sheet Excel Dashboards.

Prerequisites

Bash
pip install pandas openpyxl

The input is a monthly fact table with one row per month and region: revenue, gross margin, number of orders, budgeted revenue and revenue for the same month last year. The same approach works for any report whose readers need the headline before the detail.

Plan the page before writing code

A summary page that fits on one sheet of A4 in portrait has room for roughly 45 rows of default height and seven or eight normal-width columns, so space is the constraint that shapes everything. Decide the blocks and their order first: title and period at the top, then the headline figures, then the commentary, then the chart, then the movers, then a footer saying where the numbers come from. Each block gets a fixed band of rows, which keeps the layout stable from month to month however the numbers change.

The bands of a one-page summary The page is read top to bottom. The title and period come first, then four headline figures with their comparisons, three sentences of commentary, a twelve-month trend chart, the biggest movers by region, and a source line at the foot. Title and period rows 1–2 Four headline figures rows 4–6 Commentary rows 8–10 Twelve-month trend chart rows 12–27 Biggest movers rows 29–35 Source and run time last row

Step 1 — Calculate the headline figures

Python
import pandas as pd

df = pd.read_excel("monthly_sales.xlsx")
month = df["Month"].max()
cur = df[df["Month"] == month]
prev = df[df["Month"] == month - pd.DateOffset(months=1)]

def kpis(d):
    return {"Revenue": d["Revenue"].sum(), "Gross margin %": d["Gross margin"].sum() / d["Revenue"].sum(),
            "Orders": d["Orders"].sum(), "Average order": d["Revenue"].sum() / d["Orders"].sum()}

now, before = kpis(cur), kpis(prev)
budget, last_year = cur["Budget revenue"].sum(), cur["Revenue last year"].sum()
for k, v in now.items():
    print(f"{k:15} {v:>12,.3f}  previous month {before[k]:>12,.3f}")
print(f"vs budget {now['Revenue'] / budget - 1:+.1%}   vs last year {now['Revenue'] / last_year - 1:+.1%}")

Four figures are enough. Revenue on its own, gross margin as a percentage, the number of orders and the average order value tell the story between them: revenue up with orders flat means bigger orders; revenue up with margin down means discounting. Each is compared with the previous month, and revenue is also compared with budget and with the same month last year, because those are the two questions a senior reader asks first.

Step 2 — Write the commentary from the numbers

Python
by_region = df.pivot_table(index="Region", columns="Month", values="Revenue", aggfunc="sum")
change = (by_region[month] - by_region[month - pd.DateOffset(months=1)]).sort_values()
up, down = change.idxmax(), change.idxmin()

def pct(a, b):
    return f"{a / b - 1:+.1%}"

def money(x):
    return f"{'+' if x >= 0 else '−'}£{abs(x):,.0f}"

lines = [
    f"Revenue for {month:%B %Y} was £{now['Revenue']:,.0f}, {pct(now['Revenue'], budget)} against budget "
    f"and {pct(now['Revenue'], last_year)} against {month:%B} last year.",
    f"The biggest increase came from {up} ({money(change[up])} on the previous month); "
    f"the biggest fall was in {down} ({money(change[down])}).",
    f"Gross margin was {now['Gross margin %']:.1%} against {before['Gross margin %']:.1%} in the previous month, "
    f"on {now['Orders']:,} orders averaging £{now['Average order']:,.0f}.",
]
print("\n".join(lines))

Commentary written by hand is usually the part of a report that is late, and it is sometimes wrong because it was written against an earlier version of the numbers. Generating the sentences from the same data as the figures means they always agree. The templates are deliberately plain — what happened, compared with what, and why in terms of where — and the reader's knowledge supplies the rest. A finance analyst can still add a sentence of judgement on top; the generated lines are the factual baseline, not a replacement for it. The same variance logic, region by region and line by line, is the subject of Build a Budget vs Actual Variance Report in Excel with Python.

Step 3 — Lay out the sheet

Python
from openpyxl import Workbook
from openpyxl.chart import LineChart, Reference
from openpyxl.styles import Alignment, Font, PatternFill

wb = Workbook()
ws = wb.active
ws.title = "Summary"
detail = wb.create_sheet("Detail")
for col in "ABCDEFGH":
    ws.column_dimensions[col].width = 11.5
ws.sheet_view.showGridLines = False

ws["A1"] = "Monthly sales summary"
ws["A1"].font = Font(size=18, bold=True, color="1F3864")
ws["A2"] = f"{month:%B %Y} · all regions"
ws["A2"].font = Font(size=11, color="595959")

# Band 1: four tiles across, each two columns wide (label, value, comparison)
tiles = [("Revenue", now["Revenue"], "£#,##0", pct(now["Revenue"], budget) + " vs budget"),
         ("Gross margin", now["Gross margin %"], "0.0%",
          f"{(now['Gross margin %'] - before['Gross margin %']) * 100:+.1f} pts on month"),
         ("Orders", now["Orders"], "#,##0", pct(now["Orders"], before["Orders"]) + " on month"),
         ("Average order", now["Average order"], "£#,##0", pct(now["Average order"], before["Average order"]) + " on month")]
tile_fill = PatternFill("solid", fgColor="EEF3FA")
for i, (label, value, fmt, note) in enumerate(tiles):
    c = 1 + i * 2
    for r, content, font in [(4, label, Font(size=9, color="595959")), (5, value, Font(size=15, bold=True)),
                             (6, note, Font(size=9, color="1F3864"))]:
        ws.merge_cells(start_row=r, start_column=c, end_row=r, end_column=c + 1)
        cell = ws.cell(r, c, content)
        cell.font, cell.fill = font, tile_fill
        ws.cell(r, c + 1).fill = tile_fill
    ws.cell(5, c).number_format = fmt
ws.row_dimensions[5].height = 24

# Band 2: commentary, one merged and wrapped row per sentence
for r, text in zip(range(8, 11), lines):
    ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=8)
    ws.cell(r, 1, text).alignment = Alignment(wrap_text=True, vertical="top")
    ws.row_dimensions[r].height = 30

The sheet is eight equal columns wide, and every block is built from them: four tiles of two columns, commentary merged across all eight, the chart spanning the full width. Gridlines are switched off because the summary should look like a page, not a worksheet. Each commentary sentence gets its own merged row with wrapping and a fixed height of two lines — Excel does not grow the height of merged rows to fit their text, so the height has to be set explicitly, and two lines at this width holds a sentence of up to about 180 characters. The tiles follow the pattern in Add a KPI Summary Block to an Excel Dashboard, reduced to one row of four.

Step 4 — Chart, movers and the detail behind them

Python
monthly = df.groupby("Month")[["Revenue", "Budget revenue", "Revenue last year"]].sum()
detail.append(["Month", "Revenue", "Budget", "Last year"])
for m, row in monthly.iterrows():
    detail.append([m.strftime("%b %y"), *row.round(0).tolist()])

chart = LineChart()
chart.title = "Revenue, last 12 months"
chart.height, chart.width = 7.5, 17.5
chart.add_data(Reference(detail, min_col=2, max_col=4, min_row=1, max_row=len(monthly) + 1), titles_from_data=True)
chart.set_categories(Reference(detail, min_col=1, min_row=2, max_row=len(monthly) + 1))
chart.y_axis.numFmt = "£#,##0"
chart.x_axis.delete = chart.y_axis.delete = False
chart.legend.position = "b"
ws.add_chart(chart, "A12")

ws["A29"] = "Biggest movers on the previous month"
ws["A29"].font = Font(bold=True, color="1F3864")
movers = change.reindex(change.abs().sort_values(ascending=False).index).head(5)
for r, (region, delta) in enumerate(movers.items(), start=30):
    ws.cell(r, 1, region)
    ws.cell(r, 3, by_region.loc[region, month]).number_format = "£#,##0"
    d = ws.cell(r, 5, delta)
    d.number_format = '+£#,##0;[Red]-£#,##0'
ws["A37"] = f"Source: monthly_sales.xlsx · produced {pd.Timestamp.now():%d %b %Y %H:%M}"
ws["A37"].font = Font(size=8, color="808080")

The chart's data lives on the Detail sheet, which also gives readers who want them the actual numbers behind the lines. Three series — actual, budget and last year — are about as many as a small chart can carry before it becomes hard to read. The axes are set explicitly to stay visible, because openpyxl otherwise writes charts whose axes Excel hides. The movers block ranks regions by the size of their change, up or down, so a large fall is as visible as a large rise; the signed number format adds a plus sign to rises and shows falls in red.

Step 5 — Make it print on one page, and prove it

Python
ws.print_area = "A1:H37"
ws.page_setup.orientation = "portrait"
ws.page_setup.paperSize = ws.PAPERSIZE_A4
ws.page_setup.fitToWidth = ws.page_setup.fitToHeight = 1
ws.sheet_properties.pageSetUpPr.fitToPage = True
ws.page_margins.left = ws.page_margins.right = 0.5
ws.print_options.horizontalCentered = True
wb.save("sales_summary_2026-09.xlsx")
Bash
soffice --headless --convert-to pdf sales_summary_2026-09.xlsx > /dev/null 2>&1
pdfinfo sales_summary_2026-09.pdf | grep Pages

A print area limited to the summary block and fit-to-page in both directions guarantee one page whatever the content does: if a sentence runs long, the page shrinks slightly rather than spilling onto a second sheet. Converting the workbook to PDF with LibreOffice and counting the pages turns "it should fit" into a check that can run every month; the Detail sheet adds its own page, so the PDF has two pages in total, and the summary is the first. If the summary ever needs two pages on its own, that is a sign it has stopped being a summary. Printing settings in more depth are in Fit an Excel Sheet to One Page Wide When Exporting to PDF.

The headline band for September 2026 Four tiles across the top of the page. Revenue of 469,939 pounds, 1.2 percent under budget, gross margin percentage with the change in points, number of orders and average order value, each with the change on the previous month. A B C D 1 Revenue Gross margin Orders Average order 2 £469,939 37.0% 1,153 £408 3 −1.2% vs budget −2.4 pts on month +3.6% on month −1.8% on month

What to leave out

The hardest part of a summary page is deciding what is not on it. A useful test is to ask, for each candidate figure, what the reader would do differently if it moved. Figures that would not change anyone's decision belong on the detail sheets. The same goes for precision: revenue to the nearest pound, percentages to one decimal place, never pence on a summary. Limit colour to meaning — red for a fall, the brand colour for headings — and avoid conditional formatting that changes colours from month to month in ways that need a legend. Finally, keep the layout the same every month. Readers learn where to look, and a summary that moves its blocks around makes them search instead of read.

Executive summary or dashboard An executive summary is one printed page read top to bottom with written commentary and a few figures. A dashboard is an interactive screen with filters, more charts and no fixed page size. Executive summary One page, prints and forwards well Four figures, three sentences Read top to bottom Same layout every month Dashboard Screen-sized, interactive Many charts and filters Explored, not read Grows with new questions

Common pitfalls

  • Merged rows that hide text. Set the row height explicitly; merged cells do not auto-fit.
  • Hand-written commentary. It goes stale; generate the factual sentences from the data.
  • Hidden chart axes. Set delete = False on both axes.
  • No print settings. Set the print area and fit-to-page, then check the PDF page count.
  • Too many figures. Four headline numbers; everything else belongs behind the summary.

Conclusion

A one-page executive summary is the first sheet most readers see and often the only one. Build it from a few headline figures compared with budget, last year and last month, add three sentences of commentary generated from the same numbers, a twelve-month trend chart and the biggest movers, then fix the print area and fit-to-page and confirm with a PDF conversion that it really is one page.

Frequently asked questions

How do I make an Excel sheet print on one page with openpyxl? Set the print area, set fitToWidth and fitToHeight to 1 and enable sheet_properties.pageSetUpPr.fitToPage.

How can I generate report commentary automatically? Write sentence templates with placeholders and fill them from the same figures as the report, so the words always match the numbers.

Why does my merged commentary cell cut off text? Excel does not auto-fit row height for merged cells; set the row height in openpyxl to fit the longest expected text.

How many figures should an executive summary have? Usually four to six; each one should be something that would change a decision if it moved.

More on report layouts & templates

More on report layouts & templates →