Apply a Reusable Style Theme Across an Excel Report
The third report you write with openpyxl has the same header format as the first two, typed out again slightly differently. By the tenth, no two reports look alike, and changing the brand colour means finding every PatternFill in the codebase. A theme fixes that: define each visual role once — header, total, currency, warning — register them on the workbook as named styles, and apply them by name. This guide builds one, covers the format-ceiling error a per-cell approach eventually hits, and shows how to share the theme across reports. It extends Styling Excel Cells with openpyxl.
Prerequisites
pip install openpyxl pandas
Before writing any code, decide the roles rather than the appearances. A theme keyed on header and total survives a redesign; one keyed on indigo_bold and green_fill does not.
| Role | Used for |
|---|---|
title | the report banner |
subtitle | period and generation stamp |
header | column headings |
body | ordinary data cells |
currency | money columns |
percent | rate columns |
date | date columns |
total | summary rows |
warning | values needing attention |
Step 1 — Define the palette and the roles
Keep the colours in one place, and build the styles from them:
# theme.py
from openpyxl.styles import (
Alignment, Border, Font, NamedStyle, PatternFill, Side
)
PALETTE = {
"brand": "4338CA",
"brand_soft": "EBEBFD",
"ink": "172033",
"muted": "5B6780",
"line": "CDD5E6",
"ok": "0B6157",
"ok_soft": "D9F4F1",
"warn": "BE185D",
"warn_soft": "FEE8F2",
"white": "FFFFFF",
}
FONT = "Calibri"
THIN = Side(style="thin", color=PALETTE["line"])
def build_styles():
"""Return the report theme as a list of NamedStyle objects."""
title = NamedStyle(name="title")
title.font = Font(name=FONT, size=15, bold=True, color=PALETTE["brand"])
title.fill = PatternFill("solid", start_color=PALETTE["brand_soft"],
end_color=PALETTE["brand_soft"])
title.alignment = Alignment(horizontal="center", vertical="center")
subtitle = NamedStyle(name="subtitle")
subtitle.font = Font(name=FONT, size=9, italic=True, color=PALETTE["muted"])
subtitle.alignment = Alignment(horizontal="center", vertical="center")
header = NamedStyle(name="header")
header.font = Font(name=FONT, size=11, bold=True, color=PALETTE["white"])
header.fill = PatternFill("solid", start_color=PALETTE["brand"],
end_color=PALETTE["brand"])
header.alignment = Alignment(horizontal="center", vertical="center",
wrap_text=True)
header.border = Border(bottom=THIN)
body = NamedStyle(name="body")
body.font = Font(name=FONT, size=11, color=PALETTE["ink"])
body.alignment = Alignment(vertical="center")
currency = NamedStyle(name="currency")
currency.font = Font(name=FONT, size=11, color=PALETTE["ink"])
currency.number_format = "#,##0.00"
currency.alignment = Alignment(horizontal="right", vertical="center")
percent = NamedStyle(name="percent")
percent.font = Font(name=FONT, size=11, color=PALETTE["ink"])
percent.number_format = "0.0%"
percent.alignment = Alignment(horizontal="right", vertical="center")
date = NamedStyle(name="date")
date.font = Font(name=FONT, size=11, color=PALETTE["ink"])
date.number_format = "yyyy-mm-dd"
date.alignment = Alignment(horizontal="center", vertical="center")
total = NamedStyle(name="total")
total.font = Font(name=FONT, size=11, bold=True, color=PALETTE["ok"])
total.fill = PatternFill("solid", start_color=PALETTE["ok_soft"],
end_color=PALETTE["ok_soft"])
total.number_format = "#,##0.00"
total.border = Border(top=THIN)
total.alignment = Alignment(horizontal="right", vertical="center")
warning = NamedStyle(name="warning")
warning.font = Font(name=FONT, size=11, bold=True, color=PALETTE["warn"])
warning.fill = PatternFill("solid", start_color=PALETTE["warn_soft"],
end_color=PALETTE["warn_soft"])
warning.number_format = "#,##0.00"
warning.alignment = Alignment(horizontal="right", vertical="center")
return [title, subtitle, header, body, currency, percent, date,
total, warning]
Building them in a function rather than at module level is the important structural choice. A NamedStyle binds to the workbook it is added to, so a module-level instance added to a second workbook raises. A factory hands each workbook its own instances.
Step 2 — Register the theme on a workbook
# theme.py, continued
def apply_theme(wb):
"""Register every theme style on a workbook. Safe to call twice."""
existing = set(wb.style_names)
for style in build_styles():
if style.name not in existing:
wb.add_named_style(style)
return wb
The existing check matters because add_named_style raises on a duplicate name, and a script that opens a template already carrying the theme would otherwise fail on the second run.
Applying a style is then one assignment:
from openpyxl import Workbook
from theme import apply_theme
wb = apply_theme(Workbook())
ws = wb.active
ws["A1"] = "Region"
ws["A1"].style = "header"
ws["B2"] = 5150.00
ws["B2"].style = "currency"
wb.save("report.xlsx")
Step 3 — Apply styles by column role
Assigning cell by cell is where scripts get repetitive. Drive it from a column-to-role mapping instead:
from openpyxl.utils import get_column_letter
COLUMN_ROLES = {
"region": "body",
"owner": "body",
"invoice_date": "date",
"revenue": "currency",
"margin": "percent",
}
def style_table(ws, header_row=1, roles=COLUMN_ROLES, default="body"):
"""Apply the theme to a table using a column-name to role mapping."""
headers = {}
for cell in ws[header_row]:
if cell.value is None:
continue
cell.style = "header"
headers[str(cell.value).strip()] = cell.column
for name, column in headers.items():
role = roles.get(name, default)
for (cell,) in ws.iter_rows(min_row=header_row + 1,
min_col=column, max_col=column):
if cell.value is not None:
cell.style = role
ws.freeze_panes = ws.cell(row=header_row + 1, column=1).coordinate
return headers
Keying on the header text rather than the column position means an inserted column does not misformat everything to its right — the same reasoning as keying data rows by header in iterating rows and columns with openpyxl.
A total row and conditional warnings slot in the same way:
def style_total_row(ws, row, first_col=1, last_col=None):
last_col = last_col or ws.max_column
for col in range(first_col, last_col + 1):
ws.cell(row=row, column=col).style = "total"
def flag_above(ws, column_letter, threshold, header_row=1):
"""Re-style cells above a threshold with the warning role."""
from openpyxl.utils import column_index_from_string
col = column_index_from_string(column_letter)
flagged = 0
for (cell,) in ws.iter_rows(min_row=header_row + 1, min_col=col, max_col=col):
if isinstance(cell.value, (int, float)) and cell.value > threshold:
cell.style = "warning"
flagged += 1
return flagged
Note that re-assigning cell.style replaces the previous style entirely rather than merging — a cell styled currency and then warning keeps only the warning's number format, which is why warning defines one. Styles are whole roles, not modifiers.
Step 4 — Avoid the format ceiling
Excel caps a workbook at roughly 64,000 distinct cell formats. A script that constructs style objects inside a loop reaches it on a large sheet, and the file then fails to open with a "too many different cell formats" error.
from openpyxl.styles import Font, PatternFill
# Wrong: a new Font and PatternFill object per cell.
for row in ws.iter_rows(min_row=2, max_row=100_000):
for cell in row:
cell.font = Font(name="Calibri", size=11, color="172033")
cell.fill = PatternFill("solid", start_color="FFFFFF",
end_color="FFFFFF")
# Right: one named style, referenced by name.
for row in ws.iter_rows(min_row=2, max_row=100_000):
for cell in row:
cell.style = "body"
openpyxl does deduplicate identical style objects on save, so the wrong version above may survive — but it allocates a hundred thousand short-lived objects to get there, and any variation between iterations (a colour derived from the value, say) defeats the deduplication entirely and hits the ceiling for real.
Step 5 — Share the theme across reports
Because build_styles is a function, every workbook gets its own instances from the same definitions:
from openpyxl import Workbook, load_workbook
from theme import apply_theme, style_table, style_total_row
def new_report(title, subtitle, columns):
"""A blank workbook with the theme applied and a banner in place."""
wb = apply_theme(Workbook())
ws = wb.active
ws["A1"] = title
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=columns)
ws["A1"].style = "title"
ws.row_dimensions[1].height = 30
ws["A2"] = subtitle
ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=columns)
ws["A2"].style = "subtitle"
return wb, ws
# Every report in the project starts the same way.
wb, ws = new_report("Regional Revenue", "August 2026", columns=5)
For a report produced through pandas, apply the theme in a second pass after writing:
import pandas as pd
from openpyxl import load_workbook
from theme import apply_theme, style_table
def write_themed(df, path, sheet_name="Report"):
df.to_excel(path, sheet_name=sheet_name, index=False, engine="openpyxl")
wb = apply_theme(load_workbook(path))
style_table(wb[sheet_name])
wb.save(path)
return path
The two-pass shape is unavoidable with openpyxl, because to_excel replaces the sheet and would discard styling applied first. The banner mechanics are in merging cells and centring a report title.
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
ValueError: Style X exists already | Theme added twice | Check wb.style_names before adding. |
| Style raises on a second workbook | NamedStyle bound to the first | Build fresh instances via a factory. |
| "Too many different cell formats" | Style objects created per cell | Assign named styles by name. |
| Number format lost after re-styling | cell.style replaces, not merges | Give each role its own number format. |
| Styling vanished after a pandas write | to_excel replaced the sheet | Apply the theme in a second pass. |
| A column formatted wrongly | Roles keyed by position | Key the mapping on header text. |
| Row-level style ignored | Set on the row dimension | Assign to each cell in the row. |
| Fill renders as nothing | Only start_color given | Set both start_color and end_color. |
Performance and scale notes
Named styles are the cheap path both in memory and in file size, but the per-cell assignment loop still runs in Python. On a hundred-thousand-row sheet that is measurable.
Three ways to reduce it. Style only the columns that need a non-default look — leaving ordinary text columns unstyled costs nothing and looks fine, so a nine-role theme applied to three of twelve columns does a quarter of the work.
Set the workbook's default font instead of assigning a body style to every cell:
from openpyxl import Workbook
wb = Workbook()
wb._fonts[0].name = "Calibri" # the default font all unstyled cells inherit
wb._fonts[0].sz = 11
Use xlsxwriter for large new reports. Its set_column applies a format to a whole column in one call, independent of row count, which is the single biggest win available:
import pandas as pd
THEME = {
"header": {"bold": True, "bg_color": "#4338CA", "font_color": "#FFFFFF",
"border": 1, "align": "center", "valign": "vcenter"},
"currency": {"num_format": "#,##0.00", "align": "right"},
"date": {"num_format": "yyyy-mm-dd", "align": "center"},
"total": {"bold": True, "bg_color": "#D9F4F1", "font_color": "#0B6157",
"num_format": "#,##0.00", "top": 1},
}
def write_large_themed(df, path, roles, sheet_name="Report"):
with pd.ExcelWriter(path, engine="xlsxwriter") as writer:
df.to_excel(writer, sheet_name=sheet_name, index=False)
book, sheet = writer.book, writer.sheets[sheet_name]
formats = {name: book.add_format(spec) for name, spec in THEME.items()}
for position, column in enumerate(df.columns):
sheet.write(0, position, str(column), formats["header"])
role = roles.get(column)
sheet.set_column(position, position, 16, formats.get(role))
sheet.freeze_panes(1, 0)
return path
Building the format objects once into a dict is the same discipline as the named styles — add_format creates a new entry every call, so constructing one per column in a wide report inflates the workbook's format table for no benefit. And where a report is large enough to need streaming, note that write_only mode supports styles on the cells you append but not named styles registered afterwards, so the theme must be applied as each row is built — see writing large DataFrames with write-only mode.
Conclusion
A theme turns styling from something scattered through a script into one module with a palette and a set of roles. Define the roles by meaning — header, currency, total, warning — not by appearance, build them in a factory function so each workbook gets its own NamedStyle instances, and register them once with a guard against duplicates. Apply them through a column-name-to-role mapping so an inserted column cannot misformat everything after it. Then never construct a style object inside a loop: that is what produces the 64,000-format error, and a named style avoids it entirely.
Frequently asked questions
What is a NamedStyle and why use one?
A NamedStyle is a style registered once on the workbook and referenced by name from any cell. Excel stores it once regardless of how many cells use it, and a reader sees it in the cell-styles gallery, so it is both cheaper and more discoverable than assigning fonts and fills per cell.
What causes the "too many different cell formats" error?
Creating a fresh style object inside a loop. Excel caps a workbook at roughly 64,000 distinct cell formats, and a script that builds a new Font or PatternFill per row reaches it on a large sheet. Define each style once outside the loop.
Can I add the same NamedStyle to two workbooks?
Not the same object — a NamedStyle binds to the workbook it is added to. Build them from a plain configuration dictionary with a factory function so each workbook gets its own equivalent instances.
How do I apply a style to a whole row or column? There is no single call; assign the style to each cell in the range. Row and column dimension objects accept a style, but it only affects cells that have never been written to, so it is unreliable for a data region.
Should the theme live in code or in a config file? A dictionary in one module is enough for a single project. Move it to a config file when several scripts must match, or when somebody non-technical needs to change the brand colours without touching Python.
Related
- Up to the parent: Styling Excel Cells with openpyxl — the individual style objects a theme composes.
- Merge Cells and Centre a Report Title with openpyxl — the banner the
titlerole formats. - Write a Formatted Excel Report with xlsxwriter — the same idea in the other engine.
- Keep Excel Report Settings in a Config File — moving the palette out of code.
- Auto-Fit Column Widths When Writing with pandas — the sizing pass that pairs with the theme.