Replace Values in an Excel Column with pandas
Excel exports carry codes and spellings that are not what the report should show: region "N" in one system and "north" in another, status 1, 2 and 3 instead of Open, Closed and Cancelled, "n/a" and "-" typed in place of blanks. Recoding these is one of the most common cleaning jobs, and pandas offers several tools that look alike but behave differently when a value is not in your list. This guide, part of Cleaning Excel Data with pandas, shows which one to use, how to keep the mapping in a workbook where business users can maintain it, and how to catch the values nobody planned for.
Prerequisites
pip install pandas openpyxl
The examples use a small orders sheet with the usual problems:
import numpy as np
import pandas as pd
orders = pd.DataFrame({
"order": [101, 102, 103, 104, 105, 106],
"region": ["N", "north", "S", "E ", "W", "X"],
"status": [1, 2, "2", 3, 1, 9],
"notes": ["n/a", "ok", "N/A", "-", "ok", "tbc"],
"amount": [120, 45, 980, 15, 300, 60],
})
orders.to_excel("orders.xlsx", index=False)
df = pd.read_excel("orders.xlsx")
Step 1 — Corrections with replace
Series.replace with a dictionary changes the listed values and leaves every other value alone. That makes it the right tool for fixing known mistakes in a column that is otherwise fine:
fixes = {"N": "North", "north": "North", "S": "South", "E": "East", "W": "West"}
print(df["region"].replace(fixes).tolist())
# ['North', 'North', 'South', 'E ', 'West', 'X']
Two values slipped through. "E " has a trailing space, so it does not equal "E"; replace matches whole cell values exactly. And "X" is not in the dictionary at all, so it is left as it was — silently. For corrections that is what you want. For a recode, where every value must end up in the new scheme, it hides problems.
Step 2 — Complete recodes with map
Series.map looks every value up and returns NaN for anything missing. Normalise the text first so that trivial differences in spacing and case do not count as missing:
key = df["region"].str.strip().str.lower()
regions = {"n": "North", "north": "North", "s": "South", "e": "East", "w": "West"}
df["region_name"] = key.map(regions)
print(df["region_name"].tolist())
# ['North', 'North', 'South', 'East', 'West', nan]
Now the only gap is the genuinely unknown code, "X", and it shows up as a missing value you can count and report. Normalising text before comparing is covered in depth in Strip Whitespace and Normalise Text Columns with pandas.
Step 3 — Keep the mapping in a workbook
Hard-coded dictionaries go stale. When the codes belong to the business — region codes, product families, cost centres — keep the mapping in a sheet the owners can edit, and load it at run time:
mapping = pd.DataFrame({
"code": ["n", "north", "s", "e", "w", "s"],
"region": ["North", "North", "South", "East", "West", "Southern"],
})
mapping.to_excel("region_map.xlsx", index=False)
table = pd.read_excel("region_map.xlsx", dtype=str)
table["code"] = table["code"].str.strip().str.lower()
dupes = table[table["code"].duplicated(keep=False)]
if not dupes.empty:
print("conflicting rules:\n", dupes)
lookup = table.drop_duplicates("code", keep="first").set_index("code")["region"]
df["region_name"] = key.map(lookup)
map accepts a Series directly, so there is no need to build a dictionary. The duplicate check matters: someone has added a second rule for "s", and dict(zip(...)) would quietly keep the last one. Report conflicts and decide which rule wins rather than letting row order decide.
Step 4 — Report the values no rule covered
After a recode, list the source values that ended up empty, with counts, so the mapping sheet can be extended:
unmapped = df.loc[df["region_name"].isna(), "region"].value_counts()
print(unmapped.to_dict()) # {'X': 1}
if len(unmapped):
unmapped.rename("rows").to_frame().to_excel("unmapped_regions.xlsx")
Write the report next to the output, or fail the run if the column must be complete. Either way, the owner of the mapping sees exactly which codes to add — far better than discovering a blank region in a published report.
Step 5 — Match the key type to the column type
The status column in the workbook mixes real numbers with a text cell, "2". By default read_excel infers a type for each column and turns numeric-looking text into numbers, so a plain read gives integers and an integer-keyed dictionary works. Many pipelines, though, read code columns with dtype=str to protect leading zeros in IDs — and then every key misses, without an error:
status_names = {1: "Open", 2: "Closed", 3: "Cancelled"}
as_text = pd.read_excel("orders.xlsx", dtype={"status": str})["status"]
print(as_text.replace(status_names).tolist())
# ['1', '2', '2', '3', '1', '9']
status = pd.to_numeric(as_text.str.strip(), errors="coerce").astype("Int64")
print(status.map(status_names).tolist())
# ['Open', 'Closed', 'Closed', 'Cancelled', 'Open', nan]
Python treats 2 and "2" as different values, so decide on one type for the column, convert it explicitly, and write the mapping keys in that type. errors="coerce" turns anything that is not a number into a missing value, which then shows up in the unmapped report alongside the unknown status 9. If the codes are genuinely text — "007" must stay distinct from "7" — keep the column as text and write the keys as strings instead. The general problem is covered in Convert Excel Text Columns to Numbers with pandas.
Step 6 — Placeholders and partial text with regex
Placeholder text such as "n/a", "N/A", "-" and "tbc" is best turned into real missing values with one pattern. Pass regex=True and anchor the pattern so it matches whole cells only:
df["notes"] = df["notes"].replace(r"(?i)^\s*(n/?a|-|tbc)\s*$", np.nan, regex=True)
print(df["notes"].tolist()) # [nan, 'ok', nan, nan, 'ok', nan]
Without ^ and $, the pattern would also match "-" inside "re-order". To change text within a cell — "Ltd." to "Limited" in company names — use Series.str.replace, which works on substrings, and pass regex=False so the dot is literal:
names = pd.Series(["Acme Ltd.", "Birch Ltd", "Ltd. Holdings"])
print(names.str.replace("Ltd.", "Limited", regex=False).tolist())
# ['Acme Limited', 'Birch Ltd', 'Limited Holdings']
Step 7 — Different rules per column
DataFrame.replace accepts a nested dictionary keyed by column name, so one call can apply separate rules to separate columns without touching the others:
cleaned = df.replace({
"region": {"N": "North", "north": "North"},
"notes": {"ok": "OK"},
})
print(cleaned[["region", "notes"]].head(2).values.tolist())
# [['North', nan], ['North', 'OK']]
This is safer than a flat dictionary on the whole frame, which would also replace a stray "N" in any other text column.
Step 8 — Numeric bands with case_when
Replacing ranges of numbers — order size bands, age groups — is not a lookup at all. Series.case_when (pandas 2.2 and later) applies conditions in order, like nested IF formulas in Excel:
amount = df["amount"]
df["size"] = pd.Series("large", index=df.index).case_when([
(amount < 50, "small"),
(amount < 500, "medium"),
])
print(df["size"].tolist()) # ['medium', 'small', 'large', 'small', 'medium', 'medium']
The first matching condition wins, so list them from narrowest to widest. Excel-style IF logic in general is covered in Excel IF Formulas as pandas Conditional Columns.
Common pitfalls
- Expecting chained rules.
replace({"A": "B", "B": "C"})turns A into B and B into C in a single pass; it does not turn A into C. If you need chaining, callreplacetwice. - Replacing on the whole frame. A flat dictionary passed to
DataFrame.replacechanges matching values in every column. Target columns explicitly. - Unanchored regexes. A pattern without
^…$replaces fragments of longer values — and withregex=Trueonreplace, only the matched part is substituted. - Case-only differences. "North" and "NORTH" are different keys. Lower-case both the column and the mapping codes, as in Step 2, rather than listing every spelling.
- Leaving the result as text. A recoded status column read by another tool can still be sorted alphabetically. Use an ordered categorical when the order matters.
Conclusion
Use replace for corrections that should leave everything else alone, map for complete recodes where a missing rule must show up as a gap, str.replace for text inside cells and case_when for numeric bands. Normalise text and types before matching, keep business mappings in a workbook with a duplicate check, and always report the values no rule covered before the output is written.
Frequently asked questions
What is the difference between replace and map in pandas? replace changes only the values that appear in your dictionary and leaves everything else untouched. map looks every value up and returns NaN for anything missing from the dictionary. Use replace for corrections and map for complete recodes where unmapped values should stand out.
Why did replace not change part of a string? Series.replace matches whole cell values by default. To replace text inside a cell, use Series.str.replace, or pass regex=True to replace with a pattern.
Does replace apply rules one after another? No. All the pairs in one dictionary are applied at the same time to the original values, so {"A":"B","B":"C"} turns A into B and B into C, not A into C.
How do I replace values in only some columns? Pass a nested dictionary to DataFrame.replace, keyed by column name, or call replace on each column you want to change.
Related
- Up one level: Cleaning Excel Data with pandas — the full cleaning toolkit.
- Split a Column into Multiple Columns with pandas — the next step when one cell holds several values.
- Pandas: Drop Duplicates From an Excel Column — deduplicating once the values are consistent.
- INDEX MATCH Equivalent in pandas — lookups that return more than one column.
More on data cleaning
- How-toFind Duplicate Rows in Excel with Python
- How-toHighlight Duplicate Values in Excel with openpyxl
- How-toClean Mixed-Currency Amounts into Amount and Currency Columns
- How-toCoalesce Several Excel Columns into One with pandas
- How-toConvert Currency Strings Like "$1,234.56" and "(500)" to Numbers with pandas
- How-toConvert Excel Text Columns to Numbers with pandas