Strip Whitespace and Normalise Text Columns with pandas
Two spreadsheets both say North. The join matches nothing. This is the single most common data-cleaning problem coming out of Excel, and it is invisible by construction: a trailing space, a non-breaking space pasted from a web page, or an accented character stored two different ways all render identically on screen and compare as different strings. This guide covers finding them, fixing them, and building a normalised key column that makes joins and group-bys reliable. It is part of Cleaning Excel Data with pandas.
Prerequisites
pip install pandas openpyxl
A frame with every problem in it:
import pandas as pd
df = pd.DataFrame({
"region": ["North Region", "North Region ", "North Region",
"North Region", "north region", "Nörth Region"],
"revenue": [5150.00, 4268.50, 3511.25, 2980.10, 3140.75, 1820.00],
})
print(df["region"].nunique()) # 6 — every one is distinct
Step 1 — See what is actually there
Never diagnose whitespace by eye. repr shows the escapes:
for value in df["region"]:
print(repr(value))
# 'North Region'
# 'North Region '
# 'North\xa0Region'
# 'North Region'
For a systematic view, count the values that would change under cleaning:
import pandas as pd
def whitespace_report(series):
"""Summarise the invisible problems in a text column."""
text = series.astype("string")
return {
"values": len(text),
"distinct": int(text.nunique()),
"leading or trailing space": int((text != text.str.strip()).sum()),
"double inner space": int(text.str.contains(r"\s{2,}", na=False).sum()),
"non-breaking space": int(text.str.contains(" ", na=False).sum()),
"zero-width or BOM": int(
text.str.contains("[]", na=False).sum()
),
}
print(whitespace_report(df["region"]))
Each line of that report maps to a specific fix, which is why it is worth producing before touching anything. Leading and trailing spaces come from manual entry and from exports that pad to a fixed width. Double inner spaces usually come from concatenation in the source system — a first and last name joined with a space where the first name already ended in one. Non-breaking spaces almost always arrive by copy-and-paste from a web page or a PDF, and they cluster in exactly the columns somebody assembled by hand. Zero-width characters and the byte-order mark come from encoding round-trips, and are the hardest to spot because they occupy no visual width at all.
Running that on every text column of an import turns a mystery into a checklist:
text_columns = df.select_dtypes(include=["object", "string"]).columns
for name in text_columns:
print(name, whitespace_report(df[name]))
Step 2 — Clean in the right order
Order matters. Strip alone leaves the non-breaking space in the middle, and collapsing before replacing does not touch it because \s in the regex engine does not always match \xa0 in a byte-oriented context. Replace the specific characters first, then collapse, then strip:
import pandas as pd
INVISIBLE = {
" ": " ", # non-breaking space
" ": " ", # figure space
" ": " ", # narrow no-break space
"": "", # zero-width space
"": "", # zero-width non-joiner
"": "", # zero-width joiner
"": "", # byte-order mark
}
def clean_text(series):
"""Normalise whitespace in a text column, preserving None."""
text = series.astype("string")
for bad, good in INVISIBLE.items():
text = text.str.replace(bad, good, regex=False)
text = text.str.replace(r"\s+", " ", regex=True) # collapse runs
return text.str.strip()
df["region_clean"] = clean_text(df["region"])
print(df["region_clean"].nunique()) # 3, down from 6
astype("string") rather than astype(str) is deliberate: the nullable string dtype keeps missing values as <NA>, whereas astype(str) turns them into the literal text "nan", which then survives every subsequent clean and quietly becomes a category.
Step 3 — Build a comparison key
Cleaning whitespace leaves case and accents. For a key used to join or group, fold both — but keep the original for display:
import unicodedata
import pandas as pd
def comparison_key(series):
"""A normalised key for joining and grouping. Not for display."""
text = clean_text(series)
# NFKC folds compatibility forms and composes accents consistently.
text = text.map(
lambda v: unicodedata.normalize("NFKC", v) if pd.notna(v) else v
)
return text.str.casefold()
df["region_key"] = comparison_key(df["region"])
print(df.groupby("region_key")["revenue"].sum())
Two choices worth understanding. casefold rather than lower handles cases lower misses — the German ß folds to ss, so STRASSE and Straße match. NFKC rather than NFC additionally folds compatibility characters, so a full-width N pasted from a Japanese-locale system matches an ordinary N.
Stripping accents entirely is a further step, and one to take deliberately rather than by default — it makes Nörth and North match, which is right for a fuzzy lookup and wrong if the two are genuinely different places:
import unicodedata
def strip_accents(value):
"""Remove combining marks: 'Nörth' -> 'North'. Use with care."""
decomposed = unicodedata.normalize("NFKD", str(value))
return "".join(c for c in decomposed if not unicodedata.combining(c))
Step 4 — Keep the original alongside the key
The pattern that works in a real pipeline is three columns, not one: the value as supplied, a cleaned display version, and a key.
import pandas as pd
def add_text_key(df, column):
"""Add cleaned and key variants of a text column, keeping the original."""
out = df.copy()
out[f"{column}_clean"] = clean_text(out[column])
out[f"{column}_key"] = comparison_key(out[column])
return out
df = add_text_key(df, "region")
# Join on the key; report on the clean value.
summary = (
df.groupby("region_key")
.agg(display=("region_clean", "first"), revenue=("revenue", "sum"))
.reset_index(drop=True)
)
print(summary)
Joining two files then becomes reliable, because both sides are folded the same way:
left = add_text_key(pd.read_excel("sales.xlsx"), "region")
right = add_text_key(pd.read_excel("targets.xlsx"), "region")
merged = left.merge(right, on="region_key", how="left",
suffixes=("", "_target"), indicator=True)
unmatched = merged.loc[merged["_merge"] == "left_only", "region_clean"].unique()
if len(unmatched):
print("still unmatched after normalising:", list(unmatched))
The indicator=True and the unmatched report matter — normalising fixes the invisible mismatches and leaves the genuine ones, which are exactly the rows worth a human look. The join mechanics are covered in merging two Excel files on a common column.
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
| Join matches nothing | Trailing or non-breaking space | Clean both sides into a key column. |
| Group-by shows near-duplicate groups | Case or whitespace variants | Group on a case-folded key. |
Literal nan values appear | astype(str) on a column with NaN | Use astype("string"). |
str.strip() left the value unchanged | Non-breaking space, not a normal one | Replace explicitly first. |
| Accented names still differ | Two Unicode representations | unicodedata.normalize("NFKC", ...). |
STRASSE does not match Straße | lower does not fold ß | Use casefold. |
| Report shows lower-cased names | Key column used for display | Keep a separate cleaned display column. |
| An invisible character survives cleaning | Not in the replacement map | Print repr and add it. |
Performance and scale notes
pandas string operations are vectorised but run in Python for object dtype. Two changes make a large clean substantially faster.
Use the nullable string dtype, or the Arrow-backed variant where available. It stores data more compactly and dispatches to faster kernels:
import pandas as pd
df["region"] = df["region"].astype("string[pyarrow]") # if pyarrow installed
Combine the replacements into one pass. Six sequential str.replace calls each walk the column; a single translation table walks it once:
TRANSLATION = str.maketrans({
" ": " ", " ": " ", " ": " ",
"": "", "": "", "": "", "": "",
})
def clean_text_fast(series):
text = series.astype("string")
text = text.map(lambda v: v.translate(TRANSLATION) if pd.notna(v) else v)
return text.str.replace(r"\s+", " ", regex=True).str.strip()
A third habit matters more than either: clean on the distinct values, not on every row. A million-row column of region names holds perhaps twenty distinct values, and cleaning twenty strings then mapping is orders of magnitude cheaper:
import pandas as pd
def clean_via_lookup(series):
"""Clean each distinct value once, then map."""
distinct = pd.Series(series.dropna().unique())
lookup = dict(zip(distinct, clean_text(distinct)))
return series.map(lookup)
That trick applies to any per-value transformation on a low-cardinality column, and it is the same reasoning behind deduplicating before parsing dates in parsing Excel dates with pandas. Where the column genuinely has high cardinality — free-text notes, for instance — the lookup gains nothing, and the vectorised path is the right one.
Conclusion
Whitespace problems from Excel are invisible by definition, so diagnose with repr and a report rather than by eye. Clean in order: replace the specific invisible characters, collapse runs of whitespace, then strip. Build a separate key column that is additionally Unicode-normalised and case-folded, use it for every join and group-by, and keep the original untouched so reports show what was actually supplied. Then clean the distinct values rather than every row, and a million-row column costs no more than a twenty-value one.
Frequently asked questions
Why does my join fail when the values look identical?
One side almost certainly has trailing whitespace or a non-breaking space. Both render as a normal gap, so the values look the same on screen while comparing as different strings. Print the repr of a failing value to see what is really there.
Does str.strip remove non-breaking spaces?
Not by default in older pandas versions, and it is safest not to rely on it. Replace the specific characters first — non-breaking space, zero-width space and the byte-order mark — then strip.
Should I use lower or casefold?casefold for comparison keys, because it handles cases lower misses, such as the German sharp s folding to a double s. Use lower only when you are producing text for display.
Why do accented characters compare as different?
The same character can be stored as one code point or as a base letter plus a combining accent. Normalise with unicodedata.normalize to NFC or NFKC so both forms become identical before comparing.
Should I clean the values or keep the originals? Keep both. Clean into a new key column used for joining and grouping, and leave the original for display, so a report still shows the value exactly as it was supplied.
Related
- Up to the parent: Cleaning Excel Data with pandas — the wider cleaning toolkit.
- Convert Excel Text Columns to Numbers with pandas — the numeric equivalent of this problem.
- pandas: Drop Duplicates from an Excel Column — deduplicating once the values are normalised.
- Merge Two Excel Files on a Common Column with Python — the join that whitespace was breaking.
- Validate Excel Columns Before Import with pandas — catching the problem at the boundary.