Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
Python

Building Summary Reports with pandas pivot_table and to_excel: Turning Aggregated Data into a Formatted, Multi-Sheet Workbook

Learn how to turn raw transaction data into a professional, multi-sheet Excel workbook using pandas pivot_table and openpyxl. This lesson covers building multiple pivot summaries, applying header styles and number formats, adding a cover sheet, and structuring the whole pipeline as a reusable script.

⚡ Practitioner19 min readSep 22, 2026Updated Sep 22, 2026
Building Summary Reports with pandas pivot_table and to_excel: Turning Aggregated Data into a Formatted, Multi-Sheet Workbook
On this page
  • Introduction
  • Prerequisites
  • Building the Dataset
  • Understanding pivot_table Before You Use It
  • Building the Four Summary Tables
  • Table 1: Revenue and Profit by Region × Category
  • Table 2: Monthly Revenue Trend by Quarter
  • Table 3: Rep Performance Summary
  • Table 4: Category × Product Line Detail
  • Writing to Excel: The ExcelWriter Pattern
  • Applying Formatting with openpyxl
  • The Complete Report Script
  • Adding a Cover Sheet
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Multi-level column index not flattened
  • NaN in the margins row for computed columns
  • Sheet name too long
  • The workbook opens with a repair warning
  • format_sheet changes aren't showing up
  • Multi-Level Pivots and When to Flatten
  • Structuring This as a Reusable Module
  • Summary & Next Steps
  • Building Summary Reports with pandas pivot_table and to_excel: Turning Aggregated Data into a Formatted, Multi-Sheet Workbook

    Introduction

    You've done the analysis. The numbers are solid. Now someone in leadership wants "a report" — ideally something they can open in Excel, filter, and forward to their team. If your current workflow involves running Python, copying results into a spreadsheet, and then manually formatting it, you're doing three jobs that one script should handle.

    This lesson is about closing that loop. We're going to take a realistic sales dataset, build several meaningful summary tables using pivot_table, and then write all of them into a properly formatted, multi-sheet Excel workbook using to_excel and openpyxl. The finished product will look like something a business analyst put together deliberately — not a data dump.

    By the end of this lesson, you'll have a repeatable script you can adapt to any reporting scenario. Run it against a new month of data and you get a new report. That's the goal.

    What you'll learn:

    • How pivot_table works and how to configure it for real-world aggregation tasks
    • How to build multiple summary views from a single DataFrame
    • How to write a multi-sheet workbook with pd.ExcelWriter
    • How to apply column widths, number formats, and header styles using openpyxl
    • How to structure the whole thing as a reusable function you can schedule or hand off to others

    Prerequisites

    You should be comfortable with pandas DataFrames — loading data, selecting columns, filtering rows. If you're newer to pandas, start with Your First pandas DataFrame: Loading CSV and Excel Files and Exploring Data and Selecting and Filtering Data in pandas: loc, iloc, and Boolean Masks first.

    You should also have a working environment with pandas, openpyxl, and Jupyter or a Python script runner set up. If you need that foundation, see Setting Up Python for Data Analysis: Python, VS Code, Jupyter, and Virtual Environments.

    Install dependencies if you haven't already:

    pip install pandas openpyxl
    

    Building the Dataset

    Rather than load a CSV from an unknown path, let's construct a realistic dataset in code so you can follow along immediately. This is a transaction-level sales dataset — the kind you'd get from a CRM or ERP export.

    import pandas as pd
    import numpy as np
    from datetime import date, timedelta
    import random
    
    random.seed(42)
    np.random.seed(42)
    
    regions = ["Northeast", "Southeast", "Midwest", "West"]
    categories = ["Hardware", "Software", "Services", "Training"]
    reps = {
        "Northeast": ["Alice Marsh", "Brian Cho"],
        "Southeast": ["Carlos Vega", "Diana Pham"],
        "Midwest":   ["Ethan Brooks", "Fiona Chen"],
        "West":      ["George Kim", "Hannah Lee"],
    }
    
    n = 800
    start = date(2024, 1, 1)
    
    records = []
    for _ in range(n):
        region = random.choice(regions)
        rep = random.choice(reps[region])
        category = random.choice(categories)
        qty = random.randint(1, 50)
        unit_price = round(random.uniform(200, 5000), 2)
        revenue = round(qty * unit_price, 2)
        cost = round(revenue * random.uniform(0.4, 0.7), 2)
        order_date = start + timedelta(days=random.randint(0, 364))
        records.append({
            "order_date": order_date,
            "region": region,
            "rep": rep,
            "category": category,
            "quantity": qty,
            "unit_price": unit_price,
            "revenue": revenue,
            "cost": cost,
            "profit": round(revenue - cost, 2),
        })
    
    df = pd.DataFrame(records)
    df["order_date"] = pd.to_datetime(df["order_date"])
    df["month"] = df["order_date"].dt.to_period("M").astype(str)
    df["quarter"] = df["order_date"].dt.to_period("Q").astype(str)
    
    print(df.shape)
    print(df.dtypes)
    print(df.head(3))
    

    You should see 800 rows with clean numeric columns and date-derived period columns. This is the raw material for every pivot we're about to build.

    Note

    In a real project, you'd load this data from a CSV, database, or Excel file. The structure here — one row per transaction with dimension columns (region, category, rep) and measure columns (revenue, cost, profit) — is exactly the shape pivot_table expects. If your data needs cleaning first, check Cleaning Messy Data with pandas: Missing Values, Duplicates, and Data Types.


    Understanding pivot_table Before You Use It

    pivot_table is pandas' version of Excel's PivotTable — and if you've used that feature, the mental model translates almost exactly. You specify what goes on the rows (index), what goes on the columns (columns), what values to aggregate (values), and how to aggregate them (aggfunc).

    Here's the minimal signature:

    pd.pivot_table(
        data,           # your DataFrame
        values=...,     # column(s) to aggregate
        index=...,      # row grouping field(s)
        columns=...,    # column grouping field (optional)
        aggfunc=...,    # function or dict of functions
        fill_value=0,   # replace NaN with this (often 0)
        margins=False,  # add row/column totals
    )
    

    If you want a deeper dive into the mechanics — including stacked indexes and multi-level output — see Reshaping Data with pivot_table, melt, and stack in pandas. For this lesson, we'll use pivot_table in its practical, report-building mode and focus on what makes output useful to a business audience.

    Let's start with the most natural summary: revenue by region and category.

    pt_region_category = pd.pivot_table(
        df,
        values=["revenue", "profit"],
        index="region",
        columns="category",
        aggfunc="sum",
        fill_value=0,
        margins=True,
        margins_name="Total",
    )
    
    print(pt_region_category)
    

    This gives you a multi-level column structure: the top level is the measure (revenue, profit), the second level is the category. That's useful for analysis in Python, but when you write it to Excel you'll want flat column names. We'll handle that shortly.

    Key insight

    margins=True adds a "Total" row and column automatically — the equivalent of Excel's Grand Total option. Use margins_name="Total" to control the label, since the default is "All" which reads awkwardly in a business report.


    Building the Four Summary Tables

    A useful workbook usually has several angles on the same data. We'll build four pivot tables, each telling a different story.

    Table 1: Revenue and Profit by Region × Category

    pt1 = pd.pivot_table(
        df,
        values=["revenue", "profit"],
        index="region",
        columns="category",
        aggfunc="sum",
        fill_value=0,
        margins=True,
        margins_name="Total",
    )
    
    # Flatten multi-level columns
    pt1.columns = [f"{measure} — {cat}" for measure, cat in pt1.columns]
    pt1 = pt1.reset_index()
    pt1 = pt1.rename(columns={"region": "Region"})
    

    Table 2: Monthly Revenue Trend by Quarter

    pt2 = pd.pivot_table(
        df,
        values="revenue",
        index="quarter",
        aggfunc=["sum", "mean", "count"],
        fill_value=0,
    )
    
    pt2.columns = ["Total Revenue", "Avg Order Value", "Order Count"]
    pt2 = pt2.reset_index()
    pt2 = pt2.rename(columns={"quarter": "Quarter"})
    pt2["Total Revenue"] = pt2["Total Revenue"].round(2)
    pt2["Avg Order Value"] = pt2["Avg Order Value"].round(2)
    

    Table 3: Rep Performance Summary

    pt3 = pd.pivot_table(
        df,
        values=["revenue", "profit", "quantity"],
        index=["region", "rep"],
        aggfunc={
            "revenue": "sum",
            "profit": "sum",
            "quantity": "sum",
        },
        fill_value=0,
        margins=True,
        margins_name="Total",
    )
    
    pt3["margin_pct"] = (pt3["profit"] / pt3["revenue"] * 100).round(1)
    pt3 = pt3.reset_index()
    pt3 = pt3.rename(columns={
        "region": "Region",
        "rep": "Sales Rep",
        "revenue": "Revenue",
        "profit": "Profit",
        "quantity": "Units Sold",
        "margin_pct": "Margin %",
    })
    

    Notice we computed margin_pct after the pivot, not before. You generally can't ask pivot_table to compute a ratio of aggregates in one step — but it's straightforward to add derived columns after the fact.

    Table 4: Category × Product Line Detail

    pt4 = pd.pivot_table(
        df,
        values=["revenue", "cost", "profit"],
        index="category",
        aggfunc={
            "revenue": ["sum", "mean"],
            "cost": "sum",
            "profit": "sum",
        },
        fill_value=0,
        margins=True,
        margins_name="Total",
    )
    
    # Flatten and rename
    pt4.columns = [f"{col[0].title()} ({col[1].title()})" for col in pt4.columns]
    pt4 = pt4.reset_index()
    pt4 = pt4.rename(columns={"category": "Category"})
    

    Tip

    After any pivot_table call, always print .dtypes and .head() before writing to Excel. Multi-level column indexes, residual Period types, or unexpected object columns will cause formatting problems downstream. Better to catch them here than to debug a corrupted workbook.


    Writing to Excel: The ExcelWriter Pattern

    The core pattern for multi-sheet workbooks is pd.ExcelWriter used as a context manager. Each DataFrame gets written to a named sheet with df.to_excel(writer, sheet_name=...). When the context manager exits, the file is saved.

    output_path = "sales_summary_report.xlsx"
    
    with pd.ExcelWriter(output_path, engine="openpyxl") as writer:
        pt1.to_excel(writer, sheet_name="Region × Category", index=False)
        pt2.to_excel(writer, sheet_name="Quarterly Trend", index=False)
        pt3.to_excel(writer, sheet_name="Rep Performance", index=False)
        pt4.to_excel(writer, sheet_name="Category Detail", index=False)
    
    print(f"Workbook written to {output_path}")
    

    Open that file. You'll get four sheets with data, but the presentation is rough: columns are too narrow, numbers lack formatting, headers look like everything else. That's what we fix next.

    Warning

    If you're used to calling writer.save() at the end, note that it was deprecated in pandas 1.5 and removed in 2.0. The context manager (with ... as writer:) handles saving automatically. Using the old pattern on a modern pandas install will raise an AttributeError.


    Applying Formatting with openpyxl

    After to_excel runs, the workbook object is still live inside the ExcelWriter context. You can grab it via writer.book and then access individual sheets via writer.sheets. This is where openpyxl takes over.

    Let's build a helper function that handles the most important formatting concerns for any sheet: column widths, header style, and number formats.

    from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
    from openpyxl.utils import get_column_letter
    
    def format_sheet(ws, df, currency_cols=None, pct_cols=None, int_cols=None):
        """
        Apply professional formatting to an openpyxl worksheet.
        
        Parameters
        ----------
        ws          : openpyxl Worksheet object
        df          : the DataFrame that was written to this sheet (used for column metadata)
        currency_cols : list of column names to format as currency
        pct_cols    : list of column names to format as percentage (0–100 scale, e.g. "45.2")
        int_cols    : list of column names to format as integers with comma separator
        """
        currency_cols = currency_cols or []
        pct_cols = pct_cols or []
        int_cols = int_cols or []
    
        # --- Header row styling ---
        header_fill = PatternFill(start_color="1F3864", end_color="1F3864", fill_type="solid")
        header_font = Font(bold=True, color="FFFFFF", size=11)
        header_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
        thin = Side(style="thin", color="CCCCCC")
        cell_border = Border(bottom=thin)
    
        for cell in ws[1]:
            cell.fill = header_fill
            cell.font = header_font
            cell.alignment = header_align
    
        # --- Column widths and number formats ---
        col_name_to_idx = {col: i + 1 for i, col in enumerate(df.columns)}
    
        for col_name, col_idx in col_name_to_idx.items():
            col_letter = get_column_letter(col_idx)
    
            # Auto-width: find max content length in column
            max_len = len(str(col_name))
            for row in ws.iter_rows(min_row=2, min_col=col_idx, max_col=col_idx):
                for cell in row:
                    if cell.value is not None:
                        max_len = max(max_len, len(str(cell.value)))
            ws.column_dimensions[col_letter].width = min(max_len + 4, 40)
    
            # Number formats
            if col_name in currency_cols:
                for row in ws.iter_rows(min_row=2, min_col=col_idx, max_col=col_idx):
                    for cell in row:
                        cell.number_format = '"$"#,##0.00'
                        cell.alignment = Alignment(horizontal="right")
    
            elif col_name in pct_cols:
                for row in ws.iter_rows(min_row=2, min_col=col_idx, max_col=col_idx):
                    for cell in row:
                        cell.number_format = '0.0"%"'
                        cell.alignment = Alignment(horizontal="right")
    
            elif col_name in int_cols:
                for row in ws.iter_rows(min_row=2, min_col=col_idx, max_col=col_idx):
                    for cell in row:
                        cell.number_format = "#,##0"
                        cell.alignment = Alignment(horizontal="right")
    
        # --- Zebra striping for readability ---
        stripe_fill = PatternFill(start_color="EEF2F7", end_color="EEF2F7", fill_type="solid")
        for i, row in enumerate(ws.iter_rows(min_row=2), start=2):
            if i % 2 == 0:
                for cell in row:
                    if cell.fill.start_color.rgb == "00000000":  # only uncolored cells
                        cell.fill = stripe_fill
    
        # --- Freeze the header row ---
        ws.freeze_panes = "A2"
    

    This function is doing a lot of practical work: it styles the header with a dark blue background and white bold text (a clean, professional default), auto-sizes columns based on actual content, applies appropriate number formats, adds zebra striping for readability, and freezes the top row.

    Key insight

    The number_format strings in openpyxl use Excel's own format codes. '"$"#,##0.00' renders as dollar amounts with commas and two decimal places. '0.0"%"' appends a literal percent sign without dividing by 100 — useful when your values are already on a 0–100 scale like our Margin % column.


    The Complete Report Script

    Now we tie everything together. The full script builds the four pivot tables and writes a properly formatted workbook in one pass.

    import pandas as pd
    import numpy as np
    from openpyxl.styles import Font, PatternFill, Alignment
    from openpyxl.utils import get_column_letter
    
    # --- [Assume df is already built as above] ---
    
    def build_pivot_tables(df):
        """Return a dict of named DataFrames ready to write to Excel."""
    
        # Table 1: Region × Category
        pt1 = pd.pivot_table(
            df, values=["revenue", "profit"],
            index="region", columns="category",
            aggfunc="sum", fill_value=0,
            margins=True, margins_name="Total",
        )
        pt1.columns = [f"{m.title()} — {c}" for m, c in pt1.columns]
        pt1 = pt1.reset_index().rename(columns={"region": "Region"})
    
        # Table 2: Quarterly Trend
        pt2 = pd.pivot_table(
            df, values="revenue",
            index="quarter",
            aggfunc=["sum", "mean", "count"],
            fill_value=0,
        )
        pt2.columns = ["Total Revenue", "Avg Order Value", "Order Count"]
        pt2 = pt2.reset_index().rename(columns={"quarter": "Quarter"})
        pt2["Total Revenue"] = pt2["Total Revenue"].round(2)
        pt2["Avg Order Value"] = pt2["Avg Order Value"].round(2)
    
        # Table 3: Rep Performance
        pt3 = pd.pivot_table(
            df, values=["revenue", "profit", "quantity"],
            index=["region", "rep"],
            aggfunc={"revenue": "sum", "profit": "sum", "quantity": "sum"},
            fill_value=0, margins=True, margins_name="Total",
        )
        pt3["margin_pct"] = (pt3["profit"] / pt3["revenue"] * 100).round(1)
        pt3 = pt3.reset_index().rename(columns={
            "region": "Region", "rep": "Sales Rep",
            "revenue": "Revenue", "profit": "Profit",
            "quantity": "Units Sold", "margin_pct": "Margin %",
        })
    
        # Table 4: Category Detail
        pt4 = pd.pivot_table(
            df, values=["revenue", "cost", "profit"],
            index="category",
            aggfunc={"revenue": ["sum", "mean"], "cost": "sum", "profit": "sum"},
            fill_value=0, margins=True, margins_name="Total",
        )
        pt4.columns = [f"{c[0].title()} ({c[1].title()})" for c in pt4.columns]
        pt4 = pt4.reset_index().rename(columns={"category": "Category"})
    
        return {
            "Region × Category": pt1,
            "Quarterly Trend": pt2,
            "Rep Performance": pt3,
            "Category Detail": pt4,
        }
    
    
    def write_report(tables, output_path):
        """Write all tables to a formatted multi-sheet Excel workbook."""
        
        currency_map = {
            "Region × Category": [c for c in tables["Region × Category"].columns if "Revenue" in c or "Profit" in c],
            "Quarterly Trend": ["Total Revenue", "Avg Order Value"],
            "Rep Performance": ["Revenue", "Profit"],
            "Category Detail": [c for c in tables["Category Detail"].columns if "Revenue" in c or "Profit" in c or "Cost" in c],
        }
        pct_map = {
            "Rep Performance": ["Margin %"],
        }
        int_map = {
            "Quarterly Trend": ["Order Count"],
            "Rep Performance": ["Units Sold"],
        }
    
        with pd.ExcelWriter(output_path, engine="openpyxl") as writer:
            for sheet_name, df in tables.items():
                df.to_excel(writer, sheet_name=sheet_name, index=False)
                ws = writer.sheets[sheet_name]
                format_sheet(
                    ws, df,
                    currency_cols=currency_map.get(sheet_name, []),
                    pct_cols=pct_map.get(sheet_name, []),
                    int_cols=int_map.get(sheet_name, []),
                )
    
        print(f"Report written: {output_path}")
    
    
    # --- Run it ---
    tables = build_pivot_tables(df)
    write_report(tables, "sales_summary_report.xlsx")
    

    Open the workbook. Each sheet should have a dark header row, appropriately sized columns, dollar signs on revenue figures, a percent sign on margin, and alternating row shading. That's a report someone can use without touching it.

    Tip

    If you want to add an auto-filter to every sheet (so users can slice data like a real PivotTable), add ws.auto_filter.ref = ws.dimensions inside the format_sheet function after the freeze panes line. ws.dimensions returns the full range of the sheet as a string like "A1:J25".


    Adding a Cover Sheet

    A professional workbook usually has a cover or summary sheet — the first thing people see when they open the file. Here's how to prepend one programmatically.

    from openpyxl import load_workbook
    from openpyxl.styles import Font, Alignment
    import datetime
    
    def add_cover_sheet(output_path, report_title, summary_stats):
        """
        Add a cover sheet as the first sheet in the workbook.
        
        summary_stats: dict of {label: value} pairs to display
        """
        wb = load_workbook(output_path)
        
        # Insert a new sheet at position 0
        ws = wb.create_sheet("Summary", 0)
    
        # Title
        ws["B2"] = report_title
        ws["B2"].font = Font(bold=True, size=18, color="1F3864")
        ws["B2"].alignment = Alignment(horizontal="left", vertical="center")
    
        # Generated date
        ws["B3"] = f"Generated: {datetime.date.today().strftime('%B %d, %Y')}"
        ws["B3"].font = Font(italic=True, size=11, color="666666")
    
        # Stats table
        ws["B5"] = "Metric"
        ws["C5"] = "Value"
        for cell in [ws["B5"], ws["C5"]]:
            cell.font = Font(bold=True, color="FFFFFF")
            cell.fill = PatternFill(start_color="1F3864", end_color="1F3864", fill_type="solid")
    
        for i, (label, value) in enumerate(summary_stats.items(), start=6):
            ws[f"B{i}"] = label
            ws[f"C{i}"] = value
            ws[f"C{i}"].alignment = Alignment(horizontal="right")
    
        ws.column_dimensions["A"].width = 3   # left margin
        ws.column_dimensions["B"].width = 28
        ws.column_dimensions["C"].width = 20
        ws.sheet_view.showGridLines = False    # hide gridlines for cleaner look
    
        wb.save(output_path)
    
    
    # Compute summary stats from df
    summary = {
        "Total Revenue": f"${df['revenue'].sum():,.0f}",
        "Total Profit": f"${df['profit'].sum():,.0f}",
        "Overall Margin": f"{df['profit'].sum() / df['revenue'].sum() * 100:.1f}%",
        "Total Orders": f"{len(df):,}",
        "Date Range": f"{df['order_date'].min().date()} to {df['order_date'].max().date()}",
        "Regions": ", ".join(sorted(df["region"].unique())),
    }
    
    add_cover_sheet(
        "sales_summary_report.xlsx",
        "Annual Sales Performance Report — 2024",
        summary,
    )
    

    Notice that we're using load_workbook to reopen the file after the ExcelWriter context has closed. This is intentional: ExcelWriter in openpyxl mode doesn't support inserting sheets at specific positions as gracefully as opening the workbook directly. Load, modify, save — a clean pattern.

    Note

    Setting ws.sheet_view.showGridLines = False hides the worksheet grid, which makes cover sheets and dashboard-style sheets look much more polished. It has no effect on data sheets, but it's a nice touch on the summary page.


    Hands-On Exercise

    Now it's your turn. Take the dataset we built and extend the report with a new analysis.

    Your task: Add a fifth sheet called "Monthly Heatmap" that shows monthly revenue broken out by region. Specifically:

    1. Build a pivot table with month as the index and region as the columns, aggregating revenue with sum.
    2. Add a "Total" column that sums across all regions for each month.
    3. Format the sheet so all revenue cells use the currency format ("$"#,##0).
    4. As a stretch goal: apply conditional formatting so that the top 3 revenue months in each region column get a green fill. You'll need openpyxl.formatting.rule.ColorScaleRule or DataBarRule for this — it's worth exploring the openpyxl docs.

    The pivot for step 1 looks like this to get you started:

    pt_monthly = pd.pivot_table(
        df,
        values="revenue",
        index="month",
        columns="region",
        aggfunc="sum",
        fill_value=0,
    )
    pt_monthly["Total"] = pt_monthly.sum(axis=1)
    pt_monthly = pt_monthly.reset_index().rename(columns={"month": "Month"})
    

    Add it to the tables dict in build_pivot_tables and update the currency_map to include all numeric columns. Then regenerate the report.


    Common Mistakes & Troubleshooting

    Multi-level column index not flattened

    If you write a pivot table with columns= specified and don't flatten the column index first, to_excel will write two header rows with merged cells — which looks odd and makes downstream filtering harder.

    # Before writing, check for MultiIndex columns
    print(type(pt1.columns))  # MultiIndex means you need to flatten
    
    # Fix: join the levels into a single string
    pt1.columns = ["_".join(col).strip() for col in pt1.columns.values]
    

    NaN in the margins row for computed columns

    When you add a derived column (like margin_pct) after a pivot that includes margins=True, the Total row will have a NaN margin percentage because 0 / 0 is undefined or the formula doesn't apply cleanly. Fix it manually:

    # Recalculate the Total row's margin after joining
    total_mask = pt3["Region"] == "Total"
    pt3.loc[total_mask, "Margin %"] = (
        pt3.loc[total_mask, "Profit"] / pt3.loc[total_mask, "Revenue"] * 100
    ).round(1)
    

    Sheet name too long

    Excel limits sheet names to 31 characters. If your sheet name is longer, openpyxl will raise a ValueError. Use [:31] to truncate if you're building sheet names dynamically.

    The workbook opens with a repair warning

    This usually means openpyxl wrote something Excel doesn't recognize. The most common cause is writing a pandas Period dtype into a cell — Excel doesn't know what to do with it. Always convert Period columns to strings: df["quarter"].astype(str).

    format_sheet changes aren't showing up

    The formatting functions must run inside the pd.ExcelWriter context, after to_excel but before the context exits. If you try to format after the with block closes, the workbook is already saved and the writer.sheets reference is stale. Always follow the pattern: write → format → exit.

    with pd.ExcelWriter(path, engine="openpyxl") as writer:
        df.to_excel(writer, sheet_name="Data", index=False)
        ws = writer.sheets["Data"]     # ← grab the sheet reference here
        format_sheet(ws, df, ...)      # ← format it here, inside the context
    # ← file saves here when the 'with' block exits
    

    Multi-Level Pivots and When to Flatten

    Some reports genuinely benefit from the multi-level column structure — especially when you're comparing the same measure across many categories side by side. For those cases, you'll want to understand how to work with MultiIndex columns and rows after groupby and pivot_table, which is covered in depth in Reshaping and Analyzing Multi-Level Data in pandas: Working with MultiIndex Columns and Rows After groupby and pivot_table.

    For Excel output specifically, flat columns almost always win. Business users expect one header row they can filter and sort. Multi-level headers look great in Jupyter notebooks and confusing in Excel.

    The golden rule: flatten before you write. Pick a separator that makes the column names readable. " — " works well (Revenue — Hardware, Profit — Hardware), and " | " is another option.


    Structuring This as a Reusable Module

    If this report runs monthly, you don't want to re-edit the script every time. The right pattern is to parameterize the date range and accept the data path as an argument. Here's the skeleton:

    # report_builder.py
    
    import argparse
    import pandas as pd
    from pathlib import Path
    
    def load_data(source_path):
        path = Path(source_path)
        if path.suffix == ".csv":
            return pd.read_csv(path, parse_dates=["order_date"])
        elif path.suffix in (".xlsx", ".xls"):
            return pd.read_excel(path, parse_dates=["order_date"])
        else:
            raise ValueError(f"Unsupported file type: {path.suffix}")
    
    def run_report(source_path, output_path, start_date=None, end_date=None):
        df = load_data(source_path)
        
        if start_date:
            df = df[df["order_date"] >= pd.to_datetime(start_date)]
        if end_date:
            df = df[df["order_date"] <= pd.to_datetime(end_date)]
    
        df["month"] = df["order_date"].dt.to_period("M").astype(str)
        df["quarter"] = df["order_date"].dt.to_period("Q").astype(str)
    
        tables = build_pivot_tables(df)
        write_report(tables, output_path)
        add_cover_sheet(output_path, "Sales Performance Report", compute_summary(df))
        print(f"Done. Report saved to {output_path}")
    
    
    if __name__ == "__main__":
        parser = argparse.ArgumentParser()
        parser.add_argument("source", help="Path to input CSV or Excel file")
        parser.add_argument("output", help="Path for output Excel workbook")
        parser.add_argument("--start", help="Start date (YYYY-MM-DD)", default=None)
        parser.add_argument("--end", help="End date (YYYY-MM-DD)", default=None)
        args = parser.parse_args()
        run_report(args.source, args.output, args.start, args.end)
    

    Run it like:

    python report_builder.py sales_2024.csv output/report_q4.xlsx --start 2024-10-01 --end 2024-12-31
    

    This is the foundation of an automated reporting pipeline. If you want to take it further — scheduling this to run overnight or on a trigger — the next step is Building and Automating Recurring Reports with pandas: Scheduling Scripts to Run Without You.


    Summary & Next Steps

    You've built a complete reporting pipeline: raw transaction data in, formatted multi-sheet Excel workbook out. The core skills you've practiced:

    • Using pivot_table to build multiple angles of aggregation from a single DataFrame, including multi-function aggregation and computed columns
    • Flattening multi-level column indexes into clean, human-readable names
    • Writing multiple DataFrames to named sheets with pd.ExcelWriter
    • Applying production-quality formatting — header styles, column widths, number formats, zebra striping, and frozen panes — using openpyxl inside the writer context
    • Building a cover sheet by reopening the workbook with load_workbook
    • Structuring the whole pipeline as a reusable, parameterizable module

    A few directions to explore from here:

    More formatting power: The Automating Excel Reports with pandas and openpyxl: Formatted Workbooks Without Manual Work lesson goes deeper on conditional formatting, charts embedded in the workbook, and more advanced openpyxl patterns.

    Richer aggregations: If your summaries need weighted averages, percent-of-total columns, or custom rollup logic, Weighted Averages, Percent of Total, and Custom Aggregations in pandas: Going Beyond sum and mean in groupby covers the techniques that pivot_table alone can't handle.

    Exporting to other formats: Sometimes stakeholders want CSV exports alongside Excel, or you need to post JSON to an API. Exporting and Sharing Analysis Results: Writing CSV, Excel, and JSON Files from pandas covers all three formats in one place.

    The goal is always the same: analysis that's reproducible, reports that look deliberate, and workflows that don't require you to be in the room.

    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    Python for Data Analysis

    Previous

    Auditing and Reconciling Data Across Sources in pandas: Matching Totals, Flagging Discrepancies, and Building a Reconciliation Report

    Next

    Building a Multi-Source Reconciliation Report in pandas: Comparing Actuals vs. Targets, Flagging Variances, and Exporting a Stakeholder-Ready Excel Summary

    Related Insights

    PythonPractitioner

    Building an Inventory Aging Report in pandas: Classifying Stock by Days on Hand, Flagging Slow Movers, and Exporting a Tiered Summary to Excel

    22 min
    PythonFoundation

    Calculating Descriptive Statistics in pandas: mean, median, std, and describe for Summarizing Numeric Columns Before You Analyze

    15 min
    PythonExpert

    Building a pandas Attribution Model: Allocating Revenue Across Multi-Touch Customer Journeys Using First-Touch, Last-Touch, and Linear Attribution Rules

    24 min

    On this page

    • Introduction
    • Prerequisites
    • Building the Dataset
    • Understanding pivot_table Before You Use It
    • Building the Four Summary Tables
    • Table 1: Revenue and Profit by Region × Category
    • Table 2: Monthly Revenue Trend by Quarter
    • Table 3: Rep Performance Summary
    • Table 4: Category × Product Line Detail
    • Writing to Excel: The ExcelWriter Pattern
    • Applying Formatting with openpyxl
    • The Complete Report Script
    • Adding a Cover Sheet
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Multi-level column index not flattened
    • NaN in the margins row for computed columns
    • Sheet name too long
    • The workbook opens with a repair warning
    • format_sheet changes aren't showing up
    • Multi-Level Pivots and When to Flatten
    • Structuring This as a Reusable Module
    • Summary & Next Steps