Merge on Keys That Differ in Case and Spacing with pandas
The orders file and the product master both have a product code. orders.merge(products, on="SKU") should match every order, and matches about half. Look at the unmatched rows and the codes look identical — until you notice that one side has "SKU-001 " with a trailing space, the other "sku-001" in lower case; one export dropped the leading zeros from numeric IDs; and one cell copied from a web page contains a non-breaking space that looks exactly like a normal one. merge compares keys exactly, character for character, so every one of these is a non-match. The fix is a normalised join key on both sides, used for matching only, with the original values kept for display. This page belongs to Merging and Joining Excel DataFrames.
Prerequisites
pip install pandas openpyxl
merge_inputs.xlsx has an Orders sheet, a Products master and a Customers master. The order SKUs carry every common defect — trailing space, lower case, a non-breaking space, a zero-width space at the end, a space instead of a hyphen — plus one genuinely unknown code. The customer numbers sometimes lost their leading zeros. And the product master has a trap of its own: Sku-006 and SKU-006 are two rows that differ only in case.
Step 1 — See how much an exact merge misses
import pandas as pd
book = pd.read_excel("merge_inputs.xlsx", sheet_name=None, dtype=str)
orders, products, customers = book["Orders"], book["Products"], book["Customers"]
exact = orders.merge(products, on="SKU", how="left", indicator=True)
print(exact[["OrderID", "SKU", "Product", "_merge"]].assign(SKU=exact["SKU"].map(repr)).to_string(index=False))
print(exact["_merge"].value_counts().to_dict())
Reading everything as text (dtype=str) prevents pandas from turning 00412 into the number 412 before you get a chance to decide. indicator=True adds a _merge column saying whether each row matched. Printing the keys with repr reveals what the eye cannot: 'SKU\xa0003' contains a non-breaking space and 'SKU-004' a zero-width space. None of the seven orders matched — not even SKU-001, whose trailing space is enough to make it different.
Step 2 — Build a normalised key
import re
import unicodedata
def norm_key(value):
s = unicodedata.normalize("NFKC", str(value))
s = s.replace("", "").replace("", "") # zero-width space, BOM
s = re.sub(r"[\s\-_]+", "", s) # spaces (incl. NBSP after NFKC), hyphens, underscores
return s.casefold()
for raw in ["SKU-001 ", "sku-002", "SKU 003", "SKU-004", "SKU 005"]:
print(repr(raw), "->", norm_key(raw))
Each step removes one class of difference. NFKC normalisation turns lookalike characters — the non-breaking space, full-width letters pasted from some systems — into their plain forms. The zero-width characters are removed explicitly because no normalisation touches them. Spaces, hyphens and underscores are dropped entirely, so SKU 005, SKU-005 and SKU_005 agree. casefold is a stronger lower that also handles characters such as the German ß. The rule must be the same function on both sides — that is what makes the keys comparable.
Step 3 — Check for collisions before merging
Normalising can make two different master records identical. In the product master, Sku-006 (an old record) and SKU-006 both become sku006. Merging on that key would duplicate the matching order. Find such collisions first:
products["Key"] = products["SKU"].map(norm_key)
collisions = products[products["Key"].duplicated(keep=False)].sort_values("Key")
print(collisions.to_string(index=False))
The master has two rows for sku006, and they disagree on the product name. That is a data-quality problem for the owner of the master, not something a script should guess at. Resolve it — here by keeping the current record — and enforce uniqueness on every later run with validate:
products_clean = products[products["SKU"] != "Sku-006"]
orders["Key"] = orders["SKU"].map(norm_key)
joined = orders.merge(products_clean[["Key", "Product"]], on="Key", how="left",
validate="many_to_one", indicator=True)
print(joined[["OrderID", "SKU", "Product", "_merge"]].to_string(index=False))
validate="many_to_one" raises a MergeError if the right-hand keys are not unique, so a new collision in next month's master stops the job instead of silently doubling orders. Now six of the seven orders match; the original SKU column is untouched for display. More on merge validation in Validate a Merge to Avoid Duplicate Rows.
Step 4 — Numeric IDs that lost their leading zeros
Customer numbers are a different kind of key: 412, 00412 and 0412 should match, but 412 and 4120 must not. Pad every ID to the master's width:
width = customers["CustomerNo"].str.len().max()
customers["Key"] = customers["CustomerNo"].str.strip().str.zfill(width)
orders["CustKey"] = orders["CustomerNo"].str.strip().str.zfill(width)
with_customers = joined.merge(customers[["Key", "Customer"]].rename(columns={"Key": "CustKey"}),
left_on=orders["CustKey"], right_on="CustKey", how="left")
print(with_customers[["OrderID", "CustomerNo", "Customer"]].to_string(index=False))
zfill pads with zeros on the left to the master's five digits, so 412 becomes 00412 and matches Müller GmbH. Order 7's customer 2001 pads to 02001 and matches Oakridge Farms. Use this only for keys that are genuinely numeric with lost padding; applying it to alphanumeric codes would invent matches. Leading zeros on the way in are covered in Keep Leading Zeros When Reading Excel with pandas.
Step 5 — Report what still does not match
Whatever remains unmatched after normalisation is a real difference, and the people who own the data need to see it:
unmatched = joined[joined["_merge"] == "left_only"][["OrderID", "SKU", "Qty"]]
print(unmatched.to_string(index=False))
summary = joined["_merge"].map({"both": "matched", "left_only": "unmatched"}).value_counts()
print(summary.to_dict())
Order 7 references SKU-099, which is not in the master in any spelling — a new product that has not been set up, or a typo. Listing it, rather than dropping it, keeps the totals honest; the full pattern for finding rows missing from another file is in Find Rows in One Excel File Missing from Another.
Step 6 — Keep the rule in one reusable function
Every report that joins on these codes needs the same rule, and a rule copied into five scripts drifts. Wrap normalisation, the collision check, the merge and the report into one function:
def normalised_merge(left, right, on, key_func=norm_key, how="left"):
left = left.assign(_key=left[on].map(key_func))
right = right.assign(_key=right[on].map(key_func))
dupes = right[right["_key"].duplicated(keep=False)]
if not dupes.empty:
raise ValueError(f"lookup keys collide after normalising: {sorted(dupes[on])}")
merged = left.merge(right.drop(columns=on), on="_key", how=how,
validate="many_to_one", indicator=True)
unmatched = merged.loc[merged["_merge"] == "left_only", on].tolist()
return merged.drop(columns=["_key", "_merge"]), unmatched
try:
normalised_merge(orders[["OrderID", "SKU", "Qty"]], products[["SKU", "Product"]], on="SKU")
except ValueError as exc:
print("ValueError:", exc)
result, missing = normalised_merge(orders[["OrderID", "SKU", "Qty"]], products_clean[["SKU", "Product"]], on="SKU")
print(result.to_string(index=False))
print("unmatched:", missing)
Against the raw master the function refuses to run and names the colliding codes; against the cleaned master it returns the joined orders and the list of unmatched codes in one call. Importing it from a shared module means a change to the rule — say, a new separator character that turns up in one supplier's files — applies to every report at once. The helper raises on collisions instead of picking one silently, because the right answer depends on business knowledge the script does not have.
Performance notes
Series.map with a Python function runs once per value, which is fine for tens of thousands of keys and slow for millions. For large tables, normalise the unique keys only and map back — uniq = s.unique(); lookup = dict(zip(uniq, map(norm_key, uniq))); s.map(lookup) — because codes usually repeat heavily, or use vectorised string methods (str.normalize("NFKC"), str.replace(r"[\s\-_]+", "", regex=True), str.casefold(), plus a str.replace for the zero-width characters), which perform the same steps column-wise. The merge itself is a hash join and costs the same as any other merge.
Common pitfalls
- Normalising only one side. Both sides must go through the same function.
- Overwriting the original key. Keep the raw value for display and audit; join on a separate column.
- Ignoring collisions. Normalisation can merge distinct master records; check and validate.
- Reading IDs as numbers. Leading zeros vanish before you can normalise; read keys with
dtype=str. - Over-normalising. Removing every non-alphanumeric character can make
A-12andA1-2equal; remove only the separators that are noise in your data.
Conclusion
Exact merges fail on keys that differ in case, spacing, invisible characters, separators or lost leading zeros. Read keys as text, build one normalisation function — NFKC, remove zero-width characters, drop separators, casefold, zfill for numeric IDs — and apply it to both sides into a separate key column. Check the master for keys that collide after normalising, merge with validate="many_to_one" and indicator=True, and report the rows that still do not match.
Frequently asked questions
Why does my pandas merge miss rows that look identical? merge compares keys character by character. Trailing spaces, different case, non-breaking or zero-width spaces, or different separators make keys unequal even when they look the same. Print them with repr() to see the hidden characters.
How do I merge case-insensitively in pandas? Create a key column on both DataFrames with the same normalisation — strip the text and casefold it — and merge on that column instead of the original.
How do I match IDs when one file lost the leading zeros? Read both columns as text and pad them to the same width with str.zfill(width), using the width of the master's IDs, then merge on the padded keys.
How do I stop a normalised merge from duplicating rows? Check the lookup table for keys that become duplicates after normalisation, resolve them, and merge with validate="many_to_one" so any future duplicate raises an error.
Related
- Up one level: Merging and Joining Excel DataFrames — combining sheets and files.
- Strip Whitespace and Normalise Text Columns with pandas — the same clean-up on whole columns.
- Fuzzy Match Names Between Two Excel Files with Python — when keys differ by more than formatting.
- Remove Non-Printable Characters from Excel Data — finding hidden characters.
More on joins, lookups & comparisons
- How-toCompare Two Excel Files for Differences with Python
- How-toConvert Multi-Currency Transactions with an FX Rate Table
- How-toHighlight Differences Between Two Columns with openpyxl
- How-toMap Free-Text Job Titles to Standard Categories
- How-toStandardise Category Values with a Mapping Table in pandas
- How-toApproximate-Match VLOOKUP for Tiered Rates in pandas