Styling Excel Cells with openpyxl
A report that no one styles looks like a database dump. With openpyxl you set fonts, fills, borders, and alignment directly on cells, and because you are editing the Office Open XML file itself, those styles persist exactly as written. This guide is part of Formatting and Charting Excel Reports with Python, and it walks through every styling object you reach for in a real reporting script. Styling handles the look of a cell; the value inside it is shaped separately by number and date formats, which this guide leaves untouched. Each block runs in order against a sample workbook built in the first step.
Install and create a sample workbook
pip install openpyxl
Build a tiny sales sheet with a header row and a few data rows so the styling steps below have real cells to target:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "Sales"
ws.append(["Region", "Rep", "Units", "Revenue"])
for row in [
["North", "Alvarez", 120, 24000],
["South", "Boateng", 95, 19000],
["East", "Chen", 140, 28000],
["West", "Dubois", 80, 16000],
]:
ws.append(row)
wb.save("styled_report.xlsx")
print("Sample workbook created")
Font: bold, color, and size
Font controls typeface, weight, size, and color. Color is an 8-digit ARGB hex string (or a 6-digit RGB string, which openpyxl pads to full opacity). Assign a fresh Font to a cell's .font attribute — fonts are immutable, so you replace them rather than mutate them:
from openpyxl import load_workbook
from openpyxl.styles import Font
wb = load_workbook("styled_report.xlsx")
ws = wb["Sales"]
ws["A1"].font = Font(name="Calibri", size=12, bold=True, color="FFFFFF")
ws["D2"].font = Font(bold=True, italic=True, color="2E7D32") # green revenue
wb.save("styled_report.xlsx")
print("Fonts applied")
PatternFill: cell backgrounds
PatternFill paints a cell's background. You must pass a fill_type (use "solid"); without it the fill renders invisibly. fgColor is the visible color for a solid fill. A PatternFill is a static, unconditional colour — when you want the colour to change based on the value in the cell, reach for conditional formatting instead:
from openpyxl import load_workbook
from openpyxl.styles import PatternFill
wb = load_workbook("styled_report.xlsx")
ws = wb["Sales"]
header_fill = PatternFill(fill_type="solid", fgColor="4472C4") # blue bar
for cell in ws[1]:
cell.fill = header_fill
wb.save("styled_report.xlsx")
print("Header fill applied")
Border and Side: ruled cells
A Border is built from four Side objects — left, right, top, bottom. Each Side takes a style (such as "thin", "medium", or "double") and a color. Define one Side and reuse it on all four edges for a clean box:
from openpyxl import load_workbook
from openpyxl.styles import Border, Side
wb = load_workbook("styled_report.xlsx")
ws = wb["Sales"]
thin = Side(style="thin", color="999999")
box = Border(left=thin, right=thin, top=thin, bottom=thin)
for row in ws["A1:D5"]:
for cell in row:
cell.border = box
wb.save("styled_report.xlsx")
print("Borders applied")
Alignment: wrapping and centering
Alignment controls horizontal and vertical placement plus text wrapping. Set wrap_text=True to let long values flow onto multiple lines instead of spilling over. Center the header row both ways:
from openpyxl import load_workbook
from openpyxl.styles import Alignment
wb = load_workbook("styled_report.xlsx")
ws = wb["Sales"]
center = Alignment(horizontal="center", vertical="center", wrap_text=True)
for cell in ws[1]:
cell.alignment = center
# Right-align the numeric columns for a tidy ledger look
right = Alignment(horizontal="right")
for row in ws["C2:D5"]:
for cell in row:
cell.alignment = right
wb.save("styled_report.xlsx")
print("Alignment applied")
Zebra-stripe the data rows
Alternating row shading (banding) makes a wide table far easier to read across. Because a PatternFill is a plain object, you can pick one of two fills per row using the row index — enumerate the data rows and shade the even ones with a light tint:
from openpyxl import load_workbook
from openpyxl.styles import PatternFill
wb = load_workbook("styled_report.xlsx")
ws = wb["Sales"]
band = PatternFill("solid", fgColor="F2F5FB") # very light blue-grey
# Rows 2..5 are data; shade every other one
for i, row in enumerate(ws.iter_rows(min_row=2, max_row=5)):
if i % 2 == 1:
for cell in row:
cell.fill = band
wb.save("styled_report.xlsx")
print("Zebra striping applied")
Keep the band colour light so black text stays comfortably readable on it — a heavy fill fails the same contrast test the header's white-on-blue passes. Unlike Excel's built-in table banding, this writes a real fill onto each cell, so it survives even if the reader removes the table object.
Style a header row in one loop
In a real generator the header is styled in a single pass that combines font, fill, and alignment. Build the style objects once, outside the loop, then apply them to every cell in row 1:
from openpyxl import load_workbook
from openpyxl.styles import Font, PatternFill, Alignment
wb = load_workbook("styled_report.xlsx")
ws = wb["Sales"]
head_font = Font(bold=True, color="FFFFFF", size=12)
head_fill = PatternFill("solid", fgColor="4472C4")
head_align = Alignment(horizontal="center", vertical="center")
for cell in ws[1]:
cell.font = head_font
cell.fill = head_fill
cell.alignment = head_align
wb.save("styled_report.xlsx")
print("Header row styled in a loop")
Merge cells for a title bar
ws.merge_cells() joins a rectangular range into one logical cell. Set the value and styling on the top-left cell of the range — the others are emptied. Insert a row first so the title sits above the table:
from openpyxl import load_workbook
from openpyxl.styles import Font, Alignment
wb = load_workbook("styled_report.xlsx")
ws = wb["Sales"]
ws.insert_rows(1) # push the table down one row
ws.merge_cells("A1:D1") # title spans all four columns
title = ws["A1"]
title.value = "Q3 Regional Sales"
title.font = Font(bold=True, size=14, color="1F2937")
title.alignment = Alignment(horizontal="center", vertical="center")
wb.save("styled_report.xlsx")
print("Title bar merged")
Reuse a NamedStyle
When the same combination of font, fill, border, and alignment recurs, define a NamedStyle once, register it on the workbook, then apply it by name. Excel also exposes named styles to the user, and reusing one keeps the file smaller than repeating inline styles:
from openpyxl import load_workbook
from openpyxl.styles import NamedStyle, Font, PatternFill, Alignment
wb = load_workbook("styled_report.xlsx")
ws = wb["Sales"]
header_style = NamedStyle(name="report_header")
header_style.font = Font(bold=True, color="FFFFFF")
header_style.fill = PatternFill("solid", fgColor="305496")
header_style.alignment = Alignment(horizontal="center")
if "report_header" not in wb.named_styles:
wb.add_named_style(header_style)
for cell in ws[2]: # the column-header row (row 1 is the title)
cell.style = "report_header"
wb.save("styled_report.xlsx")
print("NamedStyle applied")
Why these styles survive
openpyxl edits the workbook XML directly, so every style you set is written into the file and reopens exactly as saved. That is the key difference from pandas.to_excel(), which writes plain unstyled cells (beyond a couple of header options) and discards any formatting you might expect. If you generate the data with pandas and need styling, write the values first, then reopen the file with openpyxl and apply styles — or skip pandas and build the sheet with openpyxl from the start.
For sizing and navigation, two companion pages go deeper: Set Column Width and Row Height in openpyxl covers ws.column_dimensions[...].width and row heights, and Freeze the Header Row in Excel with openpyxl covers ws.freeze_panes so the header stays visible while scrolling. Once the table looks right, you can turn it into a chart or drop in a company logo to finish the report.
Style objects are shared, and that matters
openpyxl deduplicates styles: two cells given identical formatting share one record in the workbook's
style table. Creating a new Font per cell defeats that, inflating the file and slowing every reader:
from openpyxl import load_workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
HEADER_FONT = Font(bold=True, color="FFFFFF", size=11)
HEADER_FILL = PatternFill("solid", start_color="4338CA")
CENTRED = Alignment(horizontal="center", vertical="center", wrap_text=True)
THIN = Side(style="thin", color="CDD5E6")
BOX = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
wb = load_workbook("report.xlsx")
ws = wb["Orders"]
for cell in ws[1]:
cell.font, cell.fill, cell.alignment, cell.border = HEADER_FONT, HEADER_FILL, CENTRED, BOX
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
for cell in row:
cell.border = BOX
wb.save("report_styled.xlsx")
Assigning the same object to many cells is safe because openpyxl style objects are immutable — the
assignment stores a reference to a shared record rather than a mutable object each cell can change.
That is also why cell.font.bold = True does not work: create a new Font (or use
copy(cell.font)) instead of mutating the one you were given.
Alignment and wrapping do more than fonts
Most reports that look cramped are suffering from alignment rather than typography. Three settings carry most of the improvement: wrapping long headers so the column can stay narrow, centring short codes so the eye can scan them, and right-aligning numbers so the decimal points line up — which Excel does automatically for real numbers and not at all for numbers stored as text.
from openpyxl.styles import Alignment
for cell in ws[1]:
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
ws.row_dimensions[1].height = 30 # room for the wrapped text
for row in ws.iter_rows(min_row=2, min_col=3, max_col=4):
for cell in row:
cell.alignment = Alignment(horizontal="right")
Setting an explicit row height on a wrapped header is what stops the text being clipped: Excel auto-fits row height when a user types, but not when openpyxl writes, so a wrapped header in a default-height row shows only its first line.
A house style in one function
Formatting spread through a script drifts. Collecting it into one function applied to every sheet keeps a multi-tab workbook coherent and gives you a single place to change when the style does:
def apply_house_style(ws, money=(), percent=(), freeze="A2"):
for cell in ws[1]:
cell.font, cell.fill, cell.alignment = HEADER_FONT, HEADER_FILL, CENTRED
ws.row_dimensions[1].height = 28
ws.freeze_panes = freeze
for letter in money:
for row in range(2, ws.max_row + 1):
ws[f"{letter}{row}"].number_format = "#,##0.00"
for letter in percent:
for row in range(2, ws.max_row + 1):
ws[f"{letter}{row}"].number_format = "0.0%"
return ws
Two arguments, one consistent look, and no styling code scattered through the report logic — which is what makes it realistic to restyle every report a team produces when the house colours change.
Column widths are the most-noticed formatting of all
A number shown as #### reads as a broken report, and it is entirely a width problem. Deriving
widths from the content takes a few lines and removes the whole class of complaint:
from openpyxl.utils import get_column_letter
def autosize(ws, min_width=9, max_width=42, padding=3):
for cells in ws.columns:
letter = get_column_letter(cells[0].column)
longest = max((len(str(c.value)) for c in cells if c.value is not None), default=0)
ws.column_dimensions[letter].width = min(max(longest + padding, min_width), max_width)
The padding covers the filter arrow that otherwise overlaps the last characters of a header, and the cap stops one long free-text column pushing everything else off the screen. Because openpyxl has no true auto-fit — Excel computes that at display time from font metrics — character counting is the practical approximation, and it is close enough that nobody notices the difference.
Row heights follow the same logic but only matter where text wraps: a wrapped header in a default-height row shows one line and hides the rest, so set the height explicitly whenever you turn wrapping on.
Merging cells, carefully
Merges are for titles and banners, not for data. A merged range holds its value in the top-left cell only; the rest become read-only, sorting and filtering behave unexpectedly, and any script reading the sheet sees blanks where a person sees text:
ws.merge_cells("A1:F1")
ws["A1"] = "Monthly report — March 2026"
ws["A1"].alignment = Alignment(horizontal="center", vertical="center")
ws.row_dimensions[1].height = 26
Where a grouping label would tempt you to merge down a column, repeat the value on every row instead and use row grouping or an autofilter to give the reader the same visual grouping without damaging the data.
Style the sheet, not the cells
The most maintainable styling code operates on ranges and columns rather than on individual cells: a header row, a money column, a border around the used range. That keeps the workbook's style table small, makes the intent obvious to the next reader of the code, and means a change to the house style is an edit to a handful of constants rather than a search through nested loops.
Consistency beats cleverness
The styling that makes a report feel professional is unremarkable: one header treatment, one money format, sensible widths, a frozen header. Applied identically across every sheet and every report a team produces, that consistency does more than any individual flourish — readers stop noticing the presentation and start reading the numbers, which is the point. Collecting those decisions into a single finishing function, imported by every report, is what makes the consistency survive contact with a deadline.
Fail where the cause is
The most useful place for a check is as close as possible to the thing that can go wrong: the sheet name at the read, the column list before the transform, the row count before the write, the file size before delivery. Each of those turns a confusing downstream error into a message naming the actual problem. Checks placed late still catch the failure, but they describe a symptom — and a symptom three stages from its cause is what makes a simple mistake take an afternoon.
Styling is where reports become maintainable or not
The difference between a report that can be restyled in an afternoon and one that cannot is entirely structural: whether the fonts, fills and formats live in a handful of named constants applied by one function, or are constructed inline wherever a cell happened to need them. The second version works and cannot be changed, because the house style is spread across every report the team owns.
Collecting them costs nothing at the time and is the single highest-leverage refactor available in a growing reporting codebase.
Key takeaways
- Styling in openpyxl comes down to four immutable objects —
Font,PatternFill,Border/Side, andAlignment— each assigned to the cell attribute of the same name. - The objects are immutable: to change one attribute you build a new object, carrying over any old values you want to keep.
PatternFillneeds an explicitfill_type("solid"); colours are ARGB hex strings, 6 or 8 digits.- Build style objects once, outside your loops, then apply them across rows or ranges — including a two-fill trick for zebra striping.
- Merge a range with
ws.merge_cells()for a title bar, and set the value and styling on the top-left cell only. - Reach for a
NamedStylewhen the same combination repeats; it keeps the file smaller and shows up in Excel's own style gallery. - Because openpyxl writes straight to the XML, the styled result reopens exactly as saved — with none of the formatting loss a plain pandas export gives you.
Frequently asked questions
Why does my PatternFill show up as blank?
You almost certainly omitted fill_type. A PatternFill with no type renders nothing. Use PatternFill("solid", fgColor="4472C4") or PatternFill(fill_type="solid", fgColor="4472C4").
Can I modify just one attribute of an existing Font?
No — Font, Fill, Border, and Alignment are immutable. Build a new object with the attributes you want and assign it. To carry over existing values, read them off the old object first: Font(name=cell.font.name, bold=True).
Why did my value disappear after merging cells? Only the top-left cell of a merged range keeps its value; the rest are cleared. Set the value and styling on that top-left cell, and merge before writing if order matters.
Do styles applied with openpyxl survive a round-trip through pandas?
No. pandas.to_excel() writes plain cells and ignores prior formatting. Apply openpyxl styles as the last step, after any pandas write, so nothing overwrites them.
What color format does openpyxl expect?
An ARGB hex string. Pass 8 digits ("FF4472C4") for explicit opacity, or 6 digits ("4472C4") and openpyxl treats it as fully opaque.
Related
- Formatting and Charting Excel Reports with Python — the parent guide tying styling, charts, number formats, and images together.
- Set Column Width and Row Height in openpyxl — size columns and rows so styled content fits.
- Freeze the Header Row in Excel with openpyxl — keep your styled header visible while scrolling.
- Creating Charts in Excel with openpyxl — turn the styled table into a chart.
- Applying Number and Date Formats in Excel — format the values inside your styled cells.
- Applying Conditional Formatting with openpyxl — let the data drive cell colors automatically.