Highlight Differences Between Two Columns with openpyxl
Reconciliations start with the question "where do these two columns disagree?" — the export from system A against system B, the forecast against the actual, this month's price list against last month's. A conditional format answers it inside the workbook, for every row, and keeps answering as values are corrected. The formula is one comparison, but what counts as "different" is a decision with at least four parts: letter case, blanks versus zeros, stray spaces and rounding noise. A plain <> makes those decisions for you, and not always the way a reconciliation needs. This page, part of Applying Conditional Formatting With openpyxl, shows each choice; every result below comes from rendering the workbook with LibreOffice.
Prerequisites
pip install openpyxl pandas
compare.xlsx holds seven items chosen to exercise every edge case: an exact match, a real difference, blank against zero, a difference in case only, two equal decimals, a difference in the third decimal place, and a trailing space. A second sheet holds last month's system A values.
from openpyxl import load_workbook
from openpyxl.formatting.rule import FormulaRule
from openpyxl.styles import PatternFill
RED = PatternFill("solid", bgColor="FFC7CE")
def add_rule(path, out, formula, cell_range="A2:C8", sheet="Compare"):
wb = load_workbook(path)
wb[sheet].conditional_formatting.add(cell_range, FormulaRule(formula=[formula], fill=RED))
wb.save(out)
Step 1 — Highlight rows where the columns differ
Apply the rule to the whole row range, anchoring the two compared columns with $ so every cell in a row tests the same pair:
add_rule("compare.xlsx", "diff_basic.xlsx", "$B2<>$C2")
Rows b, f and g are highlighted. Row c, a blank against a zero, is not; nor is row d, "North" against "north". Both are consequences of how spreadsheet comparisons work, not of openpyxl: = and <> ignore letter case, and an empty cell compares equal to 0 and to "". For many reports that is exactly right. For a reconciliation, it can hide real discrepancies.
Step 2 — Make case and blanks count
EXACT compares text character for character, case included. As a side effect it converts both values to text first, so a blank ("") and a zero ("0") now differ too:
add_rule("compare.xlsx", "diff_exact.xlsx", "NOT(EXACT($B2,$C2))")
Rows b, c, d, f and g are highlighted. If you want case to count but blank and zero to stay equal — or the reverse — say so explicitly. This version keeps the case-insensitive comparison but adds a check that both sides are either blank or filled:
add_rule("compare.xlsx", "diff_blanks.xlsx", "OR($B2<>$C2,ISBLANK($B2)<>ISBLANK($C2))")
Now row c is flagged — a missing value is not the same as a reported zero — while row d, which differs only in case, is not.
Step 3 — Ignore rounding noise in numbers
Two systems that store amounts at different precision disagree in the third decimal place without disagreeing about anything that matters. Compare numbers with a tolerance, and fall back to a plain comparison for text so the same rule works on mixed columns:
add_rule("compare.xlsx", "diff_tolerance.xlsx",
"IF(AND(ISNUMBER($B2),ISNUMBER($C2)),ABS($B2-$C2)>0.005,$B2<>$C2)")
Row f — 50.004 against 50.001 — is no longer flagged; row b, a whole unit apart, still is. Choose the tolerance from the business rule: half a cent for currency, a rounding unit for quantities. Keep it in a labelled cell ($H$1) rather than in the formula if people will want to change it. Fix Floating-Point Noise Like 0.1 + 0.2 ≠ 0.3 in Excel Output explains why exact comparison of computed decimals is fragile in the first place.
Step 4 — Ignore stray spaces in text
Trailing spaces are the most common false difference in text exports. TRIM both sides before comparing:
add_rule("compare.xlsx", "diff_trim.xlsx", "TRIM($B2)<>TRIM($C2)")
Row g is no longer flagged. Note that TRIM also turns numbers into text, so this rule treats blank and zero as different, like EXACT. For a column that holds only text, that is irrelevant; for mixed columns, combine the tests in one formula — IF(AND(ISNUMBER(...)), tolerance, TRIM comparison) — so each type gets the comparison it needs.
Show which side is higher
For budget against actual, or forecast against result, which way the difference goes matters as much as whether there is one. Two rules on the same range do it — one for each direction — and because each tests a different condition, their priority never conflicts:
wb = load_workbook("compare.xlsx")
ws = wb["Compare"]
num = "AND(ISNUMBER($B2),ISNUMBER($C2))"
ws.conditional_formatting.add("A2:C8", FormulaRule(formula=[f"AND({num},$C2-$B2>0.005)"],
fill=PatternFill("solid", bgColor="C6EFCE")))
ws.conditional_formatting.add("A2:C8", FormulaRule(formula=[f"AND({num},$B2-$C2>0.005)"], fill=RED))
wb.save("diff_direction.xlsx")
Row b, where system B is higher, turns green; a row where system A is higher would turn red; rows within the tolerance, and text rows, stay plain. Swap the colours to match what "good" means in your report — for costs, higher is the red one.
Step 5 — Compare with another sheet
The same technique compares a column with the same column on another sheet — this month against last month. Quote the sheet name, because it contains a space:
add_rule("compare.xlsx", "diff_vs_last_month.xlsx", "$B2<>'Last month'!$B2", cell_range="B2:B8")
Only B3 (100 against last month's 99) and B7 (50.004 against 50) are highlighted. The comparison relies on both sheets listing items in the same order; if they might not, look the previous value up by key instead — $B2<>INDEX('Last month'!$B:$B,MATCH($A2,'Last month'!$A:$A,0)) — so a reordered sheet does not produce a wall of false differences. Current Excel versions accept references to other sheets in conditional formatting formulas; versions older than Excel 2010 did not, and needed a defined name as a go-between.
Step 6 — List the same differences in pandas
Highlighting helps the person fixing the data; the job that produced the workbook should also know how many rows disagree. The same rules translate directly into pandas, with the same decisions made explicit:
import numpy as np
import pandas as pd
df = pd.read_excel("compare.xlsx", sheet_name="Compare")
a, b = df["system_a"], df["system_b"]
num = pd.to_numeric(a, errors="coerce").notna() & pd.to_numeric(b, errors="coerce").notna()
diff_num = (pd.to_numeric(a, errors="coerce") - pd.to_numeric(b, errors="coerce")).abs() > 0.005
diff_txt = (a.astype("string").str.strip().str.casefold().fillna("")
!= b.astype("string").str.strip().str.casefold().fillna(""))
df["mismatch"] = np.where(num, diff_num, diff_txt)
print(df.loc[df["mismatch"], "item"].tolist())
This version uses the tolerance for numbers and trimmed, case-folded text for everything else, so it flags b and c — the real difference and the blank-versus-zero — and nothing else. The casefold() is not optional: pandas compares strings case-sensitively, unlike the spreadsheet, and without it row d would be reported by the job while the workbook shows it as matching. Write the count to the job log and fail the run above a threshold; Compare Two Excel Files for Differences with Python builds a full cell-by-cell diff for whole workbooks.
Common pitfalls
- Unanchored columns.
B2<>C2without$compares the wrong pair in the second and third columns of a row rule. - Assuming
<>is strict. It ignores case and treats blank as zero. - Exact comparison of computed decimals. Use a tolerance.
- Comparing sorted and unsorted sheets by position. Look values up by key.
- Different rules in the workbook and in the job. If the conditional format ignores case and the pandas check does not, the job reports mismatches that readers cannot see. Keep the two definitions identical.
Performance notes
Positional comparisons are trivial for the spreadsheet to evaluate. Key-based rules with MATCH over whole columns are evaluated per cell and can slow large sheets noticeably; on tens of thousands of rows, compute the mismatch flag in pandas, write it as a column, and highlight on the flag instead.
Conclusion
A rule such as $B2<>$C2 on the row range highlights disagreements, but it ignores case and treats blanks as zero. Use EXACT when case matters, add ISBLANK tests when missing and zero must differ, compare numbers with a tolerance, TRIM text, and compare with another sheet by position or — safer — by key. Mirror the chosen rule in pandas so the job can count the mismatches it is highlighting.
Frequently asked questions
Is the <> comparison case-sensitive? No. In a spreadsheet formula, "North"<>"north" is FALSE, so a plain <> rule treats them as equal. Use NOT(EXACT($B2,$C2)) when case matters.
Why is a blank cell not flagged against a zero? Spreadsheet comparisons treat an empty cell as equal to 0 and to an empty string. If blank and zero must count as different — a missing value versus a real zero — test ISBLANK on both sides as well.
How do I ignore tiny rounding differences? Compare numbers with a tolerance: ABS($B2-$C2)>0.005 flags only differences larger than half a cent. Wrap it in an IF with ISNUMBER so text columns still use a plain comparison.
Can a conditional format compare with another sheet? Yes. Current versions of Excel accept references to other sheets in conditional formatting formulas, such as $B2<>'Last month'!$B2. Very old versions (before 2010) required a defined name instead.
Related
- Up one level: Applying Conditional Formatting With openpyxl — every rule type.
- Reconcile Excel Totals Against Control Totals with Python — reconciling at the total level.
- Find Rows in One Excel File Missing from Another — the rows that have no partner at all.
- Highlight Duplicate Values in Excel with openpyxl — another rule for data checks.
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