Guide
Formatting and Charting Excel Reports with Python
Turn plain pandas output into branded, stakeholder-ready Excel reports: cell styling, number and date formats, charts, and embedded logos with openpyxl.
df.to_excel("report.xlsx") produces a workbook that is correct and ugly: black Calibri on white, raw 0.4815 instead of 48.15%, dates rendered as five-digit serial numbers, and columns too narrow to read. Nobody trusts a report that looks like a database dump. This track is about the last mile — turning that plain grid into something a finance director will open, scan, and act on without complaining.
Everything here is for working Python developers who already write DataFrames to Excel and now need the output to look deliberate: a styled header row, money formatted as money, a chart that summarizes the table above it, and the company logo in the top-left corner. Almost all of it is plain openpyxl, with a note on where xlsxwriter is the faster tool. Every code block below builds its own tiny workbook and runs top to bottom, so you can paste and execute without any data of your own.
What you will learn
This pillar links four clusters, each going deep on one piece of the polished-report problem:
- Styling Excel Cells with openpyxl — fonts, fills, borders, alignment, column widths, and freeze panes.
- Applying Number and Date Formats in Excel — currency, percentage, thousands separators, and date format codes.
- Creating Charts in Excel with openpyxl — bar, line, and pie charts driven by worksheet ranges.
- Inserting Images and Logos into Excel — embedding logos and images for branded output.
The four runnable sections below are a tour of all four at once: style a header, format money and dates, add a bar chart, and drop in a logo.
The styling library landscape
Three libraries write .xlsx files, but they make different trade-offs around editing existing files versus building new ones fast. Picking the wrong one is the most common reason a styling script becomes painful.
| Library | Role | Reads existing files? | Strengths | Limits |
|---|---|---|---|---|
pandas to_excel | Dump raw tabular data | Via read_excel | One line from DataFrame to sheet; multi-sheet writes | No real styling control beyond a header bold via the engine |
| openpyxl | Style and edit workbooks cell by cell | Yes — loads and preserves existing styles | Charts, images, formats, conditional formatting; can re-open a pandas file and decorate it | Slower on very large writes |
| xlsxwriter | Build new styled files in one pass | No — write-only | Fastest styled writes; rich chart and format API | Cannot open or modify an existing file |
The decision is mechanical:
- Already have a file (a pandas export, a template, last month's report)? Use openpyxl — it is the only one of the three that can open a workbook, keep its existing formatting, and add to it.
- Generating a fresh report from scratch and want maximum speed on a big sheet? Use xlsxwriter, either directly or as the pandas engine:
df.to_excel("out.xlsx", engine="xlsxwriter"). - Just need the data on a sheet with no styling? Plain
pandas.to_excelis fine.
The realistic pattern for a weekly report is pandas to write the data, then openpyxl to dress it: pandas turns your DataFrame into rows, you re-open the file with load_workbook, and you apply styles, formats, charts, and a logo. That two-step flow is what most of this track teaches, and it builds on Using openpyxl for Excel File Manipulation and Writing DataFrames to Excel with Pandas.
Install both engines so every example below runs:
pip install pandas openpyxl
Style a header row and autosize columns
The single biggest visual upgrade is a styled header: bold white text on a colored fill, centered, with a thin border under it, and columns wide enough to read. Here we write a small regional sales table with pandas, then re-open it with openpyxl to apply Font, PatternFill, Alignment, and Border, and fit each column to its widest value.
import pandas as pd
from openpyxl import load_workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
# 1. pandas writes the raw data
sales = pd.DataFrame({
"Region": ["North", "South", "East", "West"],
"Orders": [128, 94, 156, 73],
"Revenue": [25640.50, 18890.00, 31200.75, 14005.25],
})
sales.to_excel("sales_report.xlsx", sheet_name="Sales", index=False)
# 2. openpyxl re-opens it and styles the header
wb = load_workbook("sales_report.xlsx")
ws = wb["Sales"]
header_font = Font(bold=True, color="FFFFFF", size=12)
header_fill = PatternFill("solid", fgColor="305496")
center = Alignment(horizontal="center", vertical="center")
thin_bottom = Border(bottom=Side(style="thin", color="1F3864"))
for cell in ws[1]:
cell.font = header_font
cell.fill = header_fill
cell.alignment = center
cell.border = thin_bottom
# Autosize: widen each column to its longest value
for column_cells in ws.columns:
longest = max(len(str(c.value)) for c in column_cells if c.value is not None)
ws.column_dimensions[column_cells[0].column_letter].width = longest + 3
ws.freeze_panes = "A2" # keep the header visible while scrolling
wb.save("sales_report.xlsx")
print("Styled header and fitted", ws.max_column, "columns")
Excel has no "autofit" you can call from a file writer — the fitted width comes from measuring the text yourself, which is exactly what the loop does. The full set of styling primitives lives in Styling Excel Cells with openpyxl.
Apply currency and date number formats
A number format is a display rule: it changes how a value looks, not the value itself, so 1234.5 stays a number you can sum while showing as $1,234.50. You set it with cell.number_format using Excel's format codes. Here we add an order date and a price to a tiny table, then format the date as dd-mmm-yyyy and the price as currency.
import datetime as dt
from openpyxl import Workbook
from openpyxl.styles import Font
wb = Workbook()
ws = wb.active
ws.title = "Orders"
ws.append(["Order", "Order Date", "Amount"])
for cell in ws[1]:
cell.font = Font(bold=True)
rows = [
("A-1001", dt.date(2026, 6, 1), 1234.50),
("A-1002", dt.date(2026, 6, 3), 879.00),
("A-1003", dt.date(2026, 6, 4), 2410.75),
]
for order, order_date, amount in rows:
ws.append([order, order_date, amount])
# Format the date column (B) and currency column (C)
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
row[1].number_format = "dd-mmm-yyyy" # 01-Jun-2026
row[2].number_format = '"$"#,##0.00' # $1,234.50
wb.save("orders_formatted.xlsx")
total = sum(amount for _, _, amount in rows)
print(f"Wrote 3 orders, total still summable: ${total:,.2f}")
The amounts remain real numbers — Excel can total column C even though it displays dollar signs. For percentages, thousands separators, and locale-aware currency codes, see Applying Number and Date Formats in Excel.
Add a bar chart from worksheet data
A chart in openpyxl points at ranges already on the sheet — you give it a Reference to the data and to the category labels, and Excel renders it live, so the chart updates if the cells change. Here we write four regions of revenue and anchor a BarChart next to the table.
from openpyxl import Workbook
from openpyxl.chart import BarChart, Reference
wb = Workbook()
ws = wb.active
ws.title = "Revenue"
ws.append(["Region", "Revenue"])
for region, revenue in [("North", 25640), ("South", 18890),
("East", 31200), ("West", 14005)]:
ws.append([region, revenue])
chart = BarChart()
chart.type = "col"
chart.title = "Revenue by Region"
chart.y_axis.title = "Revenue ($)"
chart.x_axis.title = "Region"
data = Reference(ws, min_col=2, min_row=1, max_row=5) # include header for series name
cats = Reference(ws, min_col=1, min_row=2, max_row=5)
chart.add_data(data, titles_from_data=True)
chart.set_categories(cats)
ws.add_chart(chart, "D2") # top-left corner of the chart
wb.save("revenue_chart.xlsx")
print("Embedded a", chart.type, "chart anchored at D2")
The chart is a native Excel object, not a pasted image, so users can restyle it in Excel and it redraws if the data changes. Line and pie variants follow the same Reference pattern in Creating Charts in Excel with openpyxl.
Insert a logo image
Branding a report usually means a logo in the top-left corner. openpyxl embeds raster images (PNG, JPEG) through openpyxl.drawing.image.Image, anchored to a cell. This needs Pillow, which openpyxl uses to read the image — the example generates a tiny PNG inline so it runs with nothing to download.
pip install openpyxl pillow
from openpyxl import Workbook
from openpyxl.drawing.image import Image as XLImage
from PIL import Image as PILImage
# Build a small placeholder logo so the example is self-contained
PILImage.new("RGB", (120, 40), color="#305496").save("logo.png")
wb = Workbook()
ws = wb.active
ws.title = "Report"
# Reserve space for the logo, then start the title below it
ws.row_dimensions[1].height = 34
ws["A3"] = "Weekly Sales Report"
logo = XLImage("logo.png")
logo.width, logo.height = 120, 40 # in pixels
ws.add_image(logo, "A1") # anchor top-left
wb.save("branded_report.xlsx")
print("Embedded logo.png at cell A1")
The image is copied into the workbook, so the saved .xlsx is self-contained — you can email it and the logo travels with it. See Inserting Images and Logos into Excel for sizing, aspect ratio, and headers/footers.
Frequently asked questions
Does styling survive pandas.to_excel?
Only minimally. to_excel writes data and a basic bold header; it gives you no handle on fonts, fills, borders, number formats, charts, or images. The standard approach is to write the data with pandas, then re-open the file with openpyxl.load_workbook and apply everything else. openpyxl preserves any styling already in the file when it loads.
openpyxl or xlsxwriter for charts and styling? Use openpyxl when you need to open and modify an existing file — it is the only one that can read a workbook and keep its formatting. Use xlsxwriter when you are generating a brand-new file from scratch and want the fastest styled write; it has a rich chart and format API but is strictly write-only, so it cannot touch a file that already exists.
How do I autosize columns?
There is no file-level "autofit." Measure the longest string in each column yourself and set ws.column_dimensions[letter].width to that length plus a small pad (the header-row example above does this). Width units are roughly the count of default-font characters, not pixels.
Do I need Excel installed to format and chart?
No. openpyxl and xlsxwriter are pure Python and write the .xlsx file format directly, so they run on a headless Linux server or CI runner with no Excel anywhere. You only need Excel (via xlwings) when a live application must run macros or recalculate — which is not the case for formatting, number formats, charts, or images.
Will a number format break my totals?
No. A number format only changes how a value is displayed. '"$"#,##0.00' makes 1234.5 show as $1,234.50, but Excel still stores a number and sums it normally. Formatting a real datetime.date with a date code is likewise display-only.
Key takeaways
- pandas writes correct but unstyled sheets; the polish comes from re-opening the file with openpyxl.
- Use openpyxl to edit existing files (it preserves styles), xlsxwriter for fast new-file builds, plain pandas for raw data.
- Header styling and fitted column widths are the cheapest, highest-impact upgrades.
- Number formats are display-only — your values stay summable and your dates stay real dates.
- Charts reference live worksheet ranges; logos embed into the file and travel with it.
- None of this needs Excel installed, so it runs unattended on a server.
Where to go next
Go deep on each piece of the polished report:
- Styling Excel Cells with openpyxl — fonts, fills, borders, alignment, column widths, and freeze panes.
- Applying Number and Date Formats in Excel — currency, percentage, thousands, and date format codes.
- Creating Charts in Excel with openpyxl — bar, line, and pie charts from worksheet ranges.
- Inserting Images and Logos into Excel — embedding logos and images into branded reports.
Related tracks:
- Automating Reporting Workflows — once a report looks right, put it on a schedule and email it.
- Applying Conditional Formatting with openpyxl — rule-based styling that reacts to the data, such as highlighting low stock.