Find Rows in One Excel File Missing from Another
Two files that should agree, and a number that does not. The question is always the same: which rows are in one and not the other, and which are in both but different? pandas answers it with a single merge — as long as the keys match, which on real spreadsheet data they frequently do not. This guide covers the reconciliation itself, the invisible key mismatches that make it lie, and the exceptions workbook that turns the result into something a colleague can act on. It extends Merging and Joining Excel DataFrames.
Prerequisites
pip install pandas openpyxl xlsxwriter
Two files that nearly agree:
import pandas as pd
pd.DataFrame({
"order_id": ["A-1001", "A-1002", "A-1003", "A-1004"],
"region": ["North", "South", "West", "North"],
"revenue": [5150.00, 4268.50, 3511.25, 2980.10],
}).to_excel("system.xlsx", index=False)
pd.DataFrame({
"order_id": ["A-1001", "A-1002 ", "A-1004", "A-1005"],
"region": ["North", "South", "North", "East"],
"revenue": [5150.00, 4268.50, 2980.10, 1820.00],
}).to_excel("ledger.xlsx", index=False)
A-1003 is genuinely missing from the ledger, A-1005 is genuinely extra — and A-1002 has a trailing space that will make it look missing from both directions unless the keys are normalised.
Step 1 — Normalise the keys first
Skip this and the reconciliation reports differences that are not real:
import re
import pandas as pd
def normalise_key(series):
"""A comparison key that survives whitespace, case and formatting drift."""
text = series.astype("string")
text = text.str.replace(" ", " ", regex=False) # non-breaking space
text = text.str.replace(r"\s+", " ", regex=True).str.strip()
return text.str.casefold()
system = pd.read_excel("system.xlsx")
ledger = pd.read_excel("ledger.xlsx")
system["key"] = normalise_key(system["order_id"])
ledger["key"] = normalise_key(ledger["order_id"])
Numeric keys read as text on one side and numbers on the other are the other frequent culprit — 1001 and 1001.0 will not match:
def normalise_numeric_key(series):
"""Canonicalise an identifier that may arrive as text or as a number."""
numbers = pd.to_numeric(series, errors="coerce")
as_text = series.astype("string").str.strip()
# Where it parsed as a whole number, use the integer form.
return numbers.map(
lambda v: str(int(v)) if pd.notna(v) and float(v).is_integer() else None
).fillna(as_text)
The wider treatment of invisible text differences is in stripping whitespace and normalising text columns.
Step 2 — Check the keys are unique
A merge on a duplicated key multiplies rows, and the resulting counts are meaningless:
import pandas as pd
def check_unique(df, key, label):
duplicated = df[key].duplicated(keep=False)
if duplicated.any():
counts = df.loc[duplicated, key].value_counts()
raise ValueError(
f"{label}: {int(duplicated.sum())} rows share a key. "
f"Worst offenders:\n{counts.head().to_string()}"
)
return True
check_unique(system, "key", "system.xlsx")
check_unique(ledger, "key", "ledger.xlsx")
Raising is usually right — a duplicated key in a file that should have unique ones is itself a finding. Where duplicates are legitimate, aggregate before comparing, or extend the key with the column that distinguishes them.
Step 3 — The reconciliation
One outer merge answers both directions:
import pandas as pd
merged = system.merge(
ledger, on="key", how="outer", suffixes=("_system", "_ledger"),
indicator=True,
)
only_system = merged.loc[merged["_merge"] == "left_only"]
only_ledger = merged.loc[merged["_merge"] == "right_only"]
in_both = merged.loc[merged["_merge"] == "both"]
print(f"only in system: {len(only_system)}")
print(f"only in ledger: {len(only_ledger)}")
print(f"in both: {len(in_both)}")
Two mistakes to avoid. Using how="left" answers only one direction — the rows added on the other side stay invisible, which is exactly the case where a total is too high rather than too low. And comparing raw sets of order IDs rather than merging loses every other column, so you know that a row is missing but not what it contained.
Step 4 — Find the rows that differ
The both group is where the subtler problems live: matching keys, different values.
import numpy as np
import pandas as pd
def find_changes(merged, columns, tolerance=0.005):
"""Rows present in both files whose compared values differ."""
both = merged.loc[merged["_merge"] == "both"].copy()
differs = pd.Series(False, index=both.index)
details = {}
for name in columns:
left, right = f"{name}_system", f"{name}_ledger"
if left not in both or right not in both:
continue
if pd.api.types.is_numeric_dtype(both[left]):
gap = (both[left] - both[right]).abs()
changed = gap > tolerance
details[f"{name}_delta"] = np.where(changed, both[left] - both[right],
np.nan)
else:
changed = both[left].astype("string").fillna("") != \
both[right].astype("string").fillna("")
differs |= changed
details[f"{name}_changed"] = changed
result = both.loc[differs].assign(
**{k: pd.Series(v, index=both.index)[differs] for k, v in details.items()}
)
return result
changes = find_changes(merged, ["region", "revenue"])
print(f"{len(changes)} row(s) differ")
The numeric tolerance is not optional. Floating-point values that round-tripped through Excel differ in the fifteenth decimal place, and an exact comparison reports every single row as changed — which is worse than reporting none, because it buries the real differences.
Step 5 — Write an exceptions workbook
Three sheets and a summary give a colleague everything needed to act:
import pandas as pd
def reconciliation_report(only_left, only_right, changes, dest,
left_name="system", right_name="ledger"):
"""Write a three-way reconciliation as a formatted workbook."""
summary = pd.DataFrame({
"finding": [f"only in {left_name}", f"only in {right_name}", "changed"],
"rows": [len(only_left), len(only_right), len(changes)],
})
with pd.ExcelWriter(dest, engine="xlsxwriter") as writer:
summary.to_excel(writer, sheet_name="Summary", index=False)
only_left.to_excel(writer, sheet_name=f"Only in {left_name}"[:31],
index=False)
only_right.to_excel(writer, sheet_name=f"Only in {right_name}"[:31],
index=False)
changes.to_excel(writer, sheet_name="Changed", index=False)
book = writer.book
header = book.add_format({"bold": True, "bg_color": "#EEF2FF",
"border": 1})
for name, frame in [("Summary", summary),
(f"Only in {left_name}"[:31], only_left),
(f"Only in {right_name}"[:31], only_right),
("Changed", changes)]:
sheet = writer.sheets[name]
for position, column in enumerate(frame.columns):
sheet.write(0, position, str(column), header)
sheet.set_column(position, position, 18)
sheet.freeze_panes(1, 0)
if len(frame):
sheet.autofilter(0, 0, len(frame), len(frame.columns) - 1)
return dest
reconciliation_report(only_system, only_ledger, changes, "reconciliation.xlsx")
Leading with a summary sheet matters: a reader wants the three counts before the detail, and a report that opens on eighteen rows of exceptions with no context invites the question "out of how many?". The multi-sheet mechanics are covered in adding a summary sheet to an Excel report.
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
| Rows report missing from both sides | Key differs invisibly | Normalise both keys before merging. |
| Row count explodes after the merge | Duplicated key on one side | Check uniqueness; aggregate or extend the key. |
| Only one direction reported | how="left" | Use how="outer" with indicator=True. |
| Every shared row reports as changed | Exact float comparison | Compare numerics with a tolerance. |
1001 does not match 1001.0 | Number stored as text on one side | Canonicalise numeric keys. |
| Missing rows found but not their contents | Compared sets of IDs, not frames | Merge the frames, not the key columns. |
| Report is unusable | Raw dump with no summary | Lead with counts, then the detail sheets. |
Performance and scale notes
merge builds a hash index over the keys, so it is roughly linear and handles a few million rows comfortably. The costs sit around it.
Read only what you compare. A reconciliation on four columns has no reason to load forty:
COMPARE = ["order_id", "region", "revenue"]
system = pd.read_excel("system.xlsx", usecols=COMPARE)
ledger = pd.read_excel("ledger.xlsx", usecols=COMPARE)
Normalise the distinct key values, not every row. Where the key is low-cardinality — a product or account code — cleaning the unique values and mapping is far cheaper than cleaning a million strings.
Use validate to fail fast. pandas will check the join cardinality for you, which is cheaper and clearer than discovering the row explosion afterwards:
merged = system.merge(
ledger, on="key", how="outer", indicator=True,
suffixes=("_system", "_ledger"),
validate="one_to_one", # raises immediately if either side duplicates
)
For genuinely large files, compare hashes rather than values. Hashing each row to a single digest turns a wide comparison into a one-column one, which both reads and merges faster:
import hashlib
import pandas as pd
def row_digest(df, columns):
joined = df[columns].astype("string").fillna("").agg("|".join, axis=1)
return joined.map(lambda s: hashlib.md5(s.encode()).hexdigest())
system["digest"] = row_digest(system, ["region", "revenue"])
ledger["digest"] = row_digest(ledger, ["region", "revenue"])
changed_keys = system.merge(ledger, on="key")
changed_keys = changed_keys.loc[
changed_keys["digest_x"] != changed_keys["digest_y"], "key"
]
That identifies which rows changed cheaply; fetch the details only for those. Where either file is too large to hold at all, the chunked reading approach in reading large Excel files in chunks lets you build a key-to-digest mapping from one side and stream the other against it.
Conclusion
Reconciling two spreadsheets is one outer merge with indicator=True — surrounded by the work that makes its answer true. Normalise both keys so whitespace and formatting cannot manufacture differences, confirm the keys are unique before merging so the counts mean something, and compare numeric values with a tolerance so floating-point noise does not flag every row. Report all three findings, not two: added, removed, and the matched rows whose values changed. Then lead the workbook with a summary, because the counts are what a reader needs before the detail.
Frequently asked questions
What is the quickest way to find rows in A that are not in B?
Merge them on the key with how="left" and indicator=True, then keep the rows whose merge indicator is left_only. It is one pass and it reports both directions when you use how="outer" instead.
Why do rows show as missing when I can see them in both files? The keys differ invisibly — trailing whitespace, a non-breaking space, different case, or a number stored as text on one side. Normalise both keys into a comparison column before merging.
How do I compare on more than one column?
Pass a list to the on argument, or build a single composite key by joining the normalised parts with a separator that cannot appear in the values, such as a vertical bar.
What if the key is not unique? A merge on a duplicated key multiplies rows. Check for duplicates first and decide deliberately — aggregate them, keep the latest, or treat the duplication itself as the finding.
Can I compare the values as well as the keys? Yes. Merge on the key with suffixes, then compare the value columns pairwise to classify each matched row as identical or changed. That turns a two-way difference into a three-way one: added, removed and changed.
Related
- Up to the parent: Merging and Joining Excel DataFrames — the join mechanics this builds on.
- Compare Two Excel Files for Differences with Python — cell-level comparison of two whole sheets.
- Concatenate Excel Sheets with Different Columns — combining the files this compares.
- Strip Whitespace and Normalise Text Columns with pandas — the key normalisation this depends on.
- Find Duplicate Rows in Excel with Python — the uniqueness check before merging.