Applying Conditional Formatting With openpyxl
Conditional formatting lets a workbook explain itself: negative numbers turn red, top performers turn green, flagged rows stand out — all evaluated by Excel when the file opens, with no macros. openpyxl writes these rules straight into the worksheet XML, so you can attach them to a report generated by an Advanced Data Transformation and Cleaning pipeline.
Unlike the static number and date formats you set once per cell, a conditional rule is re-evaluated by Excel every time a value changes — so the highlighting stays correct as the underlying data is edited. This page covers the three rule types you will use most and the openpyxl 3.1 API details that cause silent failures. Every block runs in order against a sample workbook built in the first step.
Install and create a sample workbook
pip install openpyxl
Build a small worksheet with a header row and numeric scores so the rules below have something to color:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "Scores"
ws.append(["Name", "Score", "Status", "Balance"])
rows = [
["Alvarez", 92, "Active", 1200],
["Boateng", 68, "Pending", -75],
["Chen", 81, "Active", 450],
["Dubois", 55, "Pending", 980],
["Eriksen", 99, "Active", -10],
]
for r in rows:
ws.append(r)
wb.save("formatted_report.xlsx")
print("Sample workbook created")
How a rule is built and attached
Every rule follows the same shape:
- Build the style objects you want applied (
PatternFill,Font). - Construct a rule (
CellIsRule,ColorScaleRule, orFormulaRule) describing the condition. - Register it with
ws.conditional_formatting.add(range_string, rule). - Save the workbook.
The range is always an A1-style string such as "B2:B6" (or space-separated for non-contiguous ranges, e.g. "B2:B6 D2:D6"). Named ranges are not accepted here — use explicit coordinates.
The three rule types differ in what they compare and how the result reads. Match the type to the decision you are making:
CellIsRule: compare against a value
CellIsRule compares each cell against one or two literals. Highlight failing scores (below 70) in red:
from openpyxl.formatting.rule import CellIsRule
from openpyxl.styles import PatternFill, Font
red_fill = PatternFill("solid", fgColor="FFC7CE")
red_font = Font(color="9C0006")
fail_rule = CellIsRule(
operator="lessThan",
formula=["70"], # always a list of strings, even for one value
fill=red_fill,
font=red_font,
)
ws.conditional_formatting.add("B2:B6", fail_rule)
wb.save("formatted_report.xlsx")
print("CellIsRule applied")
The formula argument is always a list of strings. The between operator takes two: formula=["70", "89"]. To match a text literal, wrap it in double quotes inside the string: formula=['"ERROR"'].
Stacking tiers and rule priority
You can attach several rules to the same range to build performance tiers. openpyxl assigns priorities in insertion order; Excel evaluates lowest priority number first. Note that openpyxl rule constructors do not accept a priority keyword — set the attribute on the rule object after constructing it.
green = PatternFill("solid", fgColor="C6EFCE")
yellow = PatternFill("solid", fgColor="FFEB9C")
high = CellIsRule(operator="greaterThanOrEqual", formula=["90"], fill=green)
mid = CellIsRule(operator="between", formula=["70", "89"], fill=yellow)
# Set priority explicitly when ordering matters across many rules
high.priority = 1
mid.priority = 2
ws.conditional_formatting.add("B2:B6", high)
ws.conditional_formatting.add("B2:B6", mid)
wb.save("formatted_report.xlsx")
print("Tiered rules applied")
To stop Excel from applying lower-priority rules once one matches a cell, set stopIfTrue=True on the rule.
ColorScaleRule: a gradient across the range
For at-a-glance magnitude, a two- or three-color scale beats discrete tiers. Colors are 8-digit ARGB strings (the leading FF is full opacity):
from openpyxl.formatting.rule import ColorScaleRule
scale = ColorScaleRule(
start_type="min", start_color="FFF8696B", # red at the low end
mid_type="percentile", mid_value=50, mid_color="FFFFEB84",
end_type="max", end_color="FF63BE7B", # green at the high end
)
ws.conditional_formatting.add("B2:B6", scale)
wb.save("formatted_report.xlsx")
print("Color scale applied")
FormulaRule: cross-column, row-level logic
When the condition depends on other columns, use FormulaRule. The formula is an Excel expression with no leading = — openpyxl writes the string verbatim and Excel rejects a formula starting with =. Use absolute column references and a relative row matching the first row of the range so the rule shifts down correctly:
from openpyxl.formatting.rule import FormulaRule
blue = PatternFill("solid", fgColor="DDEBF7")
# Highlight the whole row when Status is "Pending" and Balance is positive
pending_rule = FormulaRule(
formula=['AND($C2="Pending", $D2>0)'], # no leading "="
fill=blue,
)
ws.conditional_formatting.add("A2:D6", pending_rule)
wb.save("formatted_report.xlsx")
print("FormulaRule applied")
The single-leaf page Apply Conditional Formatting to a Range in openpyxl drills into range syntax and the CellIsRule-to-FormulaRule fallback.
Re-running a script: clearing old rules
Re-running a generator on an existing workbook stacks duplicate rules. ConditionalFormattingList has no clear() method — reassign a fresh instance to wipe the sheet's rules before re-adding:
from openpyxl.formatting.formatting import ConditionalFormattingList
ws.conditional_formatting = ConditionalFormattingList()
ws.conditional_formatting.add("B2:B6", fail_rule)
wb.save("formatted_report.xlsx")
print("Rules reset and reapplied")
Computing the range after writing data
When the row count is dynamic, build the range from ws.max_row so the rule covers exactly the data — no off-by-one that excludes the last row or formats empty cells:
from openpyxl.utils import get_column_letter
last_col = get_column_letter(ws.max_column) # "D"
target = f"A2:{last_col}{ws.max_row}"
print("Computed range:", target)
ws.conditional_formatting.add(target, pending_rule)
wb.save("formatted_report.xlsx")
Inspecting attached rules
Iterate the collection to confirm rules landed before saving. No output means nothing was attached:
for cf in ws.conditional_formatting:
print(cf.sqref, "->", len(cf.rules), "rule(s)")
API quirks to remember
- Rule constructors (
CellIsRule,ColorScaleRule,FormulaRule) do not take aprioritykwarg — setrule.priorityafterward. FormulaRuleformulas have no leading=.ConditionalFormattingListhas noclear()— reassign a freshConditionalFormattingList().PatternFillneedsfill_type="solid"(or the positional"solid") or it renders invisibly.- Excel caps conditional formatting at 64 rules per worksheet; consolidate overlapping logic into a single
FormulaRulewhere you can. - openpyxl writes valid XML but does not render it — open the file in Excel to confirm the visual result.
ColorScaleRulecolor strings are 8-digit ARGB; prefix"FF"to any 6-digit HTML color for full opacity (e.g."4472C4"→"FF4472C4").CellIsRulewithoperator="equal"matching a string requires the value wrapped in double-quotes inside the string:formula=['"ERROR"'], notformula=["ERROR"].
Data bars, icon sets and when each reads best
Colour is not the only signal available. openpyxl exposes the same three visual rule families Excel does, and each answers a different question:
from openpyxl import load_workbook
from openpyxl.formatting.rule import DataBarRule, IconSetRule
wb = load_workbook("orders.xlsx")
ws = wb["Orders"]
last = ws.max_row
ws.conditional_formatting.add(
f"D2:D{last}",
DataBarRule(start_type="min", end_type="max", color="4338CA", showValue=True),
)
ws.conditional_formatting.add(
f"E2:E{last}",
IconSetRule("3TrafficLights1", "percent", [0, 33, 67], showValue=True),
)
wb.save("orders_visual.xlsx")
showValue=True on a data bar keeps the number visible behind the bar, which is nearly always what
a report wants — a bar alone forces the reader to estimate. Icon sets earn their place for status
columns where three states are genuinely meaningful, and they become noise the moment they are
applied to a column of continuous values, where a colour scale says more with less.
Rules that reference other columns
The most useful rules are relational: highlight a row when its actual is below its target, not when
a number crosses a fixed line. FormulaRule handles that, and the anchoring is what makes it work
across the whole range:
from openpyxl import load_workbook
from openpyxl.formatting.rule import FormulaRule
from openpyxl.styles import PatternFill
wb = load_workbook("orders.xlsx")
ws = wb["Orders"]
last = ws.max_row
below_target = PatternFill("solid", start_color="FFC7CE")
ws.conditional_formatting.add(
f"A2:F{last}", # the whole row is highlighted
FormulaRule(formula=[f"$D2<$E2"], fill=below_target, stopIfTrue=False),
)
wb.save("orders_row_rules.xlsx")
The formula is written as if for the top-left cell of the range, and Excel applies it relatively to
every other cell — so $D2<$E2 keeps the columns pinned while the row number moves. Getting this
backwards is the classic conditional-formatting bug: with D$2<E$2 every row is compared against
row two, and the result looks almost right.
Rules cost something on a large sheet
Each rule is a small piece of XML, but the count matters. One rule over A2:F100000 is cheap; a
hundred thousand rules over single cells makes a workbook slow to open and painful to maintain in
Excel's own rule manager. Applying a rule to a range rather than to individual cells is the single
most important habit here, and it produces identical output.
The second cost is recalculation. A rule whose formula references another sheet, or uses a volatile
function such as TODAY(), is re-evaluated far more often than one comparing two columns on the
same row. Where a date threshold is needed, writing the cut-off into a cell once and referencing it
is much cheaper than embedding TODAY() in a rule that covers a hundred thousand rows.
Clearing rules before re-applying them
Conditional formatting accumulates. A monthly job that adds its rules to a workbook that already has last month's ends up with duplicates, and Excel evaluates all of them:
from openpyxl import load_workbook
from openpyxl.formatting.formatting import ConditionalFormattingList
wb = load_workbook("orders_row_rules.xlsx")
ws = wb["Orders"]
print("before:", sum(len(r.rules) for r in ws.conditional_formatting))
ws.conditional_formatting = ConditionalFormattingList() # start clean
print("after:", sum(len(r.rules) for r in ws.conditional_formatting))
Resetting the whole collection is the blunt version and is right when your job owns the sheet. Where the workbook is someone's template and carries rules you did not add, remove only your own ranges by matching on the range string — and log what you removed, because a rule that quietly disappears from a template is much harder to diagnose than one that was never there.
Highlighting the row, not just the cell
Readers scan rows. A rule that colours a single cell answers "which value is wrong?" while a rule across the whole row answers "which record needs attention?", and the second is usually the question a report is asked:
from openpyxl import load_workbook
from openpyxl.formatting.rule import FormulaRule
from openpyxl.styles import PatternFill, Font
wb = load_workbook("orders.xlsx")
ws = wb["Orders"]
last = ws.max_row
late = PatternFill("solid", start_color="FFEB9C")
ws.conditional_formatting.add(
f"A2:F{last}",
FormulaRule(formula=['$F2="Late"'], fill=late),
)
missing = PatternFill("solid", start_color="FFC7CE")
ws.conditional_formatting.add(
f"A2:F{last}",
FormulaRule(formula=['COUNTBLANK($A2:$F2)>0'], fill=missing, stopIfTrue=True),
)
wb.save("orders_rows.xlsx")
stopIfTrue=True on the second rule matters when rules overlap: a row that is both late and
incomplete should show one colour rather than whichever the evaluation order happened to leave on
top. Ordering the rules from most to least severe, with stopIfTrue on the ones that should win,
makes the result predictable.
Note the mixed anchoring again — $F2 pins the column and lets the row move, which is what applies
one rule consistently down a range. A rule written with $F$2 compares every row against row two,
and a rule written with F2 drifts sideways as well as down.
Formatting that survives a monthly refresh
A recurring report usually rewrites the data into a workbook that already carries formatting from last month. Two things go wrong: the rules cover the old row count, and they accumulate. Rebuilding the rules as part of the job fixes both:
from openpyxl import load_workbook
from openpyxl.formatting.formatting import ConditionalFormattingList
from openpyxl.formatting.rule import CellIsRule
from openpyxl.styles import PatternFill
from openpyxl.utils import get_column_letter
def apply_rules(ws):
ws.conditional_formatting = ConditionalFormattingList() # drop last month's
last_row = ws.max_row
last_col = get_column_letter(ws.max_column)
ws.conditional_formatting.add(
f"D2:D{last_row}",
CellIsRule(operator="lessThan", formula=["0"],
fill=PatternFill("solid", start_color="FFC7CE")),
)
ws.conditional_formatting.add(
f"A2:{last_col}{last_row}",
CellIsRule(operator="equal", formula=['"Cancelled"'],
fill=PatternFill("solid", start_color="F0F2F5")),
)
return last_row
wb = load_workbook("monthly.xlsx")
rows = apply_rules(wb["Orders"])
wb.save("monthly_formatted.xlsx")
print("rules applied over", rows, "row(s)")
Deriving every range from ws.max_row each run is what keeps the formatting in step with the data.
The alternative — a rule written once against D2:D500 — silently stops covering the sheet the month
it grows past five hundred rows, and nothing about the file looks wrong.
Conditional formatting versus writing the colour directly
Both approaches produce a coloured cell, and they differ in what happens next:
| Conditional formatting | Direct PatternFill | |
|---|---|---|
| Reacts to edits | Yes — recalculates live | No — fixed at write time |
| Rule visible to the reader | Yes, in Excel's rule manager | No |
| Depends on Excel evaluating a formula | Yes | No |
| Suits a rule Python decided | Poorly | Well |
| Cost on a large sheet | One rule per range | One style per cell |
The dividing line is where the decision was made. If Python looked up a master list and concluded that a region is invalid, that judgement cannot be expressed as a spreadsheet formula, so a direct fill plus a comment is the honest representation — which is what highlighting invalid cells does. If the rule is simply "below zero", conditional formatting is better, because it keeps responding as the reader edits.
Checking the rules landed
Formatting is invisible to a data check, so verify it explicitly rather than opening the file to look:
from openpyxl import load_workbook
wb = load_workbook("monthly_formatted.xlsx")
ws = wb["Orders"]
for rng in ws.conditional_formatting:
for rule in rng.rules:
print(f"{rng.sqref}: {rule.type} {getattr(rule, 'operator', '')} {rule.formula}")
Printing the inventory catches the two failures that otherwise reach a reader: a rule whose range stopped short of the data, and a rule that was added twice with slightly different bounds. Both are one line to spot here and a confusing email to diagnose later.
Colour choices that survive printing and colour blindness
A rule is only useful if its meaning reaches the reader. Excel's own "Bad", "Neutral" and "Good"
pairs — FFC7CE/9C0006, FFEB9C/5A4A00 and C6EFCE/0B3D1A — are worth defaulting to for
three practical reasons: they are familiar, they carry a matching font colour so the text stays
legible, and they survive greyscale printing as distinguishable shades.
Red and green alone are the pairing to avoid, because the most common form of colour blindness makes them nearly identical. Where a two-state rule must be unmistakable, pair the colour with something else — a symbol in an adjacent column, a bold font, or an icon set — so the signal does not depend on hue alone. That redundancy costs one extra rule and makes the report readable to everyone who receives it.
Formatting the summary, not the detail
A hundred thousand highlighted cells communicate nothing — the eye needs contrast, and a sheet where a third of the rows are coloured reads as noise. The technique that keeps highlighting meaningful is to apply it where the reader is looking: a small summary block with a handful of coloured cells, and a detail sheet left plain for filtering.
from openpyxl import load_workbook
from openpyxl.formatting.rule import CellIsRule
from openpyxl.styles import PatternFill
wb = load_workbook("report.xlsx")
summary = wb["Summary"]
summary.conditional_formatting.add(
f"C2:C{summary.max_row}",
CellIsRule(operator="lessThan", formula=["0"],
fill=PatternFill("solid", start_color="FFC7CE")),
)
summary.conditional_formatting.add(
f"C2:C{summary.max_row}",
CellIsRule(operator="greaterThanOrEqual", formula=["0.1"],
fill=PatternFill("solid", start_color="C6EFCE")),
)
wb.save("report_summary_formatted.xlsx")
Two rules over a dozen rows carry more meaning than ten thousand cells of graded colour, and the workbook stays fast. Where the detail sheet genuinely needs a signal, a single data bar on one column is usually enough — it conveys magnitude without asking the reader to decode a palette.
The same restraint applies to the number of distinct colours. Three states is about the limit for something a reader interprets without a legend; beyond that, add the legend or reconsider whether the distinction belongs in colour at all.
Frequently asked questions
Why doesn't passing priority=1 to CellIsRule work?
The rule constructors don't accept a priority keyword and will raise a TypeError. Build the rule first, then set the attribute on the object: rule.priority = 1.
Why does my FormulaRule show no effect or break the file?
The formula must be a plain Excel expression with no leading = — openpyxl writes the string verbatim and Excel rejects a formula that starts with =. Use 'AND($C2="Pending", $D2>0)', not '=AND(...)'.
Why is my PatternFill invisible in Excel?
A PatternFill needs fill_type="solid" (or the positional "solid") together with a color; without the fill type it defaults to None and renders nothing.
Why do my rules pile up every time I re-run the script?add() appends, and ConditionalFormattingList has no clear() method. Reassign a fresh ws.conditional_formatting = ConditionalFormattingList() before re-adding to wipe the sheet's existing rules.
Is there a limit to how many rules I can add?
Excel caps conditional formatting at 64 rules per worksheet. Consolidate overlapping logic into a single FormulaRule where you can rather than stacking many narrow rules.
Conclusion
Conditional formatting in openpyxl comes down to three moves: build the style, construct the right rule type, and register it against an A1 range. Reach for CellIsRule when a single column is compared to fixed thresholds, ColorScaleRule when you want a gradient that reads magnitude at a glance, and FormulaRule when the decision depends on other columns in the same row. The failures people hit are almost always API-shape issues rather than logic: priority set as an attribute not a keyword, a FormulaRule with no leading =, a PatternFill missing its "solid" fill type, and rules stacking on re-run. Keep the range dynamic off ws.max_row, stay under Excel's 64-rule cap, and open the saved file in Excel to confirm the visual result — openpyxl writes the XML but never renders it.
Where to go next
- Up to the section overview: Advanced Data Transformation and Cleaning.
- Apply Conditional Formatting to a Range in openpyxl — focused range recipe and troubleshooting.
- Prepare the data first with Cleaning Excel Data with Pandas.
- Combine the static styling: Styling Excel Cells with openpyxl.
- For workbook fundamentals, see Using openpyxl for Excel File Manipulation.