Highlight Values Outside a Tolerance of Target with openpyxl
Many operational reports compare an actual figure with a target that differs row by row: each machine's output against its rated capacity, each cost centre's spend against its budget, each measurement against its specification. What the reader needs is not "high" or "low" but "within tolerance or not": a line 2% under target is fine, one 12% under is not. Conditional formatting rules that compare cells with fixed thresholds cannot express that, but formula-based rules can, because each row's rule can look at that row's own target. This page builds a target report with openpyxl whose actual values turn green within tolerance, amber near the limit and red beyond it, with the tolerance held in a cell readers can change, an absolute-tolerance variant, per-row tolerances, safe handling of zero targets, and a recalculation check that the rules flag exactly the rows a pandas calculation says they should. It belongs to Applying Conditional Formatting with openpyxl.
Prerequisites
pip install openpyxl pandas
LibreOffice (soffice) recalculates the workbook for the check in Step 4. The example is a production report: each line's output for the week against its target, with a default tolerance of 5%.
Step 1 — The report with a tolerance setting
Put the tolerance in a cell with a name, so the rules can refer to it and a reader can change it:
import pandas as pd
from openpyxl import Workbook
from openpyxl.workbook.defined_name import DefinedName
lines = pd.DataFrame({"Line": ["L1", "L2", "L3", "L4", "L5", "L6", "L7", "L8"],
"Target": [1200, 950, 1500, 800, 0, 1100, 640, 2000],
"Actual": [1176, 1010, 1320, 812, 15, 1046, 702, 1990],
"Line tolerance": [None, None, None, 0.02, None, None, 0.15, None]})
wb = Workbook()
ws = wb.active
ws.title = "Output"
ws["A1"], ws["B1"] = "Tolerance", 0.05
ws["B1"].number_format = "0%"
wb.defined_names["Tolerance"] = DefinedName("Tolerance", attr_text="Output!$B$1")
ws.append([])
ws.append(list(lines.columns) + ["Variance %"])
for r, row in enumerate(lines.itertuples(index=False), start=4):
ws.append([row.Line, row.Target, row.Actual, row._3])
ws[f"E{r}"] = f'=IF(B{r}=0,"",C{r}/B{r}-1)'
ws[f"E{r}"].number_format = "+0.0%;-0.0%;0.0%"
ws[f"D{r}"].number_format = "0%"
last = ws.max_row
print(f"data in rows 4–{last}; tolerance cell B1 named 'Tolerance'")
The tolerance lives in B1 and is named Tolerance, so rules can use the name rather than $B$1 and keep working if someone inserts a row above the table. A variance column shows each line's percentage difference from target, guarded so a zero target gives an empty cell rather than #DIV/0!. Two lines carry their own tolerance in column D — L4 is a precision line held to 2%, L7 a new line allowed 15% — and the rest fall back to the sheet-wide 5%.
Step 2 — The tolerance rules
Each rule is a formula for the first data row; Excel shifts it down for every other row. The effective tolerance is the row's own value if it has one, otherwise the named default:
from openpyxl.formatting.rule import FormulaRule
from openpyxl.styles import Font, PatternFill
TOL = "IF(ISNUMBER($D4),$D4,Tolerance)"
DEV = "ABS($C4-$B4)/$B4"
rules = [
("zero target", f"AND($B4=0,$C4<>0)", "D9D9D9", "404040"),
("outside", f"AND($B4<>0,{DEV}>{TOL})", "FFC7CE", "9C0006"),
("near limit", f"AND($B4<>0,{DEV}>{TOL}*0.6)", "FFEB9C", "7F6000"),
("within", f"AND($B4<>0,{DEV}<={TOL}*0.6)", "C6EFCE", "006100"),
]
area = f"C4:C{last}"
for name, formula, fill, font in rules:
ws.conditional_formatting.add(area, FormulaRule(formula=[formula], fill=PatternFill("solid", fgColor=fill),
font=Font(color=font), stopIfTrue=True))
print("\n".join(f"{n:<12} {f}" for n, f, _, _ in rules))
The rules are evaluated in order, and stopIfTrue makes the first match win, so they can be written from the most specific to the least. A zero target gets its own grey rule — any output on a line that was meant to be idle is worth a look, and a percentage deviation from zero is meaningless. Outside tolerance is red. The amber band starts at 60% of the tolerance, warning that a line is drifting before it fails; within that, green. Columns are locked with $ and rows left relative, so every row compares its own actual, target and tolerance. The rule order and stopIfTrue behaviour are covered in Control Conditional Formatting Rule Order and Stop If True.
Step 3 — An absolute tolerance instead
Percentages are right for most targets, but not for small ones: 5% of a target of 40 is two units, which may be within measurement noise. An absolute tolerance — "within 25 units" — is a different expression of the same idea. Add it as a second sheet so both can be compared:
abs_ws = wb.create_sheet("Absolute")
abs_ws["A1"], abs_ws["B1"] = "Tolerance (units)", 25
wb.defined_names["UnitTolerance"] = DefinedName("UnitTolerance", attr_text="Absolute!$B$1")
abs_ws.append([])
abs_ws.append(["Line", "Target", "Actual"])
for row in lines.itertuples(index=False):
abs_ws.append([row.Line, row.Target, row.Actual])
abs_ws.conditional_formatting.add(f"C4:C{abs_ws.max_row}", FormulaRule(formula=["ABS($C4-$B4)>UnitTolerance"],
fill=PatternFill("solid", fgColor="FFC7CE"), font=Font(color="9C0006")))
wb.save("output_tolerance.xlsx")
Here a line is flagged when it misses its target by more than 25 units, whatever the target's size, so L1's shortfall of 24 is fine while L2's overshoot of 60 is flagged, and the zero-target line L5 needs no special case because no division is involved. In practice the two are often combined — "within 5% or 25 units, whichever is larger" — which is one MAX() inside the rule. Comparing values against fixed thresholds instead of per-row targets is covered in Highlight Cells Above a Threshold with openpyxl.
Step 4 — Check the rules against pandas
Rules that nobody can test tend to be subtly wrong. Write the same classification as a hidden helper formula, recalculate, and compare with pandas:
import subprocess
from openpyxl import load_workbook
wb = load_workbook("output_tolerance.xlsx")
ws = wb["Output"]
for r in range(4, ws.max_row + 1):
tol, dev = TOL.replace("4", str(r)), DEV.replace("4", str(r))
ws[f"G{r}"] = (f'=IF(AND($B{r}=0,$C{r}<>0),"grey",IF($B{r}=0,"",IF({dev}>{tol},"red",'
f'IF({dev}>{tol}*0.6,"amber","green"))))')
ws.column_dimensions["G"].hidden = True
wb.save("output_tolerance.xlsx")
subprocess.run(["soffice", "--headless", "--convert-to", "xlsx", "--outdir", "calc", "output_tolerance.xlsx"], check=True, capture_output=True)
excel = [c.value for c in load_workbook("calc/output_tolerance.xlsx", data_only=True)["Output"]["G"][3:]]
tol = lines["Line tolerance"].fillna(0.05)
dev = (lines["Actual"] - lines["Target"]).abs() / lines["Target"].where(lines["Target"] != 0)
expected = ["grey" if t == 0 else "red" if d > tl else "amber" if d > tl * 0.6 else "green"
for t, d, tl in zip(lines["Target"], dev, tol)]
for line, e, x in zip(lines["Line"], expected, excel):
print(f"{line}: pandas {e:<6} Excel {x:<6} {'ok' if e == x else 'MISMATCH'}")
Every line gets the same colour from the workbook formula as from pandas: L1 at −2% and L8 at −0.5% are green; L6 at −4.9% is amber, close to its 5% limit; L2 at +6.3% and L3 at −12% are red; L4, only 1.5% over but held to a 2% tolerance, is amber; L7, 9.7% over but allowed 15%, is amber too; and L5, producing on a zero target, is grey. The helper column uses the same expressions as the rules, so if it is right, the colours are right — and it stays in the file, hidden, as documentation of what the colours mean. Change the tolerance in B1 to 10% and the colours update without touching the rules: L2, at 6.3%, would then be amber, L6 would turn green, and only L3 would stay red — a quick way to show a manager what a looser standard would mean. Keeping the classification in one place also helps when the same logic is needed elsewhere — a summary count of red lines on a dashboard is simply COUNTIF over the hidden helper column.
Common pitfalls
- Fixed thresholds for per-row targets. Use formula rules that read each row's target.
- Dividing by a zero target. Give zero targets their own rule before the percentage rules.
- Rules in the wrong order. Order from specific to general and use
stopIfTrue. - Tolerance hard-coded in rules. Put it in a named cell readers can change.
- Untested rules. Mirror them in a helper formula and compare after recalculation.
Conclusion
To highlight values by their distance from a per-row target, write formula rules on the actual column that compare ABS(actual − target) / target with the row's tolerance — its own column if set, otherwise a named default cell — and order them zero target, outside, near limit, within, each with stopIfTrue. Use an absolute tolerance where percentages mislead, and verify the logic by mirroring it in a helper formula and comparing the recalculated results with pandas.
Frequently asked questions
How do I highlight values that miss their target by more than 5%? Add a FormulaRule on the actual column with a formula such as ABS($C4-$B4)/$B4>0.05, written for the first data row; Excel applies it to each row with that row's target.
How can readers change the tolerance used by conditional formatting? Put the tolerance in a cell, give it a name, and refer to the name in the rule formulas; changing the cell updates every highlight.
How do I handle a target of zero in a percentage rule? Add a rule for zero targets first, with stopIfTrue, and include $B4<>0 in the percentage rules so they never divide by zero.
Can each row have its own tolerance? Yes. Store it in a column and use IF(ISNUMBER($D4),$D4,Tolerance) in the rules so rows without one fall back to the default.
Related
- Up one level: Applying Conditional Formatting with openpyxl — rules from Python.
- Highlight an Entire Row Based on a Cell Value with openpyxl — extending a highlight across the row.
- Build a Monthly KPI Scorecard with Targets — targets in a scorecard.
- Highlight Cells with Formula Errors Using Conditional Formatting — another formula-based rule.
More on conditional formatting
- How-toApply Conditional Formatting with xlsxwriter
- How-toAdd Data Bars and Colour Scales with openpyxl
- How-toAdd Icon Sets to Excel Cells with openpyxl
- How-toApply a Colour Scale per Row or per Column with openpyxl
- How-toApply Conditional Formatting to an Excel Table That Grows
- How-toControl Conditional Formatting Rule Order and Stop If True