Highlight Cells Above a Threshold with openpyxl
A report with four hundred rows and one number that needs attention is a report nobody reads carefully. Conditional formatting solves that by making the exceptions visible without changing any values — and unlike a static fill, the rule stays live, so a cell that later falls below the threshold loses its highlight automatically. This guide covers fixed thresholds, thresholds that live in a cell so readers can change them, whole-row highlighting, and what happens when rules overlap. It extends Applying Conditional Formatting with openpyxl.
Prerequisites
pip install openpyxl pandas
A sheet to format:
import pandas as pd
pd.DataFrame({
"region": ["North", "South", "West", "East", "Central"],
"revenue": [5150.00, 4268.50, 3511.25, 2980.10, 6402.75],
"target": [4500.00, 4500.00, 3000.00, 3500.00, 5000.00],
}).to_excel("report.xlsx", index=False, startrow=1)
Note startrow=1, leaving row 1 free for the threshold cell.
Step 1 — A fixed threshold with CellIsRule
CellIsRule covers the common comparisons against a constant:
from openpyxl import load_workbook
from openpyxl.formatting.rule import CellIsRule
from openpyxl.styles import PatternFill, Font
wb = load_workbook("report.xlsx")
ws = wb.active
above = PatternFill(start_color="D9F4F1", end_color="D9F4F1", fill_type="solid")
above_font = Font(color="0B6157", bold=True)
ws.conditional_formatting.add(
"B3:B7",
CellIsRule(operator="greaterThan", formula=["4000"],
fill=above, font=above_font),
)
wb.save("report_formatted.xlsx")
Two details trip people up. The formula argument is a list of strings, even for a single number — ["4000"], not 4000. And the fill must be a solid PatternFill with both colours set; a fill with only start_color renders as nothing in some Excel versions.
The operators available:
| Operator | Meaning |
|---|---|
greaterThan | strictly above |
greaterThanOrEqual | at or above |
lessThan / lessThanOrEqual | below / at or below |
between / notBetween | two values in formula |
equal / notEqual | exact match |
ws.conditional_formatting.add(
"B3:B7",
CellIsRule(operator="between", formula=["3000", "4000"],
fill=PatternFill("solid", start_color="FDEFD8",
end_color="FDEFD8")),
)
Step 2 — A threshold readers can change
Hard-coding 4000 means regenerating the report whenever somebody wants a different cut-off. FormulaRule with an absolute reference lets the threshold live in a cell:
from openpyxl import load_workbook
from openpyxl.formatting.rule import FormulaRule
from openpyxl.styles import PatternFill, Font
wb = load_workbook("report.xlsx")
ws = wb.active
ws["A1"] = "Threshold"
ws["B1"] = 4000
ws["B1"].number_format = "#,##0.00"
ws["A1"].font = Font(bold=True)
ws.conditional_formatting.add(
"B3:B7",
FormulaRule(
formula=["AND(B3<>\"\", B3>$B$1)"],
fill=PatternFill("solid", start_color="D9F4F1", end_color="D9F4F1"),
font=Font(color="0B6157", bold=True),
),
)
wb.save("report_dynamic.xlsx")
The reference style is the whole trick. $B$1 is absolute, so every cell in the range compares against that one threshold cell. B3 is relative and refers to the top-left cell of the range — Excel slides it down as it evaluates each row, so writing B3 gives you "this row's revenue".
The AND(B3<>"", ...) guard matters: without it, blank cells compare as zero and the rule fires or does not fire on cells that hold nothing, which looks like a bug to a reader.
Step 3 — Highlight the whole row
Tinting one cell tells the reader which value is high; tinting the row tells them which record is. The difference is one anchor.
from openpyxl import load_workbook
from openpyxl.formatting.rule import FormulaRule
from openpyxl.styles import PatternFill
wb = load_workbook("report.xlsx")
ws = wb.active
ws["A1"], ws["B1"] = "Threshold", 4000
# Apply across every column of the data, testing column B in each row.
ws.conditional_formatting.add(
"A3:C7",
FormulaRule(
formula=['AND($B3<>"", $B3>$B$1)'],
fill=PatternFill("solid", start_color="D9F4F1", end_color="D9F4F1"),
),
)
wb.save("report_rows.xlsx")
$B3 — column anchored, row relative — is the pattern to remember. It is the single most useful reference form in conditional formatting, and getting it wrong is why a whole-row rule so often highlights a diagonal.
Comparing against another column rather than a fixed cell is the same shape, and is often more useful than an absolute threshold:
# Highlight rows where revenue beat the row's own target.
ws.conditional_formatting.add(
"A3:C7",
FormulaRule(formula=["$B3>$C3"],
fill=PatternFill("solid", start_color="D9F4F1",
end_color="D9F4F1")),
)
Step 4 — Order overlapping rules
Rules are evaluated in the order they were added, and several can apply to one cell — their formats merge, which produces muddled results when they conflict. Add the most specific first and stop evaluation:
from openpyxl.formatting.rule import CellIsRule
from openpyxl.styles import PatternFill
RED = PatternFill("solid", start_color="FEE8F2", end_color="FEE8F2")
AMBER = PatternFill("solid", start_color="FDEFD8", end_color="FDEFD8")
GREEN = PatternFill("solid", start_color="D9F4F1", end_color="D9F4F1")
critical = CellIsRule(operator="greaterThan", formula=["6000"], fill=RED)
critical.stopIfTrue = True
ws.conditional_formatting.add("B3:B7", critical)
warn = CellIsRule(operator="greaterThan", formula=["4000"], fill=AMBER)
warn.stopIfTrue = True
ws.conditional_formatting.add("B3:B7", warn)
ws.conditional_formatting.add(
"B3:B7", CellIsRule(operator="lessThanOrEqual", formula=["4000"], fill=GREEN)
)
Without stopIfTrue, a value of 6,500 matches both the critical and the warning rule, and Excel merges the two formats — usually giving you the first rule's fill with the second's font, which is neither. The banding is covered further in adding data bars and colour scales with openpyxl.
Step 5 — Apply it after a pandas write
to_excel replaces the sheet, so conditional formatting applied first is discarded. Either format after writing with openpyxl, or add the rule through the xlsxwriter engine while the writer is open:
import pandas as pd
df = pd.DataFrame({
"region": ["North", "South", "West", "East", "Central"],
"revenue": [5150.00, 4268.50, 3511.25, 2980.10, 6402.75],
})
with pd.ExcelWriter("report.xlsx", engine="xlsxwriter") as writer:
df.to_excel(writer, sheet_name="Report", index=False, startrow=1)
book, sheet = writer.book, writer.sheets["Report"]
sheet.write(0, 0, "Threshold")
sheet.write_number(0, 1, 4000, book.add_format({"num_format": "#,##0.00"}))
high = book.add_format({"bg_color": "#D9F4F1", "font_color": "#0B6157",
"bold": True})
sheet.conditional_format(
2, 1, len(df) + 1, 1,
{"type": "formula", "criteria": '=AND($B3<>"", $B3>$B$1)',
"format": high},
)
sheet.set_column("A:A", 14)
sheet.set_column("B:B", 14,
book.add_format({"num_format": "#,##0.00"}))
xlsxwriter's conditional_format takes zero-based row and column bounds, where openpyxl takes an A1 range string — an easy source of off-by-one errors when porting between the two.
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
TypeError on the rule | formula given as a number | Pass a list of strings: ["4000"]. |
| Rule added but nothing highlights | Fill missing end_color | Use a solid fill with both colours set. |
| Whole-row rule highlights a diagonal | Fully relative reference | Anchor the column: $B3. |
| Every row highlights identically | Fully absolute reference | Leave the row relative. |
| Blank cells highlighted | Blanks compare as zero | Add an AND(cell<>"", ...) guard. |
| Two rules produce a muddled format | Both matched and merged | Set stopIfTrue on the more specific rule. |
| Formatting gone after the job runs | to_excel replaced the sheet | Apply the rules after writing. |
| Off-by-one in the range | xlsxwriter is zero-based, openpyxl is A1 | Check which API you are using. |
Performance and scale notes
A conditional format is a single rule object covering a range, so it costs the same whether the range is ten rows or a hundred thousand — unlike per-cell fills, which create a style entry each and can approach Excel's ceiling of roughly 64,000 distinct formats.
That makes the comparison stark:
from openpyxl.styles import PatternFill
# Expensive: one style per cell, and it goes stale on the first edit.
tint = PatternFill("solid", start_color="D9F4F1", end_color="D9F4F1")
for (cell,) in ws.iter_rows(min_row=3, max_row=100_003, min_col=2, max_col=2):
if cell.value and cell.value > 4000:
cell.fill = tint
# Cheap: one rule object, and it stays live.
ws.conditional_formatting.add(
"B3:B100003",
CellIsRule(operator="greaterThan", formula=["4000"], fill=tint),
)
Two further habits. Use one rule over one range rather than many small ranges — Excel evaluates each rule over each of its ranges, and a hundred single-row rules is a hundred times the work of one covering all hundred rows. And avoid volatile functions in the formula: INDIRECT, OFFSET, TODAY and NOW force re-evaluation on every recalculation, which makes a large sheet sluggish. Compute the value in Python and write it to a cell the rule references instead.
The static-fill approach retains one advantage worth noting: it survives a conversion to PDF or CSV, where conditional formatting does not always render. For a report destined for PDF export, verify the highlighting appears in the output, and fall back to static fills for that specific artefact while keeping live rules in the workbook readers open.
Conclusion
Conditional formatting makes exceptions visible without touching the data, and it stays correct when the data changes — which a static fill does not. Use CellIsRule for a fixed comparison, FormulaRule with an absolute $B$1 reference when the threshold should live in a cell readers can edit, and $B3 — column anchored, row relative — when the whole row should highlight. Order overlapping rules from most to least specific and set stopIfTrue, guard against blank cells comparing as zero, and always apply the rules after the data has been written.
Frequently asked questions
What is the difference between a conditional format and just setting a fill? A conditional format is a live rule Excel re-evaluates whenever the data changes, so a cell that later drops below the threshold loses its highlight. A static fill is baked in and stays wrong the moment somebody edits a value.
How do I reference a threshold stored in a cell?
Use FormulaRule with an absolute reference such as $B$1. Keep the row and column anchored so every cell in the range compares against the same threshold cell rather than a shifting one.
Why does my whole-row rule highlight only one column?
The formula uses a fully relative or fully absolute reference. Anchor the column and leave the row relative — $B3 — so the rule slides down the rows while always testing column B.
Which rule wins when two overlap?
The one added first, unless a rule sets stopIfTrue. Order your rules from most specific to least specific and set stopIfTrue on the ones that should end evaluation.
Do conditional formats survive a pandas write?
No. to_excel replaces the sheet, so apply conditional formatting after all data has been written, or add it through the xlsxwriter engine while the writer is open.
Related
- Up to the parent: Applying Conditional Formatting with openpyxl — the wider rule vocabulary.
- openpyxl: Apply Conditional Formatting to a Range — the range mechanics in depth.
- Add Data Bars and Colour Scales with openpyxl — graduated alternatives to a hard threshold.
- Highlight Invalid Cells in Excel with Python — the same technique aimed at data quality.
- Apply Conditional Formatting with xlsxwriter — the other engine's API.